diff --git a/package.json b/package.json index a114f892b64..00e9ddffaed 100644 --- a/package.json +++ b/package.json @@ -480,6 +480,10 @@ "types": "./dist/plugin-sdk/security-runtime.d.ts", "default": "./dist/plugin-sdk/security-runtime.js" }, + "./plugin-sdk/gateway-method-runtime": { + "types": "./dist/plugin-sdk/gateway-method-runtime.d.ts", + "default": "./dist/plugin-sdk/gateway-method-runtime.js" + }, "./plugin-sdk/gateway-runtime": { "types": "./dist/plugin-sdk/gateway-runtime.d.ts", "default": "./dist/plugin-sdk/gateway-runtime.js" diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8b5b54ae0a8..0753c79f73c 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -108,6 +108,10 @@ "types": "./dist/src/plugin-sdk/cli-runtime.d.ts", "default": "./src/cli-runtime.ts" }, + "./gateway-method-runtime": { + "types": "./dist/src/plugin-sdk/gateway-method-runtime.d.ts", + "default": "./src/gateway-method-runtime.ts" + }, "./error-runtime": { "types": "./dist/src/plugin-sdk/error-runtime.d.ts", "default": "./src/error-runtime.ts" diff --git a/packages/plugin-sdk/src/gateway-method-runtime.ts b/packages/plugin-sdk/src/gateway-method-runtime.ts new file mode 100644 index 00000000000..deb78c86c29 --- /dev/null +++ b/packages/plugin-sdk/src/gateway-method-runtime.ts @@ -0,0 +1 @@ +export * from "../../../src/plugin-sdk/gateway-method-runtime.js"; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 42c251bbe70..920a38fd61d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -95,6 +95,7 @@ "secret-ref-runtime", "secret-file-runtime", "security-runtime", + "gateway-method-runtime", "gateway-runtime", "cli-runtime", "cli-backend", diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index cd51dff8142..7be0f442521 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -299,17 +299,20 @@ function mergeGatewayClientInternal( type DispatchGatewayMethodInProcessOptions = { allowSyntheticModelOverride?: boolean; + disableSyntheticClient?: boolean; expectFinal?: boolean; forceSyntheticClient?: boolean; pluginRuntimeOwnerId?: string; + requireScopedClient?: boolean; syntheticScopes?: string[]; timeoutMs?: number; }; -type GatewayMethodDispatchResponse = { +export type GatewayMethodDispatchResponse = { ok: boolean; payload?: unknown; error?: ErrorShape; + meta?: Record; }; function unwrapGatewayMethodDispatchResponse( @@ -322,11 +325,11 @@ function unwrapGatewayMethodDispatchResponse( return response.payload; } -async function dispatchGatewayMethod( +export async function dispatchGatewayMethodInProcessRaw( method: string, - params: Record, + params: unknown, options?: DispatchGatewayMethodInProcessOptions, -): Promise { +): Promise { const scope = getPluginRuntimeGatewayRequestScope(); const context = scope?.context ?? getFallbackGatewayContext(); const isWebchatConnect = scope?.isWebchatConnect ?? (() => false); @@ -335,6 +338,11 @@ async function dispatchGatewayMethod( `In-process gateway dispatch requires a gateway request scope (method: ${method}). No scope set and no fallback context available.`, ); } + if (options?.requireScopedClient === true && !scope?.client) { + throw new Error( + `In-process gateway dispatch requires an authenticated plugin request scope (method: ${method}).`, + ); + } let firstResponse: GatewayMethodDispatchResponse | undefined; let finalResponse: GatewayMethodDispatchResponse | undefined; @@ -353,6 +361,9 @@ async function dispatchGatewayMethod( scope?.client, pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : undefined, ); + if (options?.disableSyntheticClient === true && !scopedClient) { + throw new Error(`In-process gateway dispatch requires a scoped client (method: ${method}).`); + } await handleGatewayRequest({ req: { type: "req", @@ -361,10 +372,12 @@ async function dispatchGatewayMethod( params, }, client: - options?.forceSyntheticClient === true ? syntheticClient : (scopedClient ?? syntheticClient), + options?.forceSyntheticClient === true + ? syntheticClient + : (scopedClient ?? (options?.disableSyntheticClient === true ? null : syntheticClient)), isWebchatConnect, - respond: (ok, payload, error) => { - const response = { ok, payload, error }; + respond: (ok, payload, error, meta) => { + const response = { ok, payload, error, ...(meta ? { meta } : {}) }; if (!firstResponse) { firstResponse = response; return; @@ -382,7 +395,7 @@ async function dispatchGatewayMethod( } const firstPayload = firstResponse.payload as { status?: unknown } | undefined; if (options?.expectFinal !== true || firstPayload?.status !== "accepted") { - return unwrapGatewayMethodDispatchResponse(method, firstResponse) as T; + return firstResponse; } const final = finalResponse ?? @@ -412,7 +425,16 @@ async function dispatchGatewayMethod( resolve(response); }; })); - return unwrapGatewayMethodDispatchResponse(method, final) as T; + return final; +} + +async function dispatchGatewayMethod( + method: string, + params: unknown, + options?: DispatchGatewayMethodInProcessOptions, +): Promise { + const response = await dispatchGatewayMethodInProcessRaw(method, params, options); + return unwrapGatewayMethodDispatchResponse(method, response) as T; } export async function dispatchGatewayMethodInProcess( diff --git a/src/gateway/server/plugins-http.runtime-scopes.test.ts b/src/gateway/server/plugins-http.runtime-scopes.test.ts index 13fe59c883a..104d234a174 100644 --- a/src/gateway/server/plugins-http.runtime-scopes.test.ts +++ b/src/gateway/server/plugins-http.runtime-scopes.test.ts @@ -20,6 +20,7 @@ function createRoute(params: { auth: "gateway" | "plugin"; match?: "exact" | "prefix"; gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator"; + gatewayMethodDispatchAllowed?: boolean; handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise; }) { return { @@ -27,6 +28,7 @@ function createRoute(params: { path: params.path, auth: params.auth, gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface, + gatewayMethodDispatchAllowed: params.gatewayMethodDispatchAllowed, match: params.match ?? "exact", handler: params.handler ?? (() => true), source: "route", @@ -142,6 +144,51 @@ describe("plugin HTTP route runtime scopes", () => { expect(log.warn).not.toHaveBeenCalled(); }); + it("threads plugin route identity and gateway dispatch entitlement into runtime scope", async () => { + let observed: + | { + pluginId: string | undefined; + pluginSource: string | undefined; + gatewayMethodDispatchAllowed: boolean | undefined; + } + | undefined; + const handler = createGatewayPluginRequestHandler({ + registry: createTestRegistry({ + httpRoutes: [ + createRoute({ + path: "/secure-hook", + auth: "gateway", + gatewayMethodDispatchAllowed: true, + handler: async () => { + const scope = getPluginRuntimeGatewayRequestScope(); + observed = { + pluginId: scope?.pluginId, + pluginSource: scope?.pluginSource, + gatewayMethodDispatchAllowed: scope?.gatewayMethodDispatchAllowed, + }; + return true; + }, + }), + ], + }), + log: createMockLogger(), + }); + + const { res } = makeMockHttpResponse(); + const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, { + gatewayAuthSatisfied: true, + gatewayRequestOperatorScopes: ["operator.write"], + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(observed).toEqual({ + pluginId: "route", + pluginSource: "route", + gatewayMethodDispatchAllowed: true, + }); + }); + it("does not give approval-scoped gateway-auth routes global approval visibility", async () => { const manager = new ExecApprovalManager<{ command: string }>(); const record = manager.create({ command: "echo ok" }, 60_000, "route-hidden-approval"); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 95807b7c189..d31d09c37c4 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -147,6 +147,11 @@ export function createGatewayPluginRequestHandler(params: { { client: runtimeClient, isWebchatConnect: () => false, + ...(route.pluginId ? { pluginId: route.pluginId } : {}), + ...(route.source ? { pluginSource: route.source } : {}), + ...(route.gatewayMethodDispatchAllowed === true + ? { gatewayMethodDispatchAllowed: true } + : {}), }, async () => route.handler(req, res), ); @@ -243,6 +248,11 @@ export function createGatewayPluginUpgradeHandler(params: { { client: runtimeClient, isWebchatConnect: () => false, + ...(route.pluginId ? { pluginId: route.pluginId } : {}), + ...(route.source ? { pluginSource: route.source } : {}), + ...(route.gatewayMethodDispatchAllowed === true + ? { gatewayMethodDispatchAllowed: true } + : {}), }, async () => route.handleUpgrade?.(req, socket, head), ); diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index e8d5f8af31c..3c8dd49927f 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -47,6 +47,7 @@ vi.mock("./http-utils.js", () => ({ authorizeScopedGatewayHttpRequestOrReply: async () => ({ cfg: { gateway: { webchat: { chatHistoryMaxChars: 2000 } } }, requestAuth: { trustDeclaredOperatorScopes: true }, + operatorScopes: ["operator.read"], }), checkGatewayHttpRequestAuth: async (params: { trustedProxies?: string[]; diff --git a/src/plugin-sdk/gateway-method-runtime.test.ts b/src/plugin-sdk/gateway-method-runtime.test.ts new file mode 100644 index 00000000000..35c7b5cad0e --- /dev/null +++ b/src/plugin-sdk/gateway-method-runtime.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; +import { dispatchGatewayMethod } from "./gateway-method-runtime.js"; + +const { dispatchGatewayMethodInProcessRaw } = vi.hoisted(() => ({ + dispatchGatewayMethodInProcessRaw: vi.fn(), +})); + +vi.mock("../gateway/server-plugins.js", () => ({ + dispatchGatewayMethodInProcessRaw, +})); + +describe("plugin-sdk/gateway-method-runtime", () => { + it("rejects callers without the gateway method dispatch contract", async () => { + await expect( + withPluginRuntimeGatewayRequestScope( + { + pluginId: "plain-plugin", + client: { + id: "plugin", + connect: { scopes: ["operator.write"] }, + } as never, + isWebchatConnect: () => false, + }, + () => dispatchGatewayMethod("health", {}), + ), + ).rejects.toThrow( + 'contracts.gatewayMethodDispatch: ["authenticated-request"] for plugin "plain-plugin"', + ); + expect(dispatchGatewayMethodInProcessRaw).not.toHaveBeenCalled(); + }); + + it("dispatches through the scoped client for entitled plugin HTTP routes", async () => { + dispatchGatewayMethodInProcessRaw.mockResolvedValueOnce({ ok: true, payload: { ok: true } }); + + const result = await withPluginRuntimeGatewayRequestScope( + { + pluginId: "admin-http-rpc", + gatewayMethodDispatchAllowed: true, + client: { + id: "plugin", + connect: { scopes: ["operator.admin"] }, + } as never, + isWebchatConnect: () => false, + }, + () => dispatchGatewayMethod("health", {}, { timeoutMs: 500 }), + ); + + expect(result).toEqual({ ok: true, payload: { ok: true } }); + expect(dispatchGatewayMethodInProcessRaw).toHaveBeenCalledWith( + "health", + {}, + { + disableSyntheticClient: true, + requireScopedClient: true, + timeoutMs: 500, + }, + ); + }); +}); diff --git a/src/plugin-sdk/gateway-method-runtime.ts b/src/plugin-sdk/gateway-method-runtime.ts new file mode 100644 index 00000000000..7fcffa792a1 --- /dev/null +++ b/src/plugin-sdk/gateway-method-runtime.ts @@ -0,0 +1,45 @@ +import { dispatchGatewayMethodInProcessRaw } from "../gateway/server-plugins.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; + +export type GatewayMethodDispatchError = { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; +}; + +export type GatewayMethodDispatchResponse = { + ok: boolean; + payload?: unknown; + error?: GatewayMethodDispatchError; + meta?: Record; +}; + +export type GatewayMethodDispatchOptions = { + expectFinal?: boolean; + timeoutMs?: number; +}; + +/** + * Dispatch a Gateway control-plane method from an authenticated plugin request scope. + */ +export async function dispatchGatewayMethod( + method: string, + params?: unknown, + options?: GatewayMethodDispatchOptions, +): Promise { + const scope = getPluginRuntimeGatewayRequestScope(); + if (scope?.gatewayMethodDispatchAllowed !== true) { + const pluginLabel = scope?.pluginId ? ` for plugin "${scope.pluginId}"` : ""; + throw new Error( + `Gateway method dispatch is reserved for plugin HTTP routes that declare contracts.gatewayMethodDispatch: ["authenticated-request"]${pluginLabel}.`, + ); + } + return await dispatchGatewayMethodInProcessRaw(method, params, { + disableSyntheticClient: true, + requireScopedClient: true, + ...(options?.expectFinal !== undefined ? { expectFinal: options.expectFinal } : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); +} diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index e51fb01c2ca..a66559d252e 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -1,12 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { registerPluginHttpRoute } from "./http-registry.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { createPluginRegistry } from "./registry.js"; import { pinActivePluginHttpRouteRegistry, releasePinnedPluginHttpRouteRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "./runtime.js"; +import type { PluginRuntime } from "./runtime/types.js"; +import { createPluginRecord } from "./status.test-helpers.js"; function expectRouteRegistrationDenied(params: { replaceExisting: boolean; @@ -109,6 +113,51 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(0); }); + it("marks gateway method dispatch entitlement only for plugins declaring the contract", () => { + const pluginRegistry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + }); + const config = {} as OpenClawConfig; + const plainRecord = createPluginRecord({ + id: "plain-http", + source: "/plugins/plain-http/index.ts", + }); + const adminRecord = createPluginRecord({ + id: "admin-http", + source: "/plugins/admin-http/index.ts", + contracts: { gatewayMethodDispatch: ["authenticated-request"] }, + }); + + pluginRegistry.registry.plugins.push(plainRecord, adminRecord); + pluginRegistry.createApi(plainRecord, { config }).registerHttpRoute({ + path: "/plain", + auth: "gateway", + handler: vi.fn(), + }); + pluginRegistry.createApi(adminRecord, { config }).registerHttpRoute({ + path: "/admin", + auth: "gateway", + handler: vi.fn(), + }); + + const plainRoute = pluginRegistry.registry.httpRoutes.find( + (route) => route.pluginId === "plain-http", + ); + const adminRoute = pluginRegistry.registry.httpRoutes.find( + (route) => route.pluginId === "admin-http", + ); + + expect(plainRoute?.gatewayMethodDispatchAllowed).toBeUndefined(); + expect(adminRoute?.gatewayMethodDispatchAllowed).toBe(true); + }); + it("returns noop unregister when path is missing", () => { const registry = createEmptyPluginRegistry(); const logs: string[] = []; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index c29da59ed3f..ea12f738023 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -165,7 +165,8 @@ export type PluginManifestContractListKey = | "webContentExtractors" | "webFetchProviders" | "webSearchProviders" - | "migrationProviders"; + | "migrationProviders" + | "gatewayMethodDispatch"; type SeenIdEntry = { candidate: PluginCandidate; @@ -379,6 +380,7 @@ function mergeManifestContracts( "webFetchProviders", "webSearchProviders", "migrationProviders", + "gatewayMethodDispatch", "tools", ] as const) { const merged = mergeContractLists(manifestContracts?.[key], catalogContracts[key]); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e94eb8d85ba..b3c52020493 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -415,6 +415,7 @@ export type PluginManifestContracts = { webFetchProviders?: string[]; webSearchProviders?: string[]; migrationProviders?: string[]; + gatewayMethodDispatch?: string[]; tools?: string[]; }; @@ -807,6 +808,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u const webFetchProviders = normalizeTrimmedStringList(value.webFetchProviders); const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders); const migrationProviders = normalizeTrimmedStringList(value.migrationProviders); + const gatewayMethodDispatch = normalizeTrimmedStringList(value.gatewayMethodDispatch); const tools = normalizeTrimmedStringList(value.tools); const contracts = { ...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}), @@ -825,6 +827,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u ...(webFetchProviders.length > 0 ? { webFetchProviders } : {}), ...(webSearchProviders.length > 0 ? { webSearchProviders } : {}), ...(migrationProviders.length > 0 ? { migrationProviders } : {}), + ...(gatewayMethodDispatch.length > 0 ? { gatewayMethodDispatch } : {}), ...(tools.length > 0 ? { tools } : {}), } satisfies PluginManifestContracts; diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 027ce092b34..d157f6db8c0 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -98,6 +98,7 @@ export type PluginHttpRouteRegistration = { auth: OpenClawPluginHttpRouteAuth; match: OpenClawPluginHttpRouteMatch; gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface; + gatewayMethodDispatchAllowed?: boolean; nodeCapability?: { surface: string; ttlMs?: number; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 51364ce2c89..463f0e41ccc 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -184,6 +184,9 @@ import type { export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistration & { gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface; }; + +const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request"; + type PluginOwnedProviderRegistration = { pluginId: string; pluginName?: string; @@ -726,6 +729,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return `${plugin} (${source})`; }; + const canDispatchGatewayMethodsFromHttpRoute = (record: PluginRecord): boolean => + (record.contracts?.gatewayMethodDispatch ?? []).includes(GATEWAY_METHOD_DISPATCH_CONTRACT); + const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { const normalizedPath = normalizePluginHttpPath(params.path); if (!normalizedPath) { @@ -799,6 +805,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { ...(params.gatewayRuntimeScopeSurface ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface } : {}), + ...(canDispatchGatewayMethodsFromHttpRoute(record) + ? { gatewayMethodDispatchAllowed: true } + : {}), ...(params.nodeCapability ? { nodeCapability: { ...params.nodeCapability } } : {}), source: record.source, }; @@ -815,6 +824,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { ...(params.gatewayRuntimeScopeSurface ? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface } : {}), + ...(canDispatchGatewayMethodsFromHttpRoute(record) + ? { gatewayMethodDispatchAllowed: true } + : {}), ...(params.nodeCapability ? { nodeCapability: { ...params.nodeCapability } } : {}), source: record.source, }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index fbe8c1b22b6..37b76df579d 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -11,6 +11,7 @@ export type PluginRuntimeGatewayRequestScope = { isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; pluginId?: string; pluginSource?: string; + gatewayMethodDispatchAllowed?: boolean; }; export type PluginRuntimePluginScope = {