// 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 { await new Promise((resolve) => { setImmediate(resolve); }); } function requireFirstCommandRegistration( registry: ReturnType["registry"]["registry"], ) { const registration = registry.commands[0]; if (!registration) { throw new Error("expected first plugin command registration"); } return registration; } function joinContextFragments(...fragments: Array): 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 { return Object.fromEntries( listSessionEntriesCore({ agentId: "main", storePath }).map(({ sessionKey, entry }) => [ sessionKey, entry, ]), ); } async function updateSessionStore( storePath: string, update: (store: Record) => void, ): Promise { const store: Record = {}; update(store); for (const [sessionKey, entry] of Object.entries(store)) { await replaceSessionEntry({ sessionKey, storePath }, entry); } } function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); } const actual = record as Record; 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; }; async function withHostHookState( prefix: string, run: (fixture: HostHookStateFixture) => Promise, createTempConfig: (storePath: string) => HostHookStateFixture["tempConfig"] = (storePath) => ({ agents: { entries: { main: { default: true } } }, session: { store: storePath }, }), ): Promise { 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((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[] = []; 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 = { 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. */