// Session action contract tests cover plugin session action metadata and execution contracts. import { expectDefined } from "@openclaw/normalization-core"; import { createPluginRegistryFixture, registerTestPlugin, } from "openclaw/plugin-sdk/plugin-test-contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, QUESTIONS_SCOPE, READ_SCOPE, TALK_SCOPE, TALK_SECRETS_SCOPE, WRITE_SCOPE, type OperatorScope, } from "../../gateway/operator-scopes.js"; import { handleGatewayRequest } from "../../gateway/server-methods.js"; import { pluginHostHookHandlers } from "../../gateway/server-methods/plugin-host-hooks.js"; import type { GatewayClient, RespondFn } from "../../gateway/server-methods/types.js"; import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; import type { PluginSessionActionContext } from "../host-hooks.js"; import { createEmptyPluginRegistry } from "../registry-empty.js"; import { createPluginRegistry } from "../registry.js"; import { captureActivePluginRegistrySnapshot, rollbackStagedPluginRegistry, setActivePluginRegistry, stageActivePluginRegistry, } from "../runtime.js"; import { createPluginRecord } from "../status.test-fixtures.js"; import type { OpenClawPluginApi } from "../types.js"; const MAIN_SESSION_KEY = "agent:main:main"; type HookResponse = { ok: boolean; payload?: unknown; error?: unknown }; function sessionActionBody( pluginId: string, actionId: string, extra?: Record, ): Record { return { pluginId, actionId, ...extra, }; } async function callPluginSessionActionForTest(params: { body: Record; scopes?: string[]; }): Promise { let response: HookResponse | undefined; const respond: RespondFn = (ok, payload, error) => { response = { ok, payload, error }; }; await expectDefined( pluginHostHookHandlers["plugins.sessionAction"], 'pluginHostHookHandlers["plugins.sessionAction"] test invariant', )({ req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body }, params: params.body, client: { connId: "test-client", connect: { scopes: params.scopes ?? [WRITE_SCOPE] }, } as GatewayClient, isWebchatConnect: () => false, respond, context: { getRuntimeConfig: () => ({}) } as never, }); return response ?? { ok: false, error: new Error("handler did not respond") }; } async function callRegisteredSessionActionForTest(params: { pluginId: string; actionId: string; extra?: Record; scopes?: string[]; }): Promise { return callPluginSessionActionForTest({ body: sessionActionBody(params.pluginId, params.actionId, params.extra), ...(params.scopes ? { scopes: params.scopes } : {}), }); } async function callPluginSessionActionThroughGatewayForTest(params: { body: Record; scopes?: string[]; }): Promise { let response: HookResponse | undefined; const respond: RespondFn = (ok, payload, error) => { response = { ok, payload, error }; }; await handleGatewayRequest({ req: { id: "test", type: "req", method: "plugins.sessionAction", params: params.body }, respond, client: { connId: "test-client", connect: { role: "operator", scopes: params.scopes ?? [], }, } as GatewayClient, isWebchatConnect: () => false, context: { logGateway: { warn() {}, }, getRuntimeConfig: () => ({}), } as unknown as Parameters[0]["context"], }); return response ?? { ok: false, error: new Error("handler did not respond") }; } async function callRegisteredSessionActionThroughGatewayForTest(params: { pluginId: string; actionId: string; extra?: Record; scopes?: string[]; }): Promise { return callPluginSessionActionThroughGatewayForTest({ body: sessionActionBody(params.pluginId, params.actionId, params.extra), ...(params.scopes ? { scopes: params.scopes } : {}), }); } function requireHookError(response: HookResponse): { code?: unknown; message?: unknown } { expect(response.ok).toBe(false); const error = response.error as { code?: unknown; message?: unknown } | undefined; if (!error) { throw new Error("expected hook error"); } return error; } function requireObservedEvent( observed: unknown[], index: number, ): { runId?: unknown; sessionKey?: unknown; stream?: unknown; data?: Record } { const event = observed[index] as | { runId?: unknown; sessionKey?: unknown; stream?: unknown; data?: Record } | undefined; if (!event) { throw new Error(`expected observed event #${index + 1}`); } return event; } function registerActionFixture(params: { id: string; name?: string; register: (api: OpenClawPluginApi) => void; }) { const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ registry, config, record: createPluginRecord({ id: params.id, name: params.name ?? params.id, }), register: params.register, }); return { config, registry }; } describe("plugin session actions", () => { afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); resetAgentEventsForTest(); }); it("initializes and registers typed session actions", () => { expect(createEmptyPluginRegistry().sessionActions).toEqual([]); const { registry } = registerActionFixture({ id: "session-action-fixture", name: "Session Action Fixture", register(api) { api.registerSessionAction({ id: "approve", description: "Approve the current workflow", requiredScopes: [APPROVALS_SCOPE], handler: () => ({ ok: true, result: { accepted: true } }), }); }, }); expect(registry.registry.sessionActions).toHaveLength(1); const actionEntry = registry.registry.sessionActions[0]; expect(actionEntry?.pluginId).toBe("session-action-fixture"); expect(actionEntry?.pluginName).toBe("Session Action Fixture"); expect(actionEntry?.action.id).toBe("approve"); expect(actionEntry?.action.description).toBe("Approve the current workflow"); expect(actionEntry?.action.requiredScopes).toEqual([APPROVALS_SCOPE]); }); it("rejects invalid or duplicate session action registrations", () => { const { registry } = registerActionFixture({ id: "invalid-session-actions", name: "Invalid Session Actions", register(api) { for (const action of [ { id: "dup" }, { id: "dup" }, { id: "bad-scope", requiredScopes: ["not-a-scope"] as never }, { id: "bad-schema-shape", schema: "not-an-object" as never }, { id: "bad-schema-compile", schema: { type: "not-a-json-schema-type" } as never }, { id: "bad-schema-keyword", schema: { type: "object", properties: { id: { type: "string" } }, required: "id", } as never, }, { id: "bad-schema-ref", schema: { $ref: "#/$defs/Missing" } as never, }, { id: "" }, ]) { api.registerSessionAction({ ...action, handler: () => ({ ok: true }), }); } }, }); expect(registry.registry.sessionActions.map((entry) => entry.action.id)).toEqual(["dup"]); const diagnosticMessages = registry.registry.diagnostics?.map((diagnostic) => { expect(diagnostic.pluginId).toBe("invalid-session-actions"); return diagnostic.message; }); expect(diagnosticMessages).toHaveLength(7); expect(diagnosticMessages).toContain("session action already registered: dup"); expect(diagnosticMessages).toContain( "session action requiredScopes contains unknown operator scope: not-a-scope", ); expect(diagnosticMessages).toContain( "session action schema must be a JSON schema object or boolean: bad-schema-shape", ); expect( diagnosticMessages?.some((message) => message.includes("session action schema is not valid JSON Schema: bad-schema-compile"), ), ).toBe(true); expect( diagnosticMessages?.some((message) => message.includes("session action schema is not valid JSON Schema: bad-schema-keyword"), ), ).toBe(true); expect( diagnosticMessages?.some((message) => message.includes("session action schema is not valid JSON Schema: bad-schema-ref"), ), ).toBe(true); expect(diagnosticMessages).toContain( "session action registration requires id, handler, and valid optional fields", ); }); it("validates payload schemas and typed action results", async () => { const callSchemaAction = ( actionId: string, extra?: Record, ): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> => callRegisteredSessionActionForTest({ pluginId: "schema-action-fixture", actionId, ...(extra ? { extra } : {}), }); const handlerCalls: unknown[] = []; const { registry } = registerActionFixture({ id: "schema-action-fixture", name: "Schema Action Fixture", register(api) { api.registerSessionAction({ id: "approve", schema: { type: "object", additionalProperties: false, required: ["version"], properties: { version: { type: "string" }, }, }, handler: ({ payload, sessionKey, client }) => { handlerCalls.push({ payload, sessionKey, scopes: client?.scopes ?? [] }); return { result: { accepted: true, ...(sessionKey ? { sessionKey } : {}) }, continueAgent: true, reply: { text: "approved" }, }; }, }); api.registerSessionAction({ id: "typed-error", handler: () => ({ ok: false, error: "needs operator input", code: "needs_input", details: { field: "version" }, }), }); api.registerSessionAction({ id: "allow-any", schema: true, handler: ({ payload }) => ({ result: { payload: payload ?? null } }), }); api.registerSessionAction({ id: "deny-all", schema: false, handler: () => ({ result: { unreachable: true } }), }); }, }); setActivePluginRegistry(registry.registry); const rejected = await callPluginSessionActionForTest({ body: sessionActionBody("schema-action-fixture", "approve", { payload: { version: 1 } }), }); const rejectedError = requireHookError(rejected); expect(rejectedError.code).toBe("INVALID_REQUEST"); expect(String(rejectedError.message)).toContain( "plugin session action payload does not match schema", ); expect(handlerCalls).toEqual([]); await expect( callSchemaAction("approve", { sessionKey: MAIN_SESSION_KEY, payload: { version: "2026.05.01" }, }), ).resolves.toEqual({ ok: true, payload: { ok: true, result: { accepted: true, sessionKey: MAIN_SESSION_KEY }, continueAgent: true, reply: { text: "approved" }, }, error: undefined, }); expect(handlerCalls).toEqual([ { payload: { version: "2026.05.01" }, sessionKey: MAIN_SESSION_KEY, scopes: [WRITE_SCOPE], }, ]); await expect(callSchemaAction("typed-error")).resolves.toEqual({ ok: true, payload: { ok: false, error: "needs operator input", code: "needs_input", details: { field: "version", }, }, error: undefined, }); await expect( callSchemaAction("allow-any", { payload: { any: ["json", true] } }), ).resolves.toEqual({ ok: true, payload: { ok: true, result: { payload: { any: ["json", true] } }, }, error: undefined, }); const denyAll = await callSchemaAction("deny-all", { payload: { rejected: true } }); expect(requireHookError(denyAll).code).toBe("INVALID_REQUEST"); }); it("validates plugin session action results before returning gateway payloads", async () => { const callValidationAction = ( actionId: string, ): Promise<{ ok: boolean; payload?: unknown; error?: unknown }> => callRegisteredSessionActionForTest({ pluginId: "session-action-validation-fixture", actionId, }); const { registry } = registerActionFixture({ id: "session-action-validation-fixture", name: "Session Action Validation Fixture", register(api) { const handlers = { "bad-result": () => ({ result: 1n as never }), "bad-reply": () => ({ reply: { text: "ok", extra: () => undefined } as never }), "primitive-result": () => "not-an-object" as never, "typed-error": () => ({ ok: false, error: "needs operator input", code: "needs_input", details: { field: "version" }, }), "bad-ok": () => ({ ok: "false", error: "must not masquerade as success", }) as never, "error-shaped-success": () => ({ error: "must declare ok false", }) as never, "bad-error-details": () => ({ ok: false, error: "bad details", details: { value: 1n } as never, }), "bad-continue-agent": () => ({ continueAgent: "yes" as never }), "mixed-branch-fields": () => ({ ok: false, error: "stop", continueAgent: true, result: { leaked: true }, }) as never, "unknown-success-field": () => ({ result: { accepted: true }, extra: "unexpected", }) as never, "throws-secret": () => { throw new Error("fixture action failed"); }, }; for (const [id, handler] of Object.entries(handlers)) { api.registerSessionAction({ id, handler: handler as never }); } }, }); setActivePluginRegistry(registry.registry); const expectValidationError = async ( actionId: string, message: { exact: string } | { includes: string }, ) => { const response = await callValidationAction(actionId); const error = requireHookError(response); expect(error.code).toBe("INVALID_REQUEST"); if ("exact" in message) { expect(error.message).toBe(message.exact); } else { expect(String(error.message)).toContain(message.includes); } }; await expectValidationError("bad-result", { exact: "plugin session action result must be JSON-compatible", }); await expectValidationError("bad-reply", { exact: "plugin session action reply must be JSON-compatible", }); const primitiveResult = await callValidationAction("primitive-result"); const primitiveResultError = requireHookError(primitiveResult); expect(primitiveResultError.code).toBe("INVALID_REQUEST"); expect(primitiveResultError.message).toBe("plugin session action result must be an object"); await expect(callValidationAction("typed-error")).resolves.toEqual({ ok: true, payload: { ok: false, error: "needs operator input", code: "needs_input", details: { field: "version", }, }, error: undefined, }); await expectValidationError("bad-ok", { includes: "/ok: must be boolean" }); await expectValidationError("error-shaped-success", { includes: "unexpected property 'error'", }); await expectValidationError("bad-error-details", { exact: "plugin session action details must be JSON-compatible", }); await expectValidationError("bad-continue-agent", { includes: "/continueAgent: must be boolean", }); await expectValidationError("mixed-branch-fields", { includes: "unexpected property 'continueAgent'", }); await expectValidationError("unknown-success-field", { includes: "unexpected property 'extra'", }); const throwsSecret = await callValidationAction("throws-secret"); const throwsSecretError = requireHookError(throwsSecret); expect(throwsSecretError.code).toBe("UNAVAILABLE"); expect(throwsSecretError.message).toBe("plugin session action failed"); }); describe("authorizes session actions through the gateway by action-declared scopes", () => { const pluginId = "scope-action-fixture"; const actionScopes = { approve: [APPROVALS_SCOPE], view: [READ_SCOPE], talk: [TALK_SCOPE], "talk-approve": [TALK_SCOPE, APPROVALS_SCOPE], "talk-secrets": [TALK_SECRETS_SCOPE], admin: [ADMIN_SCOPE], pairing: [PAIRING_SCOPE], questions: [QUESTIONS_SCOPE], "default-write": undefined, } satisfies Record; const handler = vi.fn(({ sessionKey }: PluginSessionActionContext) => ({ result: { accepted: true, ...(sessionKey ? { sessionKey } : {}) }, continueAgent: true, })); beforeEach(() => { handler.mockClear(); const { registry } = registerActionFixture({ id: pluginId, register(api) { for (const [id, requiredScopes] of Object.entries(actionScopes)) { api.registerSessionAction({ id, requiredScopes, handler }); } }, }); setActivePluginRegistry(registry.registry); }); it.each< [ name: string, actionId: keyof typeof actionScopes, scopes: OperatorScope[], missingScope?: OperatorScope, ] >([ ["approvals admits a custom approval action", "approve", [APPROVALS_SCOPE]], ["read cannot approve", "approve", [READ_SCOPE], APPROVALS_SCOPE], ["write cannot approve", "approve", [WRITE_SCOPE], APPROVALS_SCOPE], ["read admits a read action", "view", [READ_SCOPE]], ["write satisfies read", "view", [WRITE_SCOPE]], ["talk admits a talk action", "talk", [TALK_SCOPE]], ["write satisfies talk", "talk", [WRITE_SCOPE]], ["admin admits a talk action", "talk", [ADMIN_SCOPE]], ["read cannot talk", "talk", [READ_SCOPE], TALK_SCOPE], ["no scopes cannot talk", "talk", [], TALK_SCOPE], [ "write reports missing approvals after satisfying talk", "talk-approve", [WRITE_SCOPE], APPROVALS_SCOPE, ], [ "write and approvals satisfy talk and approvals", "talk-approve", [WRITE_SCOPE, APPROVALS_SCOPE], ], [ "talk and approvals satisfy both requirements", "talk-approve", [TALK_SCOPE, APPROVALS_SCOPE], ], ["admin satisfies both requirements", "talk-approve", [ADMIN_SCOPE]], ["write cannot read talk secrets", "talk-secrets", [WRITE_SCOPE], TALK_SECRETS_SCOPE], ["write cannot administer", "admin", [WRITE_SCOPE], ADMIN_SCOPE], ["write cannot pair", "pairing", [WRITE_SCOPE], PAIRING_SCOPE], ["write cannot answer questions", "questions", [WRITE_SCOPE], QUESTIONS_SCOPE], ["write admits an action with default scopes", "default-write", [WRITE_SCOPE]], ["talk cannot perform a default-write action", "default-write", [TALK_SCOPE], WRITE_SCOPE], ["read cannot perform a default-write action", "default-write", [READ_SCOPE], WRITE_SCOPE], ["no scopes cannot perform a default-write action", "default-write", [], WRITE_SCOPE], ["admin admits an action with default scopes", "default-write", [ADMIN_SCOPE]], ])("%s", async (_name, actionId, scopes, missingScope) => { const response = await callRegisteredSessionActionThroughGatewayForTest({ pluginId, actionId, scopes, }); const expectedResponse: HookResponse = missingScope ? { ok: false, payload: undefined, error: { code: "FORBIDDEN", message: `missing scope: ${missingScope}`, details: { code: "MISSING_SCOPE", missingScope, requiredScopes: actionScopes[actionId] ?? [WRITE_SCOPE], }, }, } : { ok: true, payload: { ok: true, result: { accepted: true }, continueAgent: true }, error: undefined, }; expect({ response, handlerCalls: handler.mock.calls.length }).toEqual({ response: expectedResponse, handlerCalls: missingScope ? 0 : 1, }); if (!missingScope) { expect(handler).toHaveBeenCalledWith({ pluginId, actionId, client: { connId: "test-client", scopes }, }); } }); it("normalizes session keys and rejects invalid params for approval-only callers", async () => { await expect( callRegisteredSessionActionThroughGatewayForTest({ pluginId, actionId: "approve", extra: { sessionKey: ` ${MAIN_SESSION_KEY} ` }, scopes: [APPROVALS_SCOPE], }), ).resolves.toEqual({ ok: true, payload: { ok: true, result: { accepted: true, sessionKey: MAIN_SESSION_KEY }, continueAgent: true, }, error: undefined, }); expect(handler).toHaveBeenCalledWith({ pluginId, actionId: "approve", sessionKey: MAIN_SESSION_KEY, agentId: "main", client: { connId: "test-client", scopes: [APPROVALS_SCOPE] }, }); const blankPluginId = await callPluginSessionActionThroughGatewayForTest({ body: { pluginId: " ", actionId: "approve" }, scopes: [APPROVALS_SCOPE], }); expect(requireHookError(blankPluginId)).toEqual({ code: "INVALID_REQUEST", message: "plugins.sessionAction pluginId and actionId must be non-empty", }); expect(handler).toHaveBeenCalledTimes(1); }); }); it("passes a defensive copy of client scopes to session action handlers", async () => { const registry = createEmptyPluginRegistry(); let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined; let handlerScopes: string[] | undefined; const originalScopes = [READ_SCOPE]; registry.sessionActions = [ { pluginId: "scope-copy-fixture", pluginName: "Scope Copy Fixture", source: "test", action: { id: "mutate", requiredScopes: [READ_SCOPE], handler: ({ client }) => { handlerScopes = client?.scopes; client?.scopes.push(APPROVALS_SCOPE); return { result: { ok: true } }; }, }, }, ]; registry.plugins = [createPluginRecord({ id: "scope-copy-fixture" })]; setActivePluginRegistry(registry); await expectDefined( pluginHostHookHandlers["plugins.sessionAction"], 'pluginHostHookHandlers["plugins.sessionAction"] test invariant', )({ req: { id: "scope-copy", type: "req", method: "plugins.sessionAction", params: { pluginId: "scope-copy-fixture", actionId: "mutate" }, }, params: { pluginId: "scope-copy-fixture", actionId: "mutate" }, client: { connId: "scope-copy-client", connect: { scopes: originalScopes }, } as GatewayClient, isWebchatConnect: () => false, respond: (ok, payload, error) => { response = { ok, payload, error }; }, context: { getRuntimeConfig: () => ({}) } as never, }); expect(response).toEqual({ ok: true, payload: { ok: true, result: { ok: true } }, error: undefined, }); expect(handlerScopes).toEqual([READ_SCOPE, APPROVALS_SCOPE]); expect(handlerScopes).not.toBe(originalScopes); expect(originalScopes).toEqual([READ_SCOPE]); }); it("does not dispatch session actions for plugins that are not loaded", async () => { const handler = vi.fn(() => ({ result: { stale: true } })); const registry = createEmptyPluginRegistry(); registry.sessionActions = [ { pluginId: "failed-action-plugin", pluginName: "Failed Action Plugin", source: "test", action: { id: "stale", requiredScopes: [READ_SCOPE], handler, }, }, ]; registry.plugins = [ createPluginRecord({ id: "failed-action-plugin", name: "Failed Action Plugin", status: "error", }), ]; setActivePluginRegistry(registry); const staleAction = await callPluginSessionActionThroughGatewayForTest({ body: { pluginId: "failed-action-plugin", actionId: "stale", }, scopes: [READ_SCOPE], }); const staleActionError = requireHookError(staleAction); expect(staleActionError.code).toBe("UNAVAILABLE"); expect(staleActionError.message).toBe( "unknown plugin session action: failed-action-plugin/stale", ); expect(handler).not.toHaveBeenCalled(); }); it("emits plugin-attributed agent events through the plugin API", () => { const observed: unknown[] = []; const unsubscribe = onAgentEvent((event) => observed.push(event)); const { config, registry } = createPluginRegistryFixture(); let bundledApi: OpenClawPluginApi | undefined; let workspaceApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "event-plugin", name: "Event Plugin", origin: "bundled", }), register(api) { bundledApi = api; }, }); registerTestPlugin({ registry, config, record: createPluginRecord({ id: "workspace-event-plugin", name: "Workspace Event Plugin", origin: "workspace", }), register(api) { workspaceApi = api; }, }); setActivePluginRegistry(registry.registry); try { expect( bundledApi?.agent?.events.emitAgentEvent({ runId: "run-emit", sessionKey: " agent:main:main ", stream: "approval", data: { state: "queued" }, }), ).toEqual({ emitted: true, stream: "approval" }); expect( bundledApi?.agent?.events.emitAgentEvent({ runId: "run-emit", stream: "lifecycle", data: { phase: "start" }, }), ).toEqual({ emitted: false, reason: "lifecycle start requires a finite startedAt timestamp", }); expect( bundledApi?.agent?.events.emitAgentEvent({ runId: "run-emit", stream: "lifecycle", data: { phase: "start", startedAt: 1_234 }, }), ).toEqual({ emitted: true, stream: "lifecycle" }); expect( workspaceApi?.emitAgentEvent({ runId: "run-emit", stream: "lifecycle", data: { phase: "end" }, }), ).toEqual({ emitted: false, reason: "stream lifecycle is reserved for bundled plugins" }); expect( workspaceApi?.emitAgentEvent({ runId: "run-emit", stream: "assistant", data: { text: "spoofed assistant output" }, }), ).toEqual({ emitted: false, reason: "stream assistant is reserved for bundled plugins" }); expect( workspaceApi?.emitAgentEvent({ runId: "run-emit", stream: "other-plugin.workflow", data: { state: "queued" }, }), ).toEqual({ emitted: false, reason: "stream other-plugin.workflow must be scoped to plugin workspace-event-plugin", }); expect( workspaceApi?.emitAgentEvent({ runId: "run-emit", stream: "workspace-event-plugin.workflow", data: { state: "queued" }, }), ).toEqual({ emitted: true, stream: "workspace-event-plugin.workflow" }); expect( bundledApi?.emitAgentEvent({ runId: "run-emit", stream: "approval", data: 1n as never, }), ).toEqual({ emitted: false, reason: "event data must be JSON-compatible" }); } finally { unsubscribe(); } expect(observed).toHaveLength(3); const bundledEvent = requireObservedEvent(observed, 0); expect(bundledEvent.runId).toBe("run-emit"); expect(bundledEvent.sessionKey).toBe("agent:main:main"); expect(bundledEvent.stream).toBe("approval"); expect(bundledEvent.data).toEqual({ state: "queued", pluginId: "event-plugin", pluginName: "Event Plugin", }); const lifecycleEvent = requireObservedEvent(observed, 1); expect(lifecycleEvent.stream).toBe("lifecycle"); expect(lifecycleEvent.data).toEqual({ phase: "start", startedAt: 1_234, pluginId: "event-plugin", pluginName: "Event Plugin", }); const workspaceEvent = requireObservedEvent(observed, 2); expect(workspaceEvent.runId).toBe("run-emit"); expect(workspaceEvent.sessionKey).toBeUndefined(); expect(workspaceEvent.stream).toBe("workspace-event-plugin.workflow"); expect(workspaceEvent.data).toEqual({ state: "queued", pluginId: "workspace-event-plugin", pluginName: "Workspace Event Plugin", }); }); it("blocks agent events from stale and non-activating plugin API closures", () => { const observed: unknown[] = []; const unsubscribe = onAgentEvent((event) => observed.push(event)); const { config, registry } = createPluginRegistryFixture(); let capturedApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "stale-event-plugin", name: "Stale Event Plugin", origin: "bundled", }), register(api) { capturedApi = api; }, }); setActivePluginRegistry(registry.registry); setActivePluginRegistry(createEmptyPluginRegistry()); try { expect( capturedApi?.emitAgentEvent({ runId: "stale-run", stream: "approval", data: { stale: true }, }), ).toEqual({ emitted: false, reason: "plugin is not loaded" }); const neverActiveRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as never, }); let neverActiveApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry: neverActiveRegistry, config, record: createPluginRecord({ id: "never-active-event-plugin", name: "Never Active Event Plugin", origin: "bundled", }), register(api) { neverActiveApi = api; }, }); expect( neverActiveApi?.emitAgentEvent({ runId: "never-active-run", stream: "approval", data: { inactive: true }, }), ).toEqual({ emitted: false, reason: "plugin is not loaded" }); const inactiveRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as never, activateGlobalSideEffects: false, }); let inactiveApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry: inactiveRegistry, config, record: createPluginRecord({ id: "inactive-event-plugin", name: "Inactive Event Plugin", origin: "bundled", }), register(api) { inactiveApi = api; }, }); expect( inactiveApi?.emitAgentEvent({ runId: "inactive-run", stream: "approval", data: { inactive: true }, }), ).toEqual({ emitted: false, reason: "global side effects disabled" }); } finally { unsubscribe(); } expect(observed).toEqual([]); }); it("keeps agent event delivery after a staged registry rollback", () => { const observed: unknown[] = []; const unsubscribe = onAgentEvent((event) => observed.push(event)); const { config, registry } = createPluginRegistryFixture(); let capturedApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "reactivated-event-plugin", name: "Reactivated Event Plugin", origin: "bundled", }), register(api) { capturedApi = api; }, }); setActivePluginRegistry(registry.registry); const snapshot = captureActivePluginRegistrySnapshot(); stageActivePluginRegistry(createEmptyPluginRegistry(), null, "default"); rollbackStagedPluginRegistry(snapshot); try { expect( capturedApi?.emitAgentEvent({ runId: "reactivated-run", stream: "approval", data: { active: true }, }), ).toEqual({ emitted: true, stream: "approval" }); } finally { unsubscribe(); } expect(observed).toHaveLength(1); const reactivatedEvent = requireObservedEvent(observed, 0); expect(reactivatedEvent.runId).toBe("reactivated-run"); expect(reactivatedEvent.sessionKey).toBeUndefined(); expect(reactivatedEvent.stream).toBe("approval"); expect(reactivatedEvent.data).toEqual({ active: true, pluginId: "reactivated-event-plugin", pluginName: "Reactivated Event Plugin", }); }); });