refactor: add gateway method dispatch contract

This commit is contained in:
Peter Steinberger
2026-05-15 11:28:30 +01:00
parent cd9b2c0af4
commit dfeaf6f7cf
16 changed files with 273 additions and 10 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/gateway-method-runtime.js";

View File

@@ -95,6 +95,7 @@
"secret-ref-runtime",
"secret-file-runtime",
"security-runtime",
"gateway-method-runtime",
"gateway-runtime",
"cli-runtime",
"cli-backend",

View File

@@ -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<string, unknown>;
};
function unwrapGatewayMethodDispatchResponse(
@@ -322,11 +325,11 @@ function unwrapGatewayMethodDispatchResponse(
return response.payload;
}
async function dispatchGatewayMethod<T>(
export async function dispatchGatewayMethodInProcessRaw(
method: string,
params: Record<string, unknown>,
params: unknown,
options?: DispatchGatewayMethodInProcessOptions,
): Promise<T> {
): Promise<GatewayMethodDispatchResponse> {
const scope = getPluginRuntimeGatewayRequestScope();
const context = scope?.context ?? getFallbackGatewayContext();
const isWebchatConnect = scope?.isWebchatConnect ?? (() => false);
@@ -335,6 +338,11 @@ async function dispatchGatewayMethod<T>(
`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<T>(
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<T>(
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<T>(
}
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<T>(
resolve(response);
};
}));
return unwrapGatewayMethodDispatchResponse(method, final) as T;
return final;
}
async function dispatchGatewayMethod<T>(
method: string,
params: unknown,
options?: DispatchGatewayMethodInProcessOptions,
): Promise<T> {
const response = await dispatchGatewayMethodInProcessRaw(method, params, options);
return unwrapGatewayMethodDispatchResponse(method, response) as T;
}
export async function dispatchGatewayMethodInProcess<T>(

View File

@@ -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<boolean>;
}) {
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");

View File

@@ -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),
);

View File

@@ -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[];

View File

@@ -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,
},
);
});
});

View File

@@ -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<string, unknown>;
};
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<GatewayMethodDispatchResponse> {
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 } : {}),
});
}

View File

@@ -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[] = [];

View File

@@ -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]);

View File

@@ -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;

View File

@@ -98,6 +98,7 @@ export type PluginHttpRouteRegistration = {
auth: OpenClawPluginHttpRouteAuth;
match: OpenClawPluginHttpRouteMatch;
gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
gatewayMethodDispatchAllowed?: boolean;
nodeCapability?: {
surface: string;
ttlMs?: number;

View File

@@ -184,6 +184,9 @@ import type {
export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistration & {
gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
};
const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request";
type PluginOwnedProviderRegistration<T extends { id: string }> = {
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,
});

View File

@@ -11,6 +11,7 @@ export type PluginRuntimeGatewayRequestScope = {
isWebchatConnect: GatewayRequestOptions["isWebchatConnect"];
pluginId?: string;
pluginSource?: string;
gatewayMethodDispatchAllowed?: boolean;
};
export type PluginRuntimePluginScope = {