Skip to content

Commit 5317a15

Browse files
Jacky Tanjackytan123
authored andcommitted
fix(gateway): resolve plugin method scope from channel-pinned registry
In gateway mode the plugin registry is installed on the channel surface via pinActivePluginChannelRegistry(); the flat `activeRegistry` field is never set in that code path. resolveScopedMethod() and resolveSessionActionRegisteredScopes() read getPluginRegistryState()?.activeRegistry directly, so every plugin-registered gateway method / session action fails to resolve its scope, falls through to the ADMIN_SCOPE default, and rejects operator.write clients (e.g. a bridge plugin calling its own registered gateway method). Use getActivePluginChannelRegistryFromState() (channel.registry ?? activeRegistry) — the accessor already used throughout the channel/gateway code — so scope resolution works in both gateway and standalone/test modes. Add regression tests exercising the channel-pinned path; previously only the activeRegistry/standalone path was covered, which is why this shipped.
1 parent d481994 commit 5317a15

2 files changed

Lines changed: 68 additions & 6 deletions

File tree

src/gateway/method-scopes.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
*/
44
import { afterEach, describe, expect, it } from "vitest";
55
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
6-
import { setActivePluginRegistry } from "../plugins/runtime.js";
6+
import {
7+
pinActivePluginChannelRegistry,
8+
resetPluginRuntimeStateForTest,
9+
setActivePluginRegistry,
10+
} from "../plugins/runtime.js";
711
import {
812
authorizeOperatorScopesForMethod,
913
isGatewayMethodClassified,
@@ -227,6 +231,60 @@ describe("method scope resolution", () => {
227231
});
228232
});
229233

234+
describe("plugin scope resolution in gateway (channel-pinned) mode", () => {
235+
// Regression: in gateway mode the plugin registry is installed via
236+
// pinActivePluginChannelRegistry (channel surface) and `activeRegistry` is
237+
// never set. Scope resolution must read the channel-pinned registry; otherwise
238+
// every plugin-registered gateway method falls through to the admin default
239+
// and rejects operator.write clients (e.g. a bridge plugin's own gateway
240+
// method registered with operator.write).
241+
afterEach(() => {
242+
resetPluginRuntimeStateForTest();
243+
});
244+
245+
it("resolves a plugin gateway method scope from the channel-pinned registry", () => {
246+
const registry = createEmptyPluginRegistry();
247+
registry.gatewayHandlers["plugin.bridge.register"] = pluginHandler;
248+
registry.gatewayMethodDescriptors.push(
249+
createPluginGatewayMethodDescriptor({
250+
pluginId: "bridge",
251+
name: "plugin.bridge.register",
252+
handler: pluginHandler,
253+
scope: "operator.write",
254+
}),
255+
);
256+
pinActivePluginChannelRegistry(registry);
257+
258+
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugin.bridge.register")).toEqual([
259+
"operator.write",
260+
]);
261+
});
262+
263+
it("resolves a plugin session action scope from the channel-pinned registry", () => {
264+
const registry = createEmptyPluginRegistry();
265+
registry.sessionActions = [
266+
{
267+
pluginId: "scope-plugin",
268+
pluginName: "Scope Plugin",
269+
source: "test",
270+
action: {
271+
id: "view",
272+
requiredScopes: ["operator.read"],
273+
handler: () => ({ result: { ok: true } }),
274+
},
275+
},
276+
];
277+
pinActivePluginChannelRegistry(registry);
278+
279+
expect(
280+
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
281+
pluginId: "scope-plugin",
282+
actionId: "view",
283+
}),
284+
).toEqual(["operator.read"]);
285+
});
286+
});
287+
230288
describe("operator scope authorization", () => {
231289
it.each([
232290
["health", ["operator.read"], { allowed: true }],

src/gateway/method-scopes.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Gateway method authorization scope resolver.
22
// Maps static and plugin-defined gateway methods to operator scopes.
33
import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce";
4-
import { getPluginRegistryState } from "../plugins/runtime-state.js";
4+
import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-state.js";
55
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
66
import {
77
isCoreGatewayMethodClassified,
@@ -51,9 +51,13 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
5151
if (reservedScope) {
5252
return reservedScope;
5353
}
54-
const pluginDescriptor = getPluginRegistryState()?.activeRegistry?.gatewayMethodDescriptors?.find(
55-
(descriptor) => descriptor.name === method,
56-
);
54+
// In gateway mode plugins are pinned to the channel/httpRoute surfaces, not
55+
// activeRegistry. Use the channel-surface helper, which returns
56+
// channel.registry ?? activeRegistry and so covers gateway + standalone/test.
57+
const pluginDescriptor =
58+
getActivePluginChannelRegistryFromState()?.gatewayMethodDescriptors?.find(
59+
(descriptor) => descriptor.name === method,
60+
);
5761
const pluginScope = pluginDescriptor?.scope;
5862
return pluginScope === "node" || pluginScope === "dynamic" ? undefined : pluginScope;
5963
}
@@ -102,7 +106,7 @@ function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[]
102106
if (!pluginId || !actionId) {
103107
return undefined;
104108
}
105-
const registration = getPluginRegistryState()?.activeRegistry?.sessionActions?.find(
109+
const registration = getActivePluginChannelRegistryFromState()?.sessionActions?.find(
106110
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
107111
);
108112
if (!registration) {

0 commit comments

Comments
 (0)