openclaw / src /plugins /contracts /host-hooks.contract.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4c76b0d verified
Raw History Blame Contribute Delete
108 kB
// Host hook contract tests cover plugin host hook registration and runtime behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
PROTOCOL_VERSION,
validatePluginsUiDescriptorsResult,
validatePluginsUiDescriptorsParams,
validateSessionsPluginPatchParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { createCodexAppServerToolResultExtensionRunner } from "../../agents/harness/codex-app-server-extensions.js";
import { resolveSessionStorePathCore, type SessionEntry } from "../../config/sessions.js";
import {
clearPluginOwnedSessionState,
listSessionEntriesCore,
loadSessionEntryReadOnly,
replaceSessionEntry,
} from "../../config/sessions/session-accessor.js";
import {
createCoreGatewayMethodDescriptors,
createGatewayMethodRegistry,
} from "../../gateway/methods/registry.js";
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
} from "../../gateway/operator-scopes.js";
import { pluginHostHookHandlers } from "../../gateway/server-methods/plugin-host-hooks.js";
import type { GatewayRequestContext } from "../../gateway/server-methods/types.js";
import { buildGatewaySessionRow } from "../../gateway/session-utils.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { withEnvAsync } from "../../test-utils/env.js";
import type {
AgentToolResultMiddlewareContext,
AgentToolResultMiddlewareEvent,
} from "../agent-tool-result-middleware-types.js";
import type { CodexAppServerExtensionFactory } from "../codex-app-server-extension-types.js";
import { registerPluginCommandInRegistry } from "../command-registration.js";
import { executePluginCommand } from "../commands.js";
import { createHookRunner } from "../hooks.js";
import { createPluginHostRegistryRetirement, runPluginHostCleanup } from "../host-hook-cleanup.js";
import { getPluginRunContext, setPluginRunContext } from "../host-hook-runtime.js";
import { listPluginSessionSchedulerJobs } from "../host-hook-runtime.test-fixtures.js";
import {
drainPluginNextTurnInjectionContext,
enqueuePluginNextTurnInjection,
getPluginSessionExtensionStateSync,
patchPluginSessionExtension,
projectPluginSessionExtensionsSync,
} from "../host-hook-state.js";
import { buildPluginAgentTurnPrepareContext, isPluginJsonValue } from "../host-hooks.js";
import { getPluginInstance } from "../plugin-instance-scope.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import {
clearActivePluginRegistry,
getActivePluginRegistryVersion,
disposePluginRegistryInstances,
setActivePluginRegistry,
stageActivePluginRegistry,
} from "../runtime.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import {
getTrustedToolPolicyMatcherScope,
runTrustedToolPolicies,
} from "../trusted-tool-policy.js";
import { registerHostHookFixture, registerTrustedHostHookFixture } from "./host-hook-fixture.js";
async function waitForPluginEventHandlers(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function requireFirstCommandRegistration(
registry: ReturnType<typeof createPluginRegistryFixture>["registry"]["registry"],
) {
const registration = registry.commands[0];
if (!registration) {
throw new Error("expected first plugin command registration");
}
return registration;
}
function joinContextFragments(...fragments: Array<string | undefined>): string {
const present: string[] = [];
for (const fragment of fragments) {
if (fragment) {
present.push(fragment);
}
}
return present.join("\n\n");
}
function diagnosticSummaries(diagnostics: readonly unknown[]) {
return diagnostics.map((entry) => {
const diagnostic = entry as { pluginId?: string; message?: string };
return { pluginId: diagnostic.pluginId, message: diagnostic.message };
});
}
function createHostHookFixtureRegistry() {
return createPluginRegistryFixture({
plugins: {
entries: {
"host-hook-fixture": {
hooks: {
allowConversationAccess: true,
},
},
},
},
});
}
function loadSessionStore(
storePath: string,
_options?: { skipCache?: boolean },
): Record<string, SessionEntry> {
return Object.fromEntries(
listSessionEntriesCore({ agentId: "main", storePath }).map(({ sessionKey, entry }) => [
sessionKey,
entry,
]),
);
}
async function updateSessionStore(
storePath: string,
update: (store: Record<string, SessionEntry>) => void,
): Promise<void> {
const store: Record<string, SessionEntry> = {};
update(store);
for (const [sessionKey, entry] of Object.entries(store)) {
await replaceSessionEntry({ sessionKey, storePath }, entry);
}
}
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
if (!record || typeof record !== "object") {
throw new Error("Expected record");
}
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
type HostHookStateFixture = {
stateDir: string;
storePath: string;
tempConfig: { session: { store: string } } & Record<string, unknown>;
};
async function withHostHookState(
prefix: string,
run: (fixture: HostHookStateFixture) => Promise<void>,
createTempConfig: (storePath: string) => HostHookStateFixture["tempConfig"] = (storePath) => ({
agents: { entries: { main: { default: true } } },
session: { store: storePath },
}),
): Promise<void> {
const stateDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
const storePath = path.join(stateDir, "sessions.json");
const tempConfig = createTempConfig(storePath);
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await withTempConfig({
cfg: tempConfig,
run: async () => await run({ stateDir, storePath, tempConfig }),
});
});
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
describe("host-hook fixture plugin contract", () => {
afterEach(async () => {
try {
await clearActivePluginRegistry();
} finally {
resetAgentEventsForTest();
}
});
it("registers generic SDK seams without Plan Mode business logic", () => {
const { config, registry } = createHostHookFixtureRegistry();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
origin: "workspace",
contracts: { tools: ["approval_fixture_tool"] },
}),
register: registerHostHookFixture,
});
expect(registry.registry.sessionExtensions).toHaveLength(1);
expect(registry.registry.toolMetadata).toHaveLength(1);
expect(registry.registry.controlUiDescriptors).toHaveLength(1);
expect(registry.registry.runtimeLifecycles).toHaveLength(1);
expect(registry.registry.agentEventSubscriptions).toHaveLength(1);
expect(registry.registry.sessionSchedulerJobs).toHaveLength(1);
expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual([
"host-hook-fixture",
]);
expect(registry.registry.typedHooks.map((entry) => entry.hookName).toSorted()).toEqual([
"agent_turn_prepare",
"heartbeat_prompt_contribution",
]);
});
it("rejects external plugins from trusted policy and reserved command ownership", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-policy",
name: "External Policy",
origin: "workspace",
}),
register(api) {
api.registerTrustedToolPolicy({
id: "deny",
description: "Should not be accepted",
evaluate: () => undefined,
});
api.registerCommand({
name: "status",
description: "Should not be accepted",
ownership: "reserved",
handler: async () => ({ text: "no" }),
});
},
});
expect(registry.registry.trustedToolPolicies).toHaveLength(0);
expect(registry.registry.commands).toHaveLength(0);
const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
expect(diagnostics).toHaveLength(2);
expect(diagnostics[0]?.pluginId).toBe("external-policy");
expect(diagnostics[0]?.message).toContain(
"plugin must declare contracts.trustedToolPolicies for: deny",
);
expect(diagnostics[1]?.pluginId).toBe("external-policy");
expect(diagnostics[1]?.message).toContain("only bundled plugins can claim reserved command");
});
it("rejects declared external trusted policy registration without explicit opt-in", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-policy",
name: "External Policy",
origin: "workspace",
contracts: { trustedToolPolicies: ["deny"] },
explicitlyEnabled: false,
activationSource: "default",
}),
register(api) {
api.registerTrustedToolPolicy({
id: "deny",
description: "Declared external policy",
evaluate: () => ({ block: true, blockReason: "blocked by external policy" }),
});
},
});
expect(registry.registry.trustedToolPolicies).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "external-policy",
message: "plugin must be explicitly enabled to register trusted tool policy: deny",
},
]);
});
it("allows explicitly enabled declared external trusted policy registration without reserved command ownership", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-policy",
name: "External Policy",
origin: "workspace",
contracts: { trustedToolPolicies: ["deny"] },
}),
register(api) {
api.registerTrustedToolPolicy({
id: "deny",
description: "Declared external policy",
evaluate: () => ({ block: true, blockReason: "blocked by external policy" }),
});
api.registerCommand({
name: "status",
description: "Should not be accepted",
ownership: "reserved",
handler: async () => ({ text: "no" }),
});
},
});
expect(registry.registry.trustedToolPolicies).toHaveLength(1);
expect(registry.registry.trustedToolPolicies[0]?.policy.id).toBe("deny");
expect(registry.registry.commands).toHaveLength(0);
const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.pluginId).toBe("external-policy");
expect(diagnostics[0]?.message).toContain("only bundled plugins can claim reserved command");
});
it("rejects declared external tool-result middleware registration without explicit opt-in", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-middleware",
name: "External Middleware",
origin: "workspace",
contracts: { agentToolResultMiddleware: ["codex"] },
explicitlyEnabled: false,
activationSource: "default",
}),
register(api) {
api.registerAgentToolResultMiddleware(async (event) => ({ result: event.result }), {
runtimes: ["codex"],
});
},
});
expect(registry.registry.agentToolResultMiddlewares ?? []).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "external-middleware",
message: "plugin must be explicitly enabled to register agent tool result middleware",
},
]);
});
it("keeps repeated middleware runtime and matcher scopes paired", async () => {
const { config, registry } = createPluginRegistryFixture();
const handler = vi.fn(
(_event: AgentToolResultMiddlewareEvent, _ctx: AgentToolResultMiddlewareContext) => undefined,
);
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "scoped-middleware",
name: "Scoped Middleware",
origin: "bundled",
contracts: { agentToolResultMiddleware: ["openclaw", "codex"] },
}),
register(api) {
api.registerAgentToolResultMiddleware(handler, {
runtimes: ["codex"],
matcher: ["exec"],
});
api.registerAgentToolResultMiddleware(handler, {
runtimes: ["openclaw"],
matcher: ["apply_patch"],
});
},
});
const registration = expectDefined(
registry.registry.agentToolResultMiddlewares[0],
"scoped middleware registration",
);
const event = {
toolCallId: "call-1",
args: {},
result: { content: [{ type: "text" as const, text: "ok" }], details: {} },
};
await registration.handler({ ...event, toolName: "exec" }, { runtime: "codex" });
await registration.handler({ ...event, toolName: "apply_patch" }, { runtime: "codex" });
await registration.handler({ ...event, toolName: "apply_patch" }, { runtime: "openclaw" });
await registration.handler({ ...event, toolName: "exec" }, { runtime: "openclaw" });
expect(registry.registry.agentToolResultMiddlewares).toHaveLength(1);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls.map(([call, ctx]) => [call.toolName, ctx.runtime])).toEqual([
["exec", "codex"],
["apply_patch", "openclaw"],
]);
});
it("initializes a repeatedly registered Codex extension factory only once", async () => {
const { config, registry } = createPluginRegistryFixture();
const handler = vi.fn(() => undefined);
const factory = vi.fn<CodexAppServerExtensionFactory>((runtime) => {
runtime.on("tool_result", handler);
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "deduplicated-factory",
origin: "bundled",
contracts: { embeddedExtensionFactories: ["codex-app-server"] },
}),
register(api) {
api.registerCodexAppServerExtensionFactory(factory);
api.registerCodexAppServerExtensionFactory(factory);
},
});
try {
const runner = createCodexAppServerToolResultExtensionRunner(
{},
registry.registry.codexAppServerExtensionFactories.map((entry) => entry.factory),
);
await runner.applyToolResultExtensions({
threadId: "thread-1",
turnId: "turn-1",
toolCallId: "call-1",
toolName: "read",
args: {},
result: { content: [{ type: "text", text: "ok" }], details: {} },
});
expect(factory).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledOnce();
} finally {
await disposePluginRegistryInstances(registry.registry);
}
});
it("fences handlers from a retired managed Codex factory without claiming caller-owned factories", async () => {
const { config, registry } = createPluginRegistryFixture();
const managedHandler = vi.fn(() => undefined);
const callerHandler = vi.fn(() => undefined);
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "managed-factory",
origin: "bundled",
contracts: { embeddedExtensionFactories: ["codex-app-server"] },
}),
register(api) {
api.registerCodexAppServerExtensionFactory((runtime) => {
runtime.on("tool_result", managedHandler);
});
},
});
const runner = createCodexAppServerToolResultExtensionRunner({}, [
...registry.registry.codexAppServerExtensionFactories.map((entry) => entry.factory),
(runtime) => runtime.on("tool_result", callerHandler),
]);
const event = {
threadId: "thread-1",
turnId: "turn-1",
toolCallId: "call-1",
toolName: "read",
args: {},
result: { content: [{ type: "text" as const, text: "ok" }], details: {} },
};
try {
await expect(runner.applyToolResultExtensions(event)).resolves.toEqual(event.result);
expect(managedHandler).toHaveBeenCalledOnce();
expect(callerHandler).toHaveBeenCalledOnce();
await disposePluginRegistryInstances(registry.registry);
await expect(runner.applyToolResultExtensions(event)).resolves.toEqual(event.result);
expect(managedHandler).toHaveBeenCalledOnce();
expect(callerHandler).toHaveBeenCalledTimes(2);
} finally {
await disposePluginRegistryInstances(registry.registry);
}
});
it("diagnoses malformed trusted policy registrations", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "malformed-policy",
name: "Malformed Policy",
origin: "workspace",
}),
register(api) {
Reflect.apply(api.registerTrustedToolPolicy, api, [null]);
Reflect.apply(api.registerTrustedToolPolicy, api, [undefined]);
},
});
expect(registry.registry.trustedToolPolicies).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "malformed-policy",
message: "trusted tool policy registration requires id, description, and evaluate()",
},
{
pluginId: "malformed-policy",
message: "trusted tool policy registration requires id, description, and evaluate()",
},
]);
});
it("scopes installed trusted policy ids to the registering plugin", () => {
const { config, registry } = createPluginRegistryFixture();
for (const pluginId of ["budget-policy-a", "budget-policy-b"]) {
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: pluginId,
name: pluginId,
origin: "workspace",
contracts: { trustedToolPolicies: ["workflow-budget"] },
}),
register(api) {
api.registerTrustedToolPolicy({
id: "workflow-budget",
description: `${pluginId} workflow budget policy`,
evaluate: () => undefined,
});
},
});
}
expect(
registry.registry.trustedToolPolicies.map((entry) => [entry.pluginId, entry.policy.id]),
).toEqual([
["budget-policy-a", "workflow-budget"],
["budget-policy-b", "workflow-budget"],
]);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([]);
});
it("rejects duplicate trusted policy ids from the same plugin", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "duplicate-policy",
name: "Duplicate Policy",
origin: "workspace",
contracts: { trustedToolPolicies: ["workflow-budget"] },
}),
register(api) {
api.registerTrustedToolPolicy({
id: "workflow-budget",
description: "First workflow budget policy",
evaluate: () => undefined,
});
api.registerTrustedToolPolicy({
id: "workflow-budget",
description: "Duplicate workflow budget policy",
evaluate: () => undefined,
});
},
});
expect(registry.registry.trustedToolPolicies).toHaveLength(1);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "duplicate-policy",
message: "trusted tool policy already registered: workflow-budget (duplicate-policy)",
},
]);
});
it("runs bundled trusted policies before declared external trusted policies", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-policy",
name: "External Policy",
origin: "workspace",
contracts: { trustedToolPolicies: ["external-deny"] },
}),
register(api) {
api.registerTrustedToolPolicy({
id: "external-deny",
description: "Declared external policy",
evaluate: () => ({ block: true, blockReason: "external policy" }),
});
},
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "bundled-policy",
name: "Bundled Policy",
origin: "bundled",
}),
register(api) {
api.registerTrustedToolPolicy({
id: "bundled-deny",
description: "Bundled policy",
evaluate: () => ({ block: true, blockReason: "bundled policy" }),
});
},
});
setActivePluginRegistry(registry.registry);
const result = await runTrustedToolPolicies(
{ toolName: "exec", params: {} },
{ toolName: "exec" },
);
expect(result?.blockReason).toBe("bundled policy");
});
it("keeps same-id bundled and installed trusted policies owner-scoped", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "external-policy",
name: "External Policy",
origin: "workspace",
contracts: { trustedToolPolicies: ["shared-deny"] },
}),
register(api) {
api.registerTrustedToolPolicy({
id: "shared-deny",
description: "Declared external policy",
evaluate: () => ({ allow: true }),
});
},
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "bundled-policy",
name: "Bundled Policy",
origin: "bundled",
}),
register(api) {
api.registerTrustedToolPolicy({
id: "shared-deny",
description: "Bundled policy",
evaluate: () => ({ block: true, blockReason: "bundled policy" }),
});
},
});
setActivePluginRegistry(registry.registry);
expect(
registry.registry.trustedToolPolicies.map((entry) => [entry.pluginId, entry.policy.id]),
).toEqual([
["bundled-policy", "shared-deny"],
["external-policy", "shared-deny"],
]);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([]);
const result = await runTrustedToolPolicies(
{ toolName: "exec", params: {} },
{ toolName: "exec" },
);
expect(result?.blockReason).toBe("bundled policy");
});
it("allows the official npm Codex plugin to keep /codex command ownership", () => {
const { config, registry } = createPluginRegistryFixture();
const codexRoot = path.join("/tmp", ".openclaw", "npm", "node_modules", "@openclaw", "codex");
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "codex",
name: "Codex",
origin: "global",
rootDir: codexRoot,
source: path.join(codexRoot, "index.ts"),
}),
register(api) {
api.registerCommand({
name: "codex",
description: "Official npm Codex command",
ownership: "reserved",
handler: async () => ({ text: "ok" }),
});
},
});
expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]);
expect(
diagnosticSummaries(registry.registry.diagnostics).some(
(entry) =>
entry.pluginId === "codex" &&
entry.message?.includes("only bundled plugins can claim reserved command"),
),
).toBe(false);
});
it("allows the official ClawHub Codex plugin to keep /codex command ownership", () => {
const { config, registry } = createPluginRegistryFixture();
const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex");
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "codex",
name: "Codex",
packageName: "@openclaw/codex",
origin: "global",
rootDir: codexRoot,
source: path.join(codexRoot, "dist", "index.js"),
}),
register(api) {
api.registerCommand({
name: "codex",
description: "Official ClawHub Codex command",
ownership: "reserved",
handler: async () => ({ text: "ok" }),
});
},
});
expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]);
expect(
diagnosticSummaries(registry.registry.diagnostics).some(
(entry) =>
entry.pluginId === "codex" &&
entry.message?.includes("only bundled plugins can claim reserved command"),
),
).toBe(false);
});
it("rejects non-official global Codex plugins from /codex command ownership", () => {
const { config, registry } = createPluginRegistryFixture();
const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex");
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "codex",
name: "Codex",
origin: "global",
rootDir: codexRoot,
source: path.join(codexRoot, "dist", "index.js"),
}),
register(api) {
api.registerCommand({
name: "codex",
description: "Impostor Codex command",
ownership: "reserved",
handler: async () => ({ text: "no" }),
});
},
});
expect(registry.registry.commands).toHaveLength(0);
const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.pluginId).toBe("codex");
expect(diagnostics[0]?.message).toContain("only bundled plugins can claim reserved command");
});
it("rejects workspace Codex plugins that spoof the official package name", () => {
const { config, registry } = createPluginRegistryFixture();
const codexRoot = path.join("/tmp", "workspace", "codex");
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "codex",
name: "Codex",
packageName: "@openclaw/codex",
origin: "workspace",
rootDir: codexRoot,
source: path.join(codexRoot, "dist", "index.js"),
}),
register(api) {
api.registerCommand({
name: "codex",
description: "Workspace Codex command",
ownership: "reserved",
handler: async () => ({ text: "no" }),
});
},
});
expect(registry.registry.commands).toHaveLength(0);
const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.pluginId).toBe("codex");
expect(diagnostics[0]?.message).toContain("only bundled plugins can claim reserved command");
});
it("rejects reserved command ownership for non-reserved bundled command names", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "bundled-command",
name: "Bundled Command",
origin: "bundled",
}),
register(api) {
api.registerCommand({
name: "workflow",
description: "Should not need reserved ownership",
ownership: "reserved",
handler: async () => ({ text: "no" }),
});
},
});
expect(registry.registry.commands).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "bundled-command",
message: "reserved command ownership requires a reserved command name: workflow",
},
]);
});
it("lets bundled fixture policies run before normal before_tool_call hooks", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "trusted-fixture",
name: "Trusted Fixture",
origin: "bundled",
}),
register: registerTrustedHostHookFixture,
});
setActivePluginRegistry(registry.registry);
const policyResult = await runTrustedToolPolicies(
{ toolName: "blocked_fixture_tool", params: {} },
{ toolName: "blocked_fixture_tool" },
);
expectRecordFields(policyResult, {
block: true,
blockReason: "blocked by fixture policy",
});
});
it("scopes trusted policies through canonical OpenClaw tool ids", async () => {
const evaluate = vi.fn(() => ({ block: true, blockReason: "covered" }));
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "shell-policy",
source: "test",
policy: {
id: "shell-policy",
description: "covers shell tools",
matcher: ["exec"],
evaluate,
},
},
];
await expect(
runTrustedToolPolicies(
{ toolName: "web_search", params: {} },
{ toolName: "web_search" },
{ registry },
),
).resolves.toBeUndefined();
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }, { registry }),
).resolves.toMatchObject({ block: true, blockReason: "covered" });
expect(evaluate).toHaveBeenCalledOnce();
});
it.each([
{ label: "wrong type", matcher: "exec" },
{ label: "empty array", matcher: [] },
{ label: "wildcard", matcher: ["*"] },
{ label: "blank", matcher: [" "] },
{ label: "provider alias", matcher: ["Bash"] },
{ label: "sparse array", matcher: Array(1) },
])(
"fails closed before evaluating an unreadable trusted policy matcher: $label",
async ({ matcher }) => {
const evaluate = vi.fn();
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "fuzzplugin",
source: "test",
policy: {
id: "fuzzpolicy",
description: "synthetic trusted policy",
matcher: matcher as never,
evaluate,
},
},
];
expect(getTrustedToolPolicyMatcherScope(registry)).toEqual({
matchAll: true,
toolNames: [],
});
await expect(
runTrustedToolPolicies(
{ toolName: "web_search", params: {} },
{ toolName: "web_search" },
{ registry },
),
).resolves.toEqual({
block: true,
blockReason: "blocked by fuzzpolicy: policy matcher is unreadable",
});
expect(evaluate).not.toHaveBeenCalled();
},
);
it("fails closed when a trusted policy throws during evaluation", async () => {
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "fuzzplugin",
pluginName: "Fuzz Plugin",
source: "test",
policy: {
id: "fuzzpolicy",
description: "synthetic trusted policy",
evaluate: () => {
throw new Error("fuzzplugin trusted policy failed");
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
).resolves.toEqual({
block: true,
blockReason: "blocked by fuzzpolicy: policy evaluation failed",
});
});
it("fails closed when a trusted policy registration is unreadable", async () => {
const registry = createEmptyPluginRegistry();
const unreadableRegistration = {
pluginId: "fuzzplugin",
pluginName: "Fuzz Plugin",
source: "test",
};
Object.defineProperty(unreadableRegistration, "policy", {
enumerable: true,
get() {
throw new Error("fuzzplugin trusted policy is unreadable");
},
});
registry.trustedToolPolicies = [unreadableRegistration as never];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
).resolves.toEqual({
block: true,
blockReason: "blocked by fuzzplugin: policy is unreadable",
});
});
it("fails closed when a trusted policy owner id is unreadable", async () => {
const registry = createEmptyPluginRegistry();
const unreadableOwnerRegistration = {
pluginName: "Fuzz Plugin",
source: "test",
policy: {
id: "fuzzpolicy",
description: "synthetic trusted policy",
evaluate: () => undefined,
},
};
Object.defineProperty(unreadableOwnerRegistration, "pluginId", {
enumerable: true,
get() {
throw new Error("fuzzplugin trusted policy owner is unreadable");
},
});
registry.trustedToolPolicies = [unreadableOwnerRegistration as never];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
).resolves.toEqual({
block: true,
blockReason: "blocked by fuzzpolicy: policy owner is unreadable",
});
});
it("fails closed when a trusted policy decision is unreadable", async () => {
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "fuzzplugin",
pluginName: "Fuzz Plugin",
source: "test",
policy: {
id: "fuzzpolicy",
description: "synthetic trusted policy",
evaluate: () =>
Object.defineProperty({}, "allow", {
enumerable: true,
get() {
throw new Error("fuzzplugin trusted policy allow is unreadable");
},
}),
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
).resolves.toEqual({
block: true,
blockReason: "blocked by fuzzpolicy: policy decision is unreadable",
});
});
it("preserves cancellation while deriving a trusted policy rewrite", async () => {
const controller = new AbortController();
const abortError = new Error("aborted during rewrite derivation");
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "rewrite-plugin",
source: "test",
policy: {
id: "rewrite-policy",
description: "rewrite",
evaluate: () => ({ params: { input: "rewritten" } }),
},
},
];
await expect(
runTrustedToolPolicies(
{ toolName: "apply_patch", params: { input: "original" } },
{ toolName: "apply_patch", abortSignal: controller.signal },
{
registry,
deriveEvent: async () => {
controller.abort(abortError);
controller.signal.throwIfAborted();
return {};
},
},
),
).rejects.toBe(abortError);
});
it("lets later trusted policy blocks override earlier approval requests", async () => {
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-a",
pluginName: "Trusted A",
source: "test",
policy: {
id: "approval",
description: "approval",
evaluate: () => ({
requireApproval: {
title: "Review",
description: "Review the call",
},
}),
},
},
{
pluginId: "trusted-b",
pluginName: "Trusted B",
source: "test",
policy: {
id: "block",
description: "block",
evaluate: () => ({ block: true, blockReason: "blocked by later policy" }),
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
).resolves.toEqual({
block: true,
blockReason: "blocked by later policy",
});
});
it("passes adjusted trusted policy params to later trusted policies", async () => {
const seenParams: Record<string, unknown>[] = [];
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-a",
pluginName: "Trusted A",
source: "test",
policy: {
id: "params",
description: "params",
evaluate: () => ({ params: { command: "patched" } }),
},
},
{
pluginId: "trusted-b",
pluginName: "Trusted B",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenParams.push(event.params);
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{ toolName: "exec", params: { command: "original" } },
{ toolName: "exec" },
),
).resolves.toEqual({ params: { command: "patched" } });
expect(seenParams).toEqual([{ command: "patched" }]);
});
it("preserves trusted policy derived paths when params are unchanged", async () => {
const seenDerivedPaths: unknown[] = [];
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-inspector",
pluginName: "Trusted Inspector",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenDerivedPaths.push(event.derivedPaths);
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{
toolName: "apply_patch",
params: { input: "*** Update File: old.ts" },
derivedPaths: ["old.ts"],
},
{ toolName: "apply_patch" },
),
).resolves.toBeUndefined();
expect(seenDerivedPaths).toEqual([["old.ts"]]);
});
it("ignores non-plain trusted policy params when re-deriving paths", async () => {
const seenParams: unknown[] = [];
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-bad",
pluginName: "Trusted Bad",
source: "test",
policy: {
id: "bad",
description: "bad",
evaluate: () => ({ params: "not-a-plain-object" as never }),
},
},
{
pluginId: "trusted-inspector",
pluginName: "Trusted Inspector",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenParams.push(event.params);
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{ toolName: "apply_patch", params: { input: "*** Add File: old.ts" } },
{ toolName: "apply_patch" },
),
).resolves.toBeUndefined();
expect(seenParams).toEqual([{ input: "*** Add File: old.ts" }]);
});
it("does not let trusted policies mutate derived paths for later policies", async () => {
const seenDerivedPaths: unknown[] = [];
let mutationRejected = false;
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-a",
pluginName: "Trusted A",
source: "test",
policy: {
id: "mutate",
description: "mutate",
evaluate: (event) => {
try {
(event.derivedPaths as string[] | undefined)?.push("mutated.ts");
} catch {
mutationRejected = true;
}
return undefined;
},
},
},
{
pluginId: "trusted-b",
pluginName: "Trusted B",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenDerivedPaths.push(event.derivedPaths);
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{
toolName: "apply_patch",
params: { input: "*** Update File: old.ts" },
derivedPaths: ["old.ts"],
},
{ toolName: "apply_patch" },
),
).resolves.toBeUndefined();
expect(mutationRejected).toBe(true);
expect(seenDerivedPaths).toEqual([["old.ts"]]);
});
it("clears stale derived paths when trusted policy rewrites remove targets", async () => {
const seenDerivedPaths: unknown[] = [];
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-a",
pluginName: "Trusted A",
source: "test",
policy: {
id: "params",
description: "params",
evaluate: () => ({ params: { input: "not a patch" } }),
},
},
{
pluginId: "trusted-b",
pluginName: "Trusted B",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenDerivedPaths.push(event.derivedPaths);
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{
toolName: "apply_patch",
params: { patch: "*** Update File: old.ts" },
derivedPaths: ["old.ts"],
},
{ toolName: "apply_patch" },
{
deriveEvent(params) {
return typeof params.patch === "string" ? { derivedPaths: ["old.ts"] } : {};
},
},
),
).resolves.toEqual({ params: { input: "not a patch" } });
expect(seenDerivedPaths).toEqual([undefined]);
});
it("does not let derived param callbacks override core trusted policy event fields", async () => {
const seenEvents: Array<{ params: unknown; derivedPaths: unknown }> = [];
const registry = createEmptyPluginRegistry();
registry.trustedToolPolicies = [
{
pluginId: "trusted-a",
pluginName: "Trusted A",
source: "test",
policy: {
id: "params",
description: "params",
evaluate: () => ({ params: { input: "*** Update File: new.ts" } }),
},
},
{
pluginId: "trusted-b",
pluginName: "Trusted B",
source: "test",
policy: {
id: "inspect",
description: "inspect",
evaluate: (event) => {
seenEvents.push({ params: event.params, derivedPaths: event.derivedPaths });
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
await expect(
runTrustedToolPolicies(
{
toolName: "apply_patch",
params: { input: "*** Update File: old.ts" },
derivedPaths: ["old.ts"],
},
{ toolName: "apply_patch" },
{
deriveEvent() {
return {
params: { input: "malicious override" },
derivedPaths: ["new.ts"],
} as never;
},
},
),
).resolves.toEqual({ params: { input: "*** Update File: new.ts" } });
expect(seenEvents).toEqual([
{
params: { input: "*** Update File: new.ts" },
derivedPaths: ["new.ts"],
},
]);
});
it("validates plugin-owned JSON values as plain JSON-compatible data", () => {
expect(
isPluginJsonValue({
state: "waiting",
attempts: 1,
nested: [{ ok: true }, null],
}),
).toBe(true);
expect(isPluginJsonValue({ value: Number.NaN })).toBe(false);
expect(isPluginJsonValue({ value: undefined })).toBe(false);
expect(isPluginJsonValue(new Date(0))).toBe(false);
expect(isPluginJsonValue(new Map([["state", "waiting"]]))).toBe(false);
expect(isPluginJsonValue({ value: "x".repeat(70 * 1024) })).toBe(false);
});
it("rejects non-JSON descriptor schemas before projecting Control UI descriptors", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "descriptor-fixture",
name: "Descriptor Fixture",
}),
register(api) {
api.registerControlUiDescriptor({
id: "bad-schema",
surface: "session",
label: "Bad schema",
schema: new Date(0) as never,
});
},
});
expect(registry.registry.controlUiDescriptors).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "descriptor-fixture",
message: "control UI descriptor schema must be JSON-compatible: bad-schema",
},
]);
});
it("projects registered session extensions into gateway session rows", () => {
const { config, registry } = createHostHookFixtureRegistry();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
}),
register: registerHostHookFixture,
});
setActivePluginRegistry(registry.registry);
const row = buildGatewaySessionRow({
cfg: config,
agentId: "main",
storePath: "/tmp/sessions.json",
store: {},
key: "agent:main:main",
entry: {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"host-hook-fixture": {
workflow: { state: "waiting" },
},
},
},
});
expect(row.pluginExtensions).toEqual([
{
pluginId: "host-hook-fixture",
namespace: "workflow",
value: { state: "waiting" },
},
]);
});
it("projects sync session extension projectors into gateway rows without exposing raw state", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "projector-fixture",
name: "Projector Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Projected workflow state",
project: ({ state }) => {
if (!state || typeof state !== "object" || Array.isArray(state)) {
return undefined;
}
const workflowState = (state as { state?: unknown }).state;
return typeof workflowState === "string" ? { state: workflowState } : undefined;
},
});
},
});
setActivePluginRegistry(registry.registry);
const entry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"projector-fixture": {
workflow: { state: "waiting", privateToken: "secret" },
},
},
};
expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([
{
pluginId: "projector-fixture",
namespace: "workflow",
value: { state: "waiting" },
},
]);
const row = buildGatewaySessionRow({
cfg: config,
agentId: "main",
storePath: "/tmp/sessions.json",
store: {},
key: "agent:main:main",
entry,
});
expect(row.pluginExtensions).toEqual([
{
pluginId: "projector-fixture",
namespace: "workflow",
value: { state: "waiting" },
},
]);
});
it("rejects async session extension projectors because gateway rows are synchronous", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "async-projector-fixture",
name: "Async Projector Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Async workflow state",
project: (async () => ({ state: "late" })) as unknown as () => undefined,
});
},
});
expect(registry.registry.sessionExtensions).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "async-projector-fixture",
message: "session extension projector must be synchronous",
},
]);
});
it("reports specific diagnostics for malformed session extension callbacks", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "bad-session-extension-fixture",
name: "Bad Session Extension Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "projector",
description: "Bad projector",
project: "not-a-function" as never,
});
api.registerSessionExtension({
namespace: "cleanup",
description: "Bad cleanup",
cleanup: "not-a-function" as never,
});
},
});
expect(registry.registry.sessionExtensions).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "bad-session-extension-fixture",
message: "session extension projector must be a function",
},
{
pluginId: "bad-session-extension-fixture",
message: "session extension cleanup must be a function",
},
]);
});
it("rejects duplicate runtime lifecycle and agent event subscription ids", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "duplicate-host-hook-fixture",
name: "Duplicate Host Hook Fixture",
}),
register(api) {
api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined });
api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined });
api.registerRuntimeLifecycle({
id: "bad-cleanup",
cleanup: "not-a-function" as never,
});
api.registerAgentEventSubscription({
id: "events",
streams: ["tool"],
handle: () => undefined,
});
api.registerAgentEventSubscription({
id: "events",
streams: ["error"],
handle: () => undefined,
});
api.registerAgentEventSubscription({
id: "missing-handler",
streams: ["tool"],
handle: "not-a-function" as never,
});
api.registerAgentEventSubscription({
id: "bad-streams",
streams: { length: 1, 0: "tool" } as never,
handle: () => undefined,
});
api.registerSessionSchedulerJob({
id: "bad-scheduler-cleanup",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: "not-a-function" as never,
});
},
});
expect(registry.registry.runtimeLifecycles).toHaveLength(1);
expect(registry.registry.agentEventSubscriptions).toHaveLength(1);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "duplicate-host-hook-fixture",
message: "runtime lifecycle already registered: cleanup",
},
{
pluginId: "duplicate-host-hook-fixture",
message: "runtime lifecycle cleanup must be a function: bad-cleanup",
},
{
pluginId: "duplicate-host-hook-fixture",
message: "agent event subscription already registered: events",
},
{
pluginId: "duplicate-host-hook-fixture",
message: "agent event subscription registration requires id and handle",
},
{
pluginId: "duplicate-host-hook-fixture",
message: "agent event subscription streams must be an array of strings: bad-streams",
},
{
pluginId: "duplicate-host-hook-fixture",
message: "session scheduler job cleanup must be a function: bad-scheduler-cleanup",
},
]);
});
it("defensively ignores promise-like session projections from untyped plugins", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "promise-projector-fixture",
name: "Promise Projector Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Promise workflow state",
project: (() =>
Promise.reject(
new Error("projectors must be synchronous"),
)) as unknown as () => undefined,
});
},
});
setActivePluginRegistry(registry.registry);
const entry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"promise-projector-fixture": {
workflow: { state: "waiting" },
},
},
};
expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual(
[],
);
});
it("skips throwing session extension projectors without losing other projections", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "throwing-projector-fixture",
name: "Throwing Projector Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Throwing workflow state",
project: () => {
throw new Error("projection failed");
},
});
},
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "healthy-projector-fixture",
name: "Healthy Projector Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Healthy workflow state",
project: ({ state }) => state,
});
},
});
setActivePluginRegistry(registry.registry);
const entry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"throwing-projector-fixture": {
workflow: { state: "hidden" },
},
"healthy-projector-fixture": {
workflow: { state: "visible" },
},
},
};
expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([
{
pluginId: "healthy-projector-fixture",
namespace: "workflow",
value: { state: "visible" },
},
]);
const row = buildGatewaySessionRow({
cfg: config,
agentId: "main",
storePath: "/tmp/sessions.json",
store: {},
key: "agent:main:main",
entry,
});
expect(row.pluginExtensions).toEqual([
{
pluginId: "healthy-projector-fixture",
namespace: "workflow",
value: { state: "visible" },
},
]);
});
it.each(["patch", "projection", "injection"] as const)(
"uses the admitted registry for session %s after a new registry becomes globally active",
async (operation) => {
const createRegistry = (label: "global" | "scoped") => {
const fixture = createPluginRegistryFixture();
const record = createPluginRecord({ id: `${label}-owner` });
registerTestPlugin({
...fixture,
record,
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Scoped workflow state",
sessionEntrySlotKey: `${label}Projection`,
sessionEntrySlotSchema: { type: "object" },
project: ({ state }) => ({ registry: label, state: state ?? null }),
});
},
});
return { ...fixture, instance: expectDefined(getPluginInstance(record), "plugin owner") };
};
const active = createRegistry("global");
const scoped = createRegistry("scoped");
setActivePluginRegistry(scoped.registry.registry);
try {
await withHostHookState("openclaw-host-hooks-scope-", async ({ storePath, tempConfig }) => {
const sessionKey = "agent:main:main";
const access = { sessionKey, storePath };
await replaceSessionEntry(access, {
sessionId: "scoped-session",
updatedAt: 1,
pluginExtensions: { "scoped-owner": { workflow: "initial" } },
});
for (const label of ["global", "scoped"] as const) {
await enqueuePluginNextTurnInjection({
cfg: tempConfig,
pluginId: `${label}-owner`,
injection: { sessionKey, text: `${label} context` },
});
}
const readEntry = () => expectDefined(loadSessionEntryReadOnly(access), "stored session");
const admitted = createDeferredCore();
const resume = createDeferredCore();
const pending = scoped.instance.run(async () => {
admitted.resolve();
await resume.promise;
if (operation === "patch") {
const patch = {
cfg: tempConfig,
sessionKey,
pluginId: "scoped-owner",
namespace: "workflow",
value: "updated",
};
await expect(patchPluginSessionExtension(patch)).resolves.toMatchObject({
ok: true,
value: "updated",
});
const stored = readEntry();
expect(Reflect.get(stored, "scopedProjection")).toEqual({
registry: "scoped",
state: "updated",
});
expect(Reflect.get(stored, "globalProjection")).toBeUndefined();
const failure = new Error("session mutation authority expired");
await expect(
patchPluginSessionExtension({
...patch,
value: "must not commit",
assertCurrent: () => {
throw failure;
},
}),
).rejects.toBe(failure);
expect(readEntry()).toEqual(stored);
} else if (operation === "projection") {
expect(
projectPluginSessionExtensionsSync({ sessionKey, entry: readEntry() }),
).toEqual([
{
pluginId: "scoped-owner",
namespace: "workflow",
value: {
registry: "scoped",
state: "initial",
},
},
]);
} else {
const result = await drainPluginNextTurnInjectionContext({
cfg: tempConfig,
sessionKey,
});
expect(result.prependContext).toBe("scoped context");
expect(result.queuedInjections.map((entry) => entry.pluginId)).toEqual([
"scoped-owner",
]);
expect(readEntry().pluginNextTurnInjections).toBeUndefined();
}
});
try {
await admitted.promise;
setActivePluginRegistry(active.registry.registry);
expect(() => scoped.instance.run(() => undefined)).toThrow("reloaded or disabled");
resume.resolve();
await pending;
} finally {
resume.resolve();
await pending.catch(() => undefined);
await disposePluginRegistryInstances(scoped.registry.registry);
}
});
} finally {
await Promise.all([
disposePluginRegistryInstances(scoped.registry.registry),
disposePluginRegistryInstances(active.registry.registry),
]);
}
},
);
it("requires explicit unset to remove plugin session extension state", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "patch-fixture",
name: "Patch Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Patch workflow state",
});
},
});
setActivePluginRegistry(registry.registry);
await withHostHookState("openclaw-host-hooks-patch-", async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
pluginExtensions: {
"patch-fixture": { workflow: { state: "waiting" } },
},
};
return undefined;
});
await expect(
patchPluginSessionExtension({
cfg: tempConfig,
sessionKey: "agent:main:main",
pluginId: "patch-fixture",
namespace: "workflow",
}),
).resolves.toEqual({
ok: false,
error: "plugin session extension value is required unless unset is true",
});
expect(
loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"]
?.workflow,
).toEqual({ state: "waiting" });
await expect(
patchPluginSessionExtension({
cfg: tempConfig,
sessionKey: "agent:main:main",
pluginId: "patch-fixture",
namespace: "workflow",
value: { state: "ambiguous" },
unset: true,
}),
).resolves.toEqual({
ok: false,
error: "plugin session extension cannot specify both unset and value",
});
expect(
loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"]
?.workflow,
).toEqual({ state: "waiting" });
await expect(
patchPluginSessionExtension({
cfg: tempConfig,
sessionKey: "agent:main:main",
pluginId: "patch-fixture",
namespace: "workflow",
value: { state: "approved" },
}),
).resolves.toEqual({
ok: true,
key: "agent:main:main",
value: { state: "approved" },
});
await expect(
patchPluginSessionExtension({
cfg: tempConfig,
sessionKey: "agent:main:main",
pluginId: "patch-fixture",
namespace: "workflow",
unset: true,
}),
).resolves.toEqual({
ok: true,
key: "agent:main:main",
value: undefined,
});
expect(loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions).toBeUndefined();
});
});
it("models queued next-turn injections and agent_turn_prepare as one prompt context", async () => {
const { config, registry } = createHostHookFixtureRegistry();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
}),
register: registerHostHookFixture,
});
const runner = createHookRunner(registry.registry);
const queuedContext = buildPluginAgentTurnPrepareContext({
queuedInjections: [
{
id: "approval",
pluginId: "approval-plugin",
text: "approval workflow resumed",
placement: "prepend_context",
createdAt: 1,
},
{
id: "budget",
pluginId: "budget-plugin",
text: "budget policy summary",
placement: "append_context",
createdAt: 1,
},
],
});
const hookContext = await runner.runAgentTurnPrepare(
{
prompt: "continue",
messages: [],
queuedInjections: [],
},
{ sessionKey: "agent:main:main" },
);
expect(
joinContextFragments(
queuedContext.prependContext,
queuedContext.appendContext,
hookContext?.prependContext,
),
).toContain("approval workflow resumed");
expect(hookContext?.prependContext).toBe("fixture turn context");
});
it("skips malformed persisted next-turn injection records during prompt assembly", () => {
const queuedContext = buildPluginAgentTurnPrepareContext({
queuedInjections: [
{
id: "bad-text",
pluginId: "approval-plugin",
text: 123,
placement: "prepend_context",
createdAt: 1,
} as never,
{
id: "bad-placement",
pluginId: "approval-plugin",
text: "wrong placement",
placement: "middle_context",
createdAt: 1,
} as never,
{
id: "valid",
pluginId: "approval-plugin",
text: " approval workflow resumed ",
placement: "append_context",
createdAt: 1,
},
],
});
expect(queuedContext).toEqual({ appendContext: "approval workflow resumed" });
});
it("rejects malformed next-turn injection input before persisting records", async () => {
await expect(
enqueuePluginNextTurnInjection({
cfg: {},
pluginId: "approval-fixture",
injection: {
sessionKey: "agent:main:main",
text: "invalid placement",
placement: "middle_context",
} as never,
}),
).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });
await expect(
enqueuePluginNextTurnInjection({
cfg: {},
pluginId: "approval-fixture",
injection: {
sessionKey: "agent:main:main",
text: "invalid ttl",
ttlMs: Number.POSITIVE_INFINITY,
},
}),
).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });
await expect(
enqueuePluginNextTurnInjection({
cfg: {},
pluginId: "approval-fixture",
injection: {
sessionKey: "agent:main:main",
text: "negative ttl",
ttlMs: -1,
},
}),
).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });
});
it.each(["enqueue", "drain", "extension"] as const)(
"keeps global plugin %s state in its selected agent store",
async (operation) => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(createPluginRecord({ id: "owner-fixture", status: "loaded" }));
registry.sessionExtensions.push({
pluginId: "owner-fixture",
source: "test",
extension: { namespace: "workflow", description: "Agent-owned workflow state" },
});
registry.trustedToolPolicies.push({
pluginId: "owner-fixture",
source: "test",
policy: {
id: "owner-state",
description: "Read the selected agent's workflow state",
evaluate: (_event, ctx) => ({
params: { workflow: ctx.getSessionExtension?.("workflow") },
}),
},
});
setActivePluginRegistry(registry);
await withHostHookState(
"openclaw-host-hooks-owner-",
async ({ tempConfig }) => {
const scope = (agentId: string) => ({
agentId,
sessionKey: "global",
storePath: resolveSessionStorePathCore(tempConfig.session.store, { agentId }),
});
for (const agentId of ["qa", "beta"]) {
await replaceSessionEntry(scope(agentId), {
sessionId: `session-${agentId}`,
updatedAt: 1,
pluginExtensions: { "owner-fixture": { workflow: { owner: agentId } } },
pluginNextTurnInjections: {
"owner-fixture": [
{
id: agentId,
pluginId: "owner-fixture",
text: agentId,
placement: "prepend_context",
createdAt: 1,
},
],
},
});
}
const betaBefore = loadSessionEntryReadOnly(scope("beta"));
if (operation === "enqueue") {
const result = await enqueuePluginNextTurnInjection({
cfg: tempConfig,
pluginId: "owner-fixture",
injection: { agentId: "qa", sessionKey: "global", text: "only qa" },
});
expect(result.enqueued).toBe(true);
const queued = loadSessionEntryReadOnly(scope("qa"))?.pluginNextTurnInjections?.[
"owner-fixture"
];
expect(queued?.map((entry) => entry.text)).toEqual(["qa", "only qa"]);
expect(queued?.[1]).not.toHaveProperty("agentId");
} else if (operation === "drain") {
const result = await drainPluginNextTurnInjectionContext({
cfg: tempConfig,
agentId: "qa",
sessionKey: "global",
});
expect(result.prependContext).toBe("qa");
expect(loadSessionEntryReadOnly(scope("qa"))?.pluginNextTurnInjections).toBeUndefined();
} else {
await expect(
patchPluginSessionExtension({
cfg: tempConfig,
agentId: "qa",
sessionKey: "global",
pluginId: "owner-fixture",
namespace: "workflow",
value: { owner: "qa", approved: true },
}),
).resolves.toMatchObject({ ok: true });
expect(
getPluginSessionExtensionStateSync({
cfg: tempConfig,
agentId: "qa",
sessionKey: "global",
pluginId: "owner-fixture",
}),
).toEqual({ workflow: { owner: "qa", approved: true } });
await expect(
runTrustedToolPolicies(
{ toolName: "read", params: {} },
{ toolName: "read", sessionKey: "global", agentId: "qa" },
{ config: tempConfig, registry },
),
).resolves.toEqual({ params: { workflow: { owner: "qa", approved: true } } });
}
expect(loadSessionEntryReadOnly(scope("beta"))).toEqual(betaBefore);
},
(storePath) => ({
agents: { ownership: "explicit", entries: { qa: {}, beta: {} } },
session: { store: path.join(path.dirname(storePath), "{agentId}", "sessions.json") },
}),
);
},
);
it("reports duplicate next-turn injections as not newly enqueued", async () => {
await withHostHookState("openclaw-host-hooks-injection-", async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
};
return undefined;
});
const now = Date.now();
const first = await enqueuePluginNextTurnInjection({
cfg: tempConfig,
pluginId: "approval-fixture",
injection: {
sessionKey: "agent:main:main",
text: "resume approval workflow",
placement: "prepend_context",
idempotencyKey: "approval:resume",
},
now,
});
const duplicate = await enqueuePluginNextTurnInjection({
cfg: tempConfig,
pluginId: "approval-fixture",
injection: {
sessionKey: "agent:main:main",
text: "resume approval workflow again",
placement: "prepend_context",
idempotencyKey: "approval:resume",
},
now: now + 1,
});
expect(first.enqueued).toBe(true);
expect(duplicate).toEqual({
enqueued: false,
id: first.id,
sessionKey: "agent:main:main",
});
const stored = loadSessionStore(storePath, { skipCache: true });
expect(
stored["agent:main:main"]?.pluginNextTurnInjections?.["approval-fixture"],
).toHaveLength(1);
});
});
it("suppresses stale next-turn injections from plugins that are no longer loaded", async () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
createPluginRecord({
id: "active-injector",
name: "Active Injector",
status: "loaded",
}),
createPluginRecord({
id: "disabled-injector",
name: "Disabled Injector",
status: "disabled",
}),
createPluginRecord({
id: "policy-blocked-injector",
name: "Policy Blocked Injector",
status: "loaded",
}),
);
setActivePluginRegistry(registry);
await withHostHookState(
"openclaw-host-hooks-stale-",
async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
pluginNextTurnInjections: {
"active-injector": [
{
id: "active",
pluginId: "active-injector",
text: "active prompt contribution",
placement: "append_context",
createdAt: 1,
},
],
"disabled-injector": [
{
id: "stale",
pluginId: "disabled-injector",
text: "stale prompt contribution",
placement: "prepend_context",
createdAt: 1,
},
],
"policy-blocked-injector": [
{
id: "policy-blocked",
pluginId: "policy-blocked-injector",
text: "policy blocked prompt contribution",
placement: "prepend_context",
createdAt: 1,
},
],
},
};
return undefined;
});
const { queuedInjections: drained } = await drainPluginNextTurnInjectionContext({
cfg: tempConfig,
sessionKey: "agent:main:main",
now: 2,
});
expect(drained).toHaveLength(1);
expectRecordFields(drained[0], {
id: "active",
pluginId: "active-injector",
text: "active prompt contribution",
});
const stored = loadSessionStore(storePath, { skipCache: true });
expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined();
},
(storePath) => ({
session: { store: storePath },
plugins: {
entries: {
"policy-blocked-injector": {
hooks: { allowPromptInjection: false },
},
},
},
}),
);
});
it("preserves global enqueue order when draining live next-turn injections", async () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
createPluginRecord({
id: "injector-a",
name: "Injector A",
status: "loaded",
}),
createPluginRecord({
id: "injector-b",
name: "Injector B",
status: "loaded",
}),
);
setActivePluginRegistry(registry);
await withHostHookState("openclaw-host-hooks-order-", async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
pluginNextTurnInjections: {
"injector-a": [
{
id: "a1",
pluginId: "injector-a",
text: "first",
placement: "append_context",
createdAt: 1,
},
{
id: "a2",
pluginId: "injector-a",
text: "third",
placement: "append_context",
createdAt: 3,
},
],
"injector-b": [
{
id: "b1",
pluginId: "injector-b",
text: "second",
placement: "append_context",
createdAt: 2,
},
],
},
};
return undefined;
});
const { queuedInjections: drained } = await drainPluginNextTurnInjectionContext({
cfg: tempConfig,
sessionKey: "agent:main:main",
now: 4,
});
expect(drained).toHaveLength(3);
expectRecordFields(drained[0], { id: "a1", text: "first" });
expectRecordFields(drained[1], { id: "b1", text: "second" });
expectRecordFields(drained[2], { id: "a2", text: "third" });
});
});
it("validates gateway protocol envelopes for plugin patch and UI descriptors", () => {
expect(
validateSessionsPluginPatchParams({
key: "agent:main:main",
pluginId: "approval-plugin",
namespace: "workflow",
value: { state: "waiting" },
}),
).toBe(true);
expect(
validateSessionsPluginPatchParams({
key: "agent:main:main",
pluginId: "approval-plugin",
namespace: "workflow",
value: { state: "waiting" },
accidentalPlanModeRootField: true,
}),
).toBe(false);
expect(validatePluginsUiDescriptorsParams({})).toBe(true);
expect(validatePluginsUiDescriptorsParams({ pluginId: "host-hook-fixture" })).toBe(false);
expect(
validatePluginsUiDescriptorsResult({
ok: true,
descriptors: [
{
id: "approval-panel",
pluginId: "host-hook-fixture",
surface: "session",
label: "Approval panel",
},
],
}),
).toBe(true);
expect(
validatePluginsUiDescriptorsResult({
ok: true,
descriptors: [
{
id: "approval-panel",
pluginId: "host-hook-fixture",
surface: "session",
label: "Approval panel",
leakedRegistryField: true,
},
],
}),
).toBe(false);
});
it("projects plugin UI descriptor metadata through the strict gateway result shape", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
}),
register(api) {
api.registerControlUiDescriptor({
id: "approval-panel",
surface: "session",
label: "Approval panel",
});
api.registerControlUiDescriptor({
id: "admin-panel",
surface: "settings",
label: "Admin panel",
requiredScopes: ["operator.admin"],
});
},
});
const descriptorEntry = registry.registry.controlUiDescriptors[0];
if (!descriptorEntry) {
throw new Error("expected control UI descriptor registration");
}
Object.assign(descriptorEntry.descriptor, { leakedRegistryField: true });
setActivePluginRegistry(registry.registry);
const methodRegistry = createGatewayMethodRegistry(
createCoreGatewayMethodDescriptors(pluginHostHookHandlers),
registry.registry,
);
const context: Pick<GatewayRequestContext, "getRuntimeConfig" | "getGatewayMethodRegistry"> = {
getRuntimeConfig: () => config,
getGatewayMethodRegistry: () => methodRegistry,
};
const calls: Array<[boolean, unknown, unknown]> = [];
void expectDefined(
pluginHostHookHandlers["plugins.uiDescriptors"],
'pluginHostHookHandlers["plugins.uiDescriptors"] test invariant',
)({
req: { type: "req", id: "ui-descriptors", method: "plugins.uiDescriptors", params: {} },
params: {},
client: {
connect: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: { id: "gateway-client", version: "test", platform: "test", mode: "backend" },
scopes: [ADMIN_SCOPE],
},
},
isWebchatConnect: () => false,
context: context as GatewayRequestContext,
respond: (ok: boolean, payload: unknown, error: unknown) => {
calls.push([ok, payload, error]);
},
});
expect(calls).toHaveLength(1);
const [ok, payload, error] = calls[0] ?? [];
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(validatePluginsUiDescriptorsResult(payload)).toBe(true);
expect(payload).toEqual({
ok: true,
generation: getActivePluginRegistryVersion(),
methods: ["plugins.uiDescriptors", "plugins.sessionAction"],
controlUiTabs: [],
controlUiWidgetKinds: [
{ pluginId: "session", kind: "session:report", label: "Report" },
{ pluginId: "session", kind: "session:progress", label: "Session progress" },
{ pluginId: "session", kind: "session:website", label: "Website" },
],
pluginSurfaceUrls: {},
descriptors: [
{
id: "admin-panel",
pluginId: "host-hook-fixture",
pluginName: "Host Hook Fixture",
surface: "settings",
label: "Admin panel",
requiredScopes: ["operator.admin"],
},
{
id: "approval-panel",
pluginId: "host-hook-fixture",
pluginName: "Host Hook Fixture",
surface: "session",
label: "Approval panel",
},
],
});
});
it("enforces command requiredScopes for gateway clients and command owners", async () => {
const handlerCalls: string[] = [];
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "approval-command-fixture",
name: "Approval Command Fixture",
}),
register(api) {
api.registerCommand({
name: "approval-fixture",
description: "Continue the agent after approval.",
requiredScopes: [APPROVALS_SCOPE],
acceptsArgs: true,
handler: async (ctx) => {
handlerCalls.push(ctx.args ?? "");
return { text: "approval queued", continueAgent: true };
},
});
},
});
const registration = requireFirstCommandRegistration(registry.registry);
const command = {
...registration.command,
pluginId: registration.pluginId,
pluginName: registration.pluginName,
pluginRoot: registration.rootDir,
};
expect(
registerPluginCommandInRegistry(registry.registry, "invalid-command-fixture", {
name: "invalid-scopes-fixture",
description: "Invalid scopes.",
requiredScopes: "operator.approvals" as never,
handler: () => ({ text: "unused" }),
}).error,
).toBe("Command requiredScopes must be an array of operator scopes");
expect(
registerPluginCommandInRegistry(registry.registry, "invalid-command-fixture", {
name: "unknown-scopes-fixture",
description: "Unknown scopes.",
requiredScopes: ["operator.unknown" as never],
handler: () => ({ text: "unused" }),
}).error,
).toBe("Command requiredScopes contains unknown operator scope: operator.unknown");
expect(
registerPluginCommandInRegistry(registry.registry, "invalid-command-fixture", {
name: "invalid-owner-status-fixture",
description: "Invalid owner status exposure.",
exposeSenderIsOwner: "yes" as never,
handler: () => ({ text: "unused" }),
}).error,
).toBe("Command exposeSenderIsOwner must be a boolean");
await expect(
executePluginCommand({
command,
args: "resume-text",
senderId: "owner",
channel: "whatsapp",
isAuthorizedSender: true,
senderIsOwner: true,
sessionKey: "agent:main:main",
commandBody: "/approval-fixture resume-text",
config,
}),
).resolves.toEqual({ text: "approval queued", continueAgent: true });
expect(handlerCalls).toEqual(["resume-text"]);
await expect(
executePluginCommand({
command,
args: "resume",
senderId: "owner",
channel: "whatsapp",
isAuthorizedSender: true,
gatewayClientScopes: [READ_SCOPE, WRITE_SCOPE],
sessionKey: "agent:main:main",
commandBody: "/approval-fixture resume",
config,
}),
).resolves.toEqual({
text: `⚠️ This command requires gateway scope: ${APPROVALS_SCOPE}.`,
});
expect(handlerCalls).toEqual(["resume-text"]);
await expect(
executePluginCommand({
command,
args: "resume",
senderId: "owner",
channel: "whatsapp",
isAuthorizedSender: true,
gatewayClientScopes: [APPROVALS_SCOPE],
sessionKey: "agent:main:main",
commandBody: "/approval-fixture resume",
config,
}),
).resolves.toEqual({ text: "approval queued", continueAgent: true });
expect(handlerCalls).toEqual(["resume-text", "resume"]);
});
it("dispatches sanitized agent events and clears plugin run context on run end", async () => {
const { config, registry } = createHostHookFixtureRegistry();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
}),
register: registerHostHookFixture,
});
setActivePluginRegistry(registry.registry);
emitAgentEvent({
runId: "run-1",
stream: "tool",
data: { name: "approval_fixture_tool" },
});
await Promise.resolve();
expect(
getPluginRunContext({
pluginId: "host-hook-fixture",
get: { runId: "run-1", namespace: "lastToolEvent" },
}),
).toEqual({ runId: "run-1", seen: true });
emitAgentEvent({
runId: "run-1",
stream: "lifecycle",
data: { phase: "end" },
});
await waitForPluginEventHandlers();
expect(
getPluginRunContext({
pluginId: "host-hook-fixture",
get: { runId: "run-1", namespace: "lastToolEvent" },
}),
).toBeUndefined();
});
it("clears run context on terminal events even when no plugin subscribes to agent events", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
expect(
setPluginRunContext({
pluginId: "context-only-plugin",
patch: { runId: "run-no-subscribers", namespace: "state", value: { ok: true } },
}),
).toBe(true);
emitAgentEvent({
runId: "run-no-subscribers",
stream: "lifecycle",
data: { phase: "end" },
});
await waitForPluginEventHandlers();
expect(
getPluginRunContext({
pluginId: "context-only-plugin",
get: { runId: "run-no-subscribers", namespace: "state" },
}),
).toBeUndefined();
});
it("continues agent event dispatch and terminal cleanup when one subscription throws", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "throwing-subscription",
name: "Throwing Subscription",
}),
register(api) {
api.registerAgentEventSubscription({
id: "throws",
streams: ["tool"],
handle() {
throw new Error("subscription failed");
},
});
},
});
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "healthy-subscription",
name: "Healthy Subscription",
}),
register(api) {
api.registerAgentEventSubscription({
id: "records",
streams: ["tool"],
handle(event, ctx) {
ctx.setRunContext("seen", { runId: event.runId });
},
});
},
});
setActivePluginRegistry(registry.registry);
emitAgentEvent({
runId: "run-throws",
stream: "tool",
data: { name: "approval_fixture_tool" },
});
await Promise.resolve();
expect(
getPluginRunContext({
pluginId: "healthy-subscription",
get: { runId: "run-throws", namespace: "seen" },
}),
).toEqual({ runId: "run-throws" });
emitAgentEvent({
runId: "run-throws",
stream: "lifecycle",
data: { phase: "end" },
});
await waitForPluginEventHandlers();
expect(
getPluginRunContext({
pluginId: "healthy-subscription",
get: { runId: "run-throws", namespace: "seen" },
}),
).toBeUndefined();
});
it("cleans plugin-owned session state and lifecycle resources on reset/disable", async () => {
const cleanupEvents: string[] = [];
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "cleanup-fixture",
name: "Cleanup Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "cleanup test",
cleanup: ({ reason, sessionKey }) => {
cleanupEvents.push(`session:${reason}:${sessionKey ?? ""}`);
},
});
api.registerRuntimeLifecycle({
id: "monitor",
cleanup: ({ reason, sessionKey }) => {
cleanupEvents.push(`runtime:${reason}:${sessionKey ?? ""}`);
},
});
api.registerSessionSchedulerJob({
id: "nudge",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: ({ reason, sessionKey }) => {
cleanupEvents.push(`scheduler:${reason}:${sessionKey}`);
},
});
},
});
setActivePluginRegistry(registry.registry);
const entry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"cleanup-fixture": { workflow: { state: "waiting" } },
"other-plugin": { workflow: { state: "keep" } },
},
pluginNextTurnInjections: {
"cleanup-fixture": [
{
id: "resume",
pluginId: "cleanup-fixture",
text: "resume",
placement: "prepend_context" as const,
createdAt: 1,
},
],
"other-plugin": [
{
id: "keep",
pluginId: "other-plugin",
text: "keep",
placement: "append_context" as const,
createdAt: 1,
},
],
},
};
clearPluginOwnedSessionState(entry, "cleanup-fixture");
expect(entry.pluginExtensions).toEqual({
"other-plugin": { workflow: { state: "keep" } },
});
expect(entry.pluginNextTurnInjections).toEqual({
"other-plugin": [
{
id: "keep",
pluginId: "other-plugin",
text: "keep",
placement: "append_context",
createdAt: 1,
},
],
});
await withHostHookState("openclaw-host-hooks-state-", async ({ tempConfig }) => {
await runPluginHostCleanup({
cfg: tempConfig,
registry: registry.registry,
pluginId: "cleanup-fixture",
reason: "reset",
sessionKey: "agent:main:main",
});
const next = createEmptyPluginRegistry();
stageActivePluginRegistry(next, null, "default");
await createPluginHostRegistryRetirement({
cfg: tempConfig,
previousRegistry: registry.registry,
nextRegistry: next,
})();
});
expect(cleanupEvents).toEqual([
"session:reset:agent:main:main",
"runtime:reset:agent:main:main",
"scheduler:reset:agent:main:main",
"session:disable:",
"runtime:disable:",
]);
expect(listPluginSessionSchedulerJobs("cleanup-fixture")).toStrictEqual([]);
});
it("keeps scheduler job records when cleanup fails so cleanup can retry", async () => {
const cleanup = vi.fn<() => void>().mockImplementationOnce(() => {
throw new Error("cleanup failed");
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "cleanup-failure-fixture",
name: "Cleanup Failure Fixture",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "retryable-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup,
});
},
});
setActivePluginRegistry(registry.registry);
const cleanupResult = await runPluginHostCleanup({
cfg: config,
registry: registry.registry,
pluginId: "cleanup-failure-fixture",
reason: "disable",
});
expect(cleanupResult.failures).toHaveLength(1);
expectRecordFields(cleanupResult.failures[0], {
pluginId: "cleanup-failure-fixture",
hookId: "scheduler:retryable-job",
});
expect(listPluginSessionSchedulerJobs("cleanup-failure-fixture")).toEqual([
{
id: "retryable-job",
pluginId: "cleanup-failure-fixture",
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
await expect(
runPluginHostCleanup({
cfg: config,
registry: registry.registry,
pluginId: "cleanup-failure-fixture",
reason: "disable",
}),
).resolves.toEqual({ cleanupCount: 0, failures: [] });
expect(cleanup).toHaveBeenCalledTimes(2);
expect(listPluginSessionSchedulerJobs("cleanup-failure-fixture")).toEqual([]);
});
it("preserves restarted scheduler jobs while cleaning the replaced registry", async () => {
const cleanupEvents: string[] = [];
const previous = createEmptyPluginRegistry();
previous.plugins.push(
createPluginRecord({
id: "restart-fixture",
name: "Restart Fixture",
status: "loaded",
}),
);
previous.sessionSchedulerJobs = [
{
pluginId: "restart-fixture",
pluginName: "Restart Fixture",
job: {
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: ({ reason, jobId }) => {
cleanupEvents.push(`${reason}:${jobId}`);
},
},
source: "/virtual/restart-fixture/index.ts",
rootDir: "/virtual/restart-fixture",
},
];
const next = createEmptyPluginRegistry();
next.plugins.push(
createPluginRecord({
id: "restart-fixture",
name: "Restart Fixture",
status: "loaded",
}),
);
next.sessionSchedulerJobs = [
{
pluginId: "restart-fixture",
pluginName: "Restart Fixture",
job: {
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: () => undefined,
},
source: "/virtual/restart-fixture/index.ts",
rootDir: "/virtual/restart-fixture",
},
];
setActivePluginRegistry(previous);
stageActivePluginRegistry(next, null, "default");
const cleanupResult = await createPluginHostRegistryRetirement({
cfg: {},
previousRegistry: previous,
nextRegistry: next,
})();
expect(cleanupResult.failures).toEqual([]);
expect(cleanupEvents).toStrictEqual([]);
expect(listPluginSessionSchedulerJobs("restart-fixture")).toEqual([
{
id: "shared-job",
pluginId: "restart-fixture",
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
});
it("does not invoke old scheduler cleanup for a preserved newer generation", async () => {
const cleanupEvents: string[] = [];
const previousFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: previousFixture.registry,
config: previousFixture.config,
record: createPluginRecord({
id: "scheduler-preserve",
name: "Scheduler Preserve",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: ({ reason, jobId }) => {
cleanupEvents.push(`${reason}:${jobId}`);
},
});
},
});
setActivePluginRegistry(previousFixture.registry.registry);
const replacementFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: replacementFixture.registry,
config: replacementFixture.config,
record: createPluginRecord({
id: "scheduler-preserve",
name: "Scheduler Preserve",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
});
},
});
stageActivePluginRegistry(replacementFixture.registry.registry, null, "default");
await expect(
createPluginHostRegistryRetirement({
cfg: previousFixture.config,
previousRegistry: previousFixture.registry.registry,
nextRegistry: replacementFixture.registry.registry,
})(),
).resolves.toEqual({ cleanupCount: 0, failures: [] });
expect(cleanupEvents).toEqual([]);
expect(listPluginSessionSchedulerJobs("scheduler-preserve")).toEqual([
{
id: "shared-job",
pluginId: "scheduler-preserve",
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
});
it("does not let stale scheduler cleanup delete a newer job generation", async () => {
const cleanupStarted = createDeferredCore();
const finishCleanup = createDeferredCore();
const previousFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: previousFixture.registry,
config: previousFixture.config,
record: createPluginRecord({
id: "scheduler-race",
name: "Scheduler Race",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: async () => {
cleanupStarted.resolve();
await finishCleanup.promise;
},
});
},
});
setActivePluginRegistry(previousFixture.registry.registry);
const next = createEmptyPluginRegistry();
stageActivePluginRegistry(next, null, "default");
const cleanupPromise = createPluginHostRegistryRetirement({
cfg: previousFixture.config,
previousRegistry: previousFixture.registry.registry,
nextRegistry: next,
})();
try {
await Promise.race([
cleanupStarted.promise,
cleanupPromise.then((result) => {
expect(result.failures).toEqual([]);
throw new Error("Expected scheduler cleanup to start before retirement settled");
}),
]);
const replacementFixture = createPluginRegistryFixture();
registerTestPlugin({
registry: replacementFixture.registry,
config: replacementFixture.config,
record: createPluginRecord({
id: "scheduler-race",
name: "Scheduler Race",
}),
register(api) {
api.registerSessionSchedulerJob({
id: "shared-job",
sessionKey: "agent:main:main",
kind: "monitor",
});
},
});
setActivePluginRegistry(replacementFixture.registry.registry);
finishCleanup.resolve();
const cleanupResult = await cleanupPromise;
expect(cleanupResult.failures).toEqual([]);
expect(listPluginSessionSchedulerJobs("scheduler-race")).toEqual([
{
id: "shared-job",
pluginId: "scheduler-race",
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
} finally {
finishCleanup.resolve();
await cleanupPromise;
}
});
it("does not register scheduler jobs globally during non-activating registry loads", () => {
const registry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
activateGlobalSideEffects: false,
});
const config = {};
let handle:
| {
id: string;
pluginId: string;
sessionKey: string;
kind: string;
}
| undefined;
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "snapshot-fixture",
name: "Snapshot Fixture",
}),
register(api) {
handle = api.registerSessionSchedulerJob({
id: "snapshot-job",
sessionKey: "agent:main:main",
kind: "monitor",
});
},
});
expect(handle).toEqual({
id: "snapshot-job",
pluginId: "snapshot-fixture",
sessionKey: "agent:main:main",
kind: "monitor",
});
const schedulerJobs = registry.registry.sessionSchedulerJobs;
expect(schedulerJobs).toHaveLength(1);
const schedulerJob = schedulerJobs[0];
expect(schedulerJob?.pluginId).toBe("snapshot-fixture");
expectRecordFields(schedulerJob?.job, {
id: "snapshot-job",
sessionKey: "agent:main:main",
kind: "monitor",
});
expect(listPluginSessionSchedulerJobs("snapshot-fixture")).toStrictEqual([]);
});
it("removes persistent plugin-owned session state and pending injections during cleanup", async () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "cleanup-fixture",
name: "Cleanup Fixture",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "cleanup test",
});
},
});
await withHostHookState("openclaw-host-hooks-store-", async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
pluginExtensions: {
"cleanup-fixture": { workflow: { state: "waiting" } },
"other-plugin": { workflow: { state: "keep" } },
},
pluginNextTurnInjections: {
"cleanup-fixture": [
{
id: "resume",
pluginId: "cleanup-fixture",
text: "resume",
placement: "prepend_context",
createdAt: 1,
},
],
"other-plugin": [
{
id: "keep",
pluginId: "other-plugin",
text: "keep",
placement: "append_context",
createdAt: 1,
},
],
},
};
return undefined;
});
const cleanupResult = await runPluginHostCleanup({
cfg: tempConfig,
registry: registry.registry,
pluginId: "cleanup-fixture",
reason: "disable",
});
expect(cleanupResult.failures).toEqual([]);
const stored = loadSessionStore(storePath, { skipCache: true });
expectRecordFields(stored["agent:main:main"], {
pluginExtensions: {
"other-plugin": { workflow: { state: "keep" } },
},
pluginNextTurnInjections: {
"other-plugin": [
{
id: "keep",
pluginId: "other-plugin",
text: "keep",
placement: "append_context",
createdAt: 1,
},
],
},
});
});
});
it("does not clear unrelated run context during session-scoped cleanup", async () => {
const registry = createEmptyPluginRegistry();
expect(
setPluginRunContext({
pluginId: "plugin-a",
patch: { runId: "run-a", namespace: "state", value: { keep: "a" } },
}),
).toBe(true);
expect(
setPluginRunContext({
pluginId: "plugin-b",
patch: { runId: "run-b", namespace: "state", value: { keep: "b" } },
}),
).toBe(true);
await withHostHookState("openclaw-host-hooks-run-context-", async ({ tempConfig }) => {
await runPluginHostCleanup({
cfg: tempConfig,
registry,
reason: "reset",
sessionKey: "agent:main:main",
});
});
expect(
getPluginRunContext({
pluginId: "plugin-a",
get: { runId: "run-a", namespace: "state" },
}),
).toEqual({ keep: "a" });
expect(
getPluginRunContext({
pluginId: "plugin-b",
get: { runId: "run-b", namespace: "state" },
}),
).toEqual({ keep: "b" });
});
it("cleans pending injections for plugins that registered no host-hook callbacks", async () => {
const previousRegistry = createEmptyPluginRegistry();
previousRegistry.plugins.push(
createPluginRecord({
id: "injection-only-fixture",
name: "Injection Only Fixture",
status: "loaded",
}),
);
await withHostHookState(
"openclaw-host-hooks-injection-only-",
async ({ storePath, tempConfig }) => {
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
pluginNextTurnInjections: {
"injection-only-fixture": [
{
id: "resume",
pluginId: "injection-only-fixture",
text: "resume",
placement: "prepend_context",
createdAt: 1,
},
],
},
};
return undefined;
});
const cleanupResult = await createPluginHostRegistryRetirement({
cfg: tempConfig,
previousRegistry,
nextRegistry: createEmptyPluginRegistry(),
})();
expect(cleanupResult.failures).toEqual([]);
const stored = loadSessionStore(storePath, { skipCache: true });
expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined();
},
);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */