fix(plugins): snapshot node invoke policies

This commit is contained in:
Vincent Koc
2026-06-05 14:24:52 +02:00
parent 5366dbda13
commit fdcb82cd6c
2 changed files with 173 additions and 3 deletions

View File

@@ -0,0 +1,115 @@
// Node invoke policy registration tests cover plugin-owned policy snapshotting.
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
isForegroundRestrictedPluginNodeCommand,
resolveNodeCommandAllowlist,
} from "../../gateway/node-command-policy.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../runtime.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
} from "../types.js";
describe("plugin node invoke policy registration", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("snapshots policy fields before node command policy projection", async () => {
let commandsReads = 0;
let defaultPlatformsReads = 0;
let dangerousReads = 0;
let foregroundRestrictedReads = 0;
let handleReads = 0;
const handler: OpenClawPluginNodeInvokePolicy["handle"] = (ctx) => ({
ok: true,
payload: ctx.command,
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "volatile-node-policy",
name: "Volatile Node Policy",
}),
register(api) {
api.registerNodeInvokePolicy({
get commands() {
commandsReads += 1;
if (commandsReads > 1) {
throw new Error("policy commands getter re-read");
}
return [" volatile.snapshot ", "volatile.snapshot"];
},
get defaultPlatforms() {
defaultPlatformsReads += 1;
if (defaultPlatformsReads > 1) {
throw new Error("policy defaultPlatforms getter re-read");
}
return ["ios", " ios ", "android"];
},
get dangerous() {
dangerousReads += 1;
if (dangerousReads > 1) {
throw new Error("policy dangerous getter re-read");
}
return false;
},
get foregroundRestrictedOnIos() {
foregroundRestrictedReads += 1;
if (foregroundRestrictedReads > 1) {
throw new Error("policy foregroundRestrictedOnIos getter re-read");
}
return true;
},
get handle() {
handleReads += 1;
if (handleReads > 1) {
throw new Error("policy handle getter re-read");
}
return handler;
},
} as OpenClawPluginNodeInvokePolicy);
},
});
setActivePluginRegistry(registry.registry);
const policy = registry.registry.nodeInvokePolicies?.[0]?.policy;
expect(policy).toMatchObject({
commands: ["volatile.snapshot"],
defaultPlatforms: ["ios", "android"],
foregroundRestrictedOnIos: true,
});
expect(policy?.dangerous).toBeUndefined();
expect(
resolveNodeCommandAllowlist({} as OpenClawConfig, { platform: "ios" }).has(
"volatile.snapshot",
),
).toBe(true);
expect(isForegroundRestrictedPluginNodeCommand(" volatile.snapshot ")).toBe(true);
expect(
await Promise.resolve(
policy?.handle({
nodeId: "node-1",
command: "volatile.snapshot",
params: {},
config: {} as OpenClawConfig,
invokeNode: async () => ({ ok: true, payload: "raw", payloadJSON: null }),
} satisfies OpenClawPluginNodeInvokePolicyContext),
),
).toEqual({ ok: true, payload: "volatile.snapshot" });
expect(commandsReads).toBe(1);
expect(defaultPlatformsReads).toBe(1);
expect(dangerousReads).toBe(1);
expect(foregroundRestrictedReads).toBe(1);
expect(handleReads).toBe(1);
});
});

View File

@@ -1650,8 +1650,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
policy: OpenClawPluginNodeInvokePolicy,
pluginConfig?: Record<string, unknown>,
) => {
const fields = readNodeInvokePolicyFields(record, policy);
if (!fields) {
return;
}
const commands = normalizeUniqueStringEntries(
Array.isArray(policy.commands) ? policy.commands : [],
Array.isArray(fields.commands) ? fields.commands : [],
);
if (commands.length === 0) {
pushDiagnostic({
@@ -1662,7 +1666,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (typeof policy.handle !== "function") {
const handle = fields.handle;
if (typeof handle !== "function") {
pushDiagnostic({
level: "error",
pluginId: record.id,
@@ -1689,13 +1694,63 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.nodeInvokePolicies.push({
pluginId: record.id,
pluginName: record.name,
policy: { ...policy, commands },
policy: {
commands,
...(Array.isArray(fields.defaultPlatforms)
? {
defaultPlatforms: normalizeUniqueStringEntries(
fields.defaultPlatforms,
) as OpenClawPluginNodeInvokePolicy["defaultPlatforms"],
}
: {}),
...(fields.dangerous === true ? { dangerous: true } : {}),
...(fields.foregroundRestrictedOnIos === true ? { foregroundRestrictedOnIos: true } : {}),
handle: handle as OpenClawPluginNodeInvokePolicy["handle"],
},
pluginConfig,
source: record.source,
rootDir: record.rootDir,
});
};
const readNodeInvokePolicyFields = (
record: PluginRecord,
policy: OpenClawPluginNodeInvokePolicy,
):
| {
commands: unknown;
defaultPlatforms: unknown;
dangerous: unknown;
foregroundRestrictedOnIos: unknown;
handle: unknown;
}
| undefined => {
let commands: unknown;
try {
commands = policy.commands;
return {
commands,
defaultPlatforms: policy.defaultPlatforms,
dangerous: policy.dangerous,
foregroundRestrictedOnIos: policy.foregroundRestrictedOnIos,
handle: policy.handle,
};
} catch (error) {
const normalizedCommands = normalizeUniqueStringEntries(
Array.isArray(commands) ? commands : [],
);
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message:
`node invoke policy registration has unreadable fields` +
`${normalizedCommands.length > 0 ? `: ${normalizedCommands.join(", ")}` : ""}: ${formatErrorMessage(error)}`,
});
return undefined;
}
};
const registerSecurityAuditCollector = (
record: PluginRecord,
collector: OpenClawPluginSecurityAuditCollector,