fix: reserve admin gateway method prefixes

This commit is contained in:
Peter Steinberger
2026-04-04 20:03:51 +09:00
parent 86ee50b968
commit 7ffbbd8586
7 changed files with 103 additions and 7 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689)
- Providers/OpenAI Codex: treat Codex CLI auth as the canonical source, stop persisting copied Codex OAuth secrets into `auth-profiles.json`, refresh expired Codex-managed tokens back into Codex storage, and keep OpenAI WebSocket fallback/cache paths stable across transport changes.
- Plugins/gateway: keep reserved admin RPC namespaces (`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`) admin-only even for plugin-defined methods, and warn when a plugin tries to register a narrower scope.
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345)
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069)

View File

@@ -163,6 +163,11 @@ methods:
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,
`update.*`) always stay `operator.admin`, even if a plugin tries to assign a
narrower gateway method scope. Prefer plugin-specific prefixes for
plugin-owned methods.
### CLI registration metadata
`api.registerCli(registrar, opts?)` accepts two kinds of top-level metadata:

View File

@@ -50,6 +50,18 @@ describe("method scope resolution", () => {
"operator.write",
]);
});
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);
expect(resolveLeastPrivilegeOperatorScopesForMethod("config.plugin.inspect")).toEqual([
"operator.admin",
]);
});
});
describe("operator scope authorization", () => {
@@ -105,6 +117,19 @@ describe("operator scope authorization", () => {
missingScope: "operator.admin",
});
});
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);
expect(authorizeOperatorScopesForMethod("config.plugin.inspect", ["operator.read"])).toEqual({
allowed: false,
missingScope: "operator.admin",
});
});
});
describe("plugin approval method registration", () => {

View File

@@ -1,4 +1,5 @@
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js";
export const ADMIN_SCOPE = "operator.admin" as const;
export const READ_SCOPE = "operator.read" as const;
@@ -149,8 +150,6 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
],
};
const ADMIN_METHOD_PREFIXES = ["exec.approvals.", "config.", "wizard.", "update."] as const;
const METHOD_SCOPE_BY_NAME = new Map<string, OperatorScope>(
Object.entries(METHOD_SCOPE_GROUPS).flatMap(([scope, methods]) =>
methods.map((method) => [method, scope as OperatorScope]),
@@ -162,13 +161,13 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
if (explicitScope) {
return explicitScope;
}
if (isReservedAdminGatewayMethod(method)) {
return ADMIN_SCOPE;
}
const pluginScope = getActivePluginRegistry()?.gatewayMethodScopes?.[method];
if (pluginScope) {
return pluginScope;
}
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
return ADMIN_SCOPE;
}
return undefined;
}

View File

@@ -964,6 +964,47 @@ describe("loadOpenClawPlugins", () => {
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed-config-path.ping");
},
},
{
label: "coerces reserved gateway method namespaces to operator.admin",
run: () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "reserved-gateway-scope",
filename: "reserved-gateway-scope.cjs",
body: `module.exports = {
id: "reserved-gateway-scope",
register(api) {
api.registerGatewayMethod(
"config.plugin.inspect",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.read" },
);
},
};`,
});
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["reserved-gateway-scope"],
},
},
});
expect(Object.keys(registry.gatewayHandlers)).toContain("config.plugin.inspect");
expect(registry.gatewayMethodScopes?.["config.plugin.inspect"]).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",
),
),
).toBe(true);
},
},
{
label: "limits imports to the requested plugin ids",
run: () => {

View File

@@ -9,6 +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 { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
@@ -433,9 +434,23 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return;
}
registry.gatewayHandlers[trimmed] = handler;
if (opts?.scope) {
let effectiveScope = opts?.scope;
if (
effectiveScope &&
effectiveScope !== "operator.admin" &&
isReservedAdminGatewayMethod(trimmed)
) {
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";
}
if (effectiveScope) {
registry.gatewayMethodScopes ??= {};
registry.gatewayMethodScopes[trimmed] = opts.scope;
registry.gatewayMethodScopes[trimmed] = effectiveScope;
}
record.gatewayMethods.push(trimmed);
};

View File

@@ -0,0 +1,10 @@
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));
}