diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 59d9386cf61..2252d45e633 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -9,6 +9,16 @@ import { import { listGatewayMethods } from "./server-methods-list.js"; import { coreGatewayHandlers } from "./server-methods.js"; +const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect"; + +function setPluginGatewayMethodScope(method: string, scope: "operator.read" | "operator.write") { + const registry = createEmptyPluginRegistry(); + registry.gatewayMethodScopes = { + [method]: scope, + }; + setActivePluginRegistry(registry); +} + afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -52,13 +62,9 @@ describe("method scope resolution", () => { }); it("keeps reserved admin namespaces admin-only even if a plugin scope is narrower", () => { - const registry = createEmptyPluginRegistry(); - registry.gatewayMethodScopes = { - "config.plugin.inspect": "operator.read", - }; - setActivePluginRegistry(registry); + setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read"); - expect(resolveLeastPrivilegeOperatorScopesForMethod("config.plugin.inspect")).toEqual([ + expect(resolveLeastPrivilegeOperatorScopesForMethod(RESERVED_ADMIN_PLUGIN_METHOD)).toEqual([ "operator.admin", ]); }); @@ -119,13 +125,11 @@ describe("operator scope authorization", () => { }); it("requires admin for reserved admin namespaces even if a plugin registered a narrower scope", () => { - const registry = createEmptyPluginRegistry(); - registry.gatewayMethodScopes = { - "config.plugin.inspect": "operator.read", - }; - setActivePluginRegistry(registry); + setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read"); - expect(authorizeOperatorScopesForMethod("config.plugin.inspect", ["operator.read"])).toEqual({ + expect( + authorizeOperatorScopesForMethod(RESERVED_ADMIN_PLUGIN_METHOD, ["operator.read"]), + ).toEqual({ allowed: false, missingScope: "operator.admin", }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 7d5dbf67e6d..42540a0c6b3 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -1,5 +1,5 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; -import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js"; +import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js"; export const ADMIN_SCOPE = "operator.admin" as const; export const READ_SCOPE = "operator.read" as const; @@ -161,8 +161,9 @@ function resolveScopedMethod(method: string): OperatorScope | undefined { if (explicitScope) { return explicitScope; } - if (isReservedAdminGatewayMethod(method)) { - return ADMIN_SCOPE; + const reservedScope = resolveReservedGatewayMethodScope(method); + if (reservedScope) { + return reservedScope; } const pluginScope = getActivePluginRegistry()?.gatewayMethodScopes?.[method]; if (pluginScope) { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 668671096fd..3bb7015346f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -74,6 +74,10 @@ function memoryPluginBody(id: string) { return `module.exports = { id: ${JSON.stringify(id)}, kind: "memory", register() {} };`; } +const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect"; +const RESERVED_ADMIN_SCOPE_WARNING = + "gateway method scope coerced to operator.admin for reserved core namespace"; + function writeBundledPlugin(params: { id: string; body?: string; @@ -975,7 +979,7 @@ describe("loadOpenClawPlugins", () => { id: "reserved-gateway-scope", register(api) { api.registerGatewayMethod( - "config.plugin.inspect", + ${JSON.stringify(RESERVED_ADMIN_PLUGIN_METHOD)}, ({ respond }) => respond(true, { ok: true }), { scope: "operator.read" }, ); @@ -994,12 +998,12 @@ describe("loadOpenClawPlugins", () => { }, }); - expect(Object.keys(registry.gatewayHandlers)).toContain("config.plugin.inspect"); - expect(registry.gatewayMethodScopes?.["config.plugin.inspect"]).toBe("operator.admin"); + expect(Object.keys(registry.gatewayHandlers)).toContain(RESERVED_ADMIN_PLUGIN_METHOD); + expect(registry.gatewayMethodScopes?.[RESERVED_ADMIN_PLUGIN_METHOD]).toBe("operator.admin"); expect( registry.diagnostics.some((diag) => String(diag.message).includes( - "gateway method scope coerced to operator.admin for reserved core namespace: config.plugin.inspect", + `${RESERVED_ADMIN_SCOPE_WARNING}: ${RESERVED_ADMIN_PLUGIN_METHOD}`, ), ), ).toBe(true); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 41659c6cc9b..ffef249d23c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -9,7 +9,7 @@ import type { } from "../gateway/server-methods/types.js"; import { registerInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; -import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js"; +import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveUserPath } from "../utils.js"; import { buildPluginApi } from "./api-builder.js"; import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js"; @@ -434,20 +434,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } registry.gatewayHandlers[trimmed] = handler; - let effectiveScope = opts?.scope; - if ( - effectiveScope && - effectiveScope !== "operator.admin" && - isReservedAdminGatewayMethod(trimmed) - ) { + const normalizedScope = normalizePluginGatewayMethodScope(trimmed, opts?.scope); + if (normalizedScope.coercedToReservedAdmin) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, message: `gateway method scope coerced to operator.admin for reserved core namespace: ${trimmed}`, }); - effectiveScope = "operator.admin"; } + const effectiveScope = normalizedScope.scope; if (effectiveScope) { registry.gatewayMethodScopes ??= {}; registry.gatewayMethodScopes[trimmed] = effectiveScope; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2a67e341013..811e7076cc7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1992,6 +1992,13 @@ export type OpenClawPluginApi = { registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void; /** Register a native messaging channel plugin (channel capability). */ registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void; + /** + * Register a gateway RPC method for this plugin. + * + * Reserved core admin namespaces (`config.*`, `exec.approvals.*`, + * `wizard.*`, `update.*`) always normalize to `operator.admin` even if a + * narrower scope is requested. + */ registerGatewayMethod: ( method: string, handler: GatewayRequestHandler, diff --git a/src/shared/gateway-method-policy.ts b/src/shared/gateway-method-policy.ts new file mode 100644 index 00000000000..03af7d1c88e --- /dev/null +++ b/src/shared/gateway-method-policy.ts @@ -0,0 +1,41 @@ +export const RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES = [ + "exec.approvals.", + "config.", + "wizard.", + "update.", +] as const; + +export const RESERVED_ADMIN_GATEWAY_METHOD_SCOPE = "operator.admin" as const; + +export function isReservedAdminGatewayMethod(method: string): boolean { + return RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)); +} + +export function resolveReservedGatewayMethodScope( + method: string, +): typeof RESERVED_ADMIN_GATEWAY_METHOD_SCOPE | undefined { + if (!isReservedAdminGatewayMethod(method)) { + return undefined; + } + return RESERVED_ADMIN_GATEWAY_METHOD_SCOPE; +} + +export function normalizePluginGatewayMethodScope( + method: string, + scope: TScope | undefined, +): { + scope: TScope | typeof RESERVED_ADMIN_GATEWAY_METHOD_SCOPE | undefined; + coercedToReservedAdmin: boolean; +} { + const reservedScope = resolveReservedGatewayMethodScope(method); + if (!reservedScope || !scope || scope === reservedScope) { + return { + scope, + coercedToReservedAdmin: false, + }; + } + return { + scope: reservedScope, + coercedToReservedAdmin: true, + }; +} diff --git a/src/shared/gateway-method-prefixes.ts b/src/shared/gateway-method-prefixes.ts deleted file mode 100644 index 64e580698bd..00000000000 --- a/src/shared/gateway-method-prefixes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ADMIN_GATEWAY_METHOD_PREFIXES = [ - "exec.approvals.", - "config.", - "wizard.", - "update.", -] as const; - -export function isReservedAdminGatewayMethod(method: string): boolean { - return ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)); -}