From bab3136d3c6074faf0c91c409619969749885b7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 23 Sep 2026 19:41:31 +0800 Subject: [PATCH] fix(plugins): withhold Incognito content from observation hooks (#156417) --- docs/plugins/hooks.md | 6 ++ extensions/active-memory/index.test.ts | 2 +- .../workboard/src/lifecycle-sync.test.ts | 39 ++++++--- src/plugins/hook-agent-observations.ts | 34 ++++++++ src/plugins/hooks.ts | 21 ++--- src/plugins/wired-hooks-llm.test.ts | 86 +++++++++++++++++++ 6 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 src/plugins/hook-agent-observations.ts diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 27dd6bee8b55..f7f97a2c665a 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -135,6 +135,12 @@ reload mode, hook policy changes hot-reload the existing plugin runtime. - These are specific registration gates, not a sandbox or a universal filter for every hook that can see message data. Install only plugins you trust. +Incognito sessions do not dispatch `llm_input` or `llm_output` observations. +Their `agent_end` hooks still receive run identity, success, and duration for +cleanup and settlement, but receive empty `messages` and no `error` text. +Policy, provider, approval, and explicitly invoked tool hooks remain active. +This boundary does not sandbox plugins or disable native harness telemetry. + A typed handler receives `(event, ctx)`. The event describes the operation; the second argument carries hook-specific context. Fields such as `ctx.agentId`, `ctx.sessionKey`, and `ctx.runId` are optional on many hooks and diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 9dd571d63450..2c9c61a87fc0 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -1314,7 +1314,7 @@ describe("active-memory plugin", () => { }); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); - await requireHook("agent_end")({ runId: context.runId, messages: [], success: true }, context); + await requireHook("agent_end")({ runId: context.runId, messages: [], success: false }, context); await runPromptBuild({ prompt: "what wings should i order?" }, context); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); }); diff --git a/extensions/workboard/src/lifecycle-sync.test.ts b/extensions/workboard/src/lifecycle-sync.test.ts index df9c580dc9e7..8f1bf6e413b7 100644 --- a/extensions/workboard/src/lifecycle-sync.test.ts +++ b/extensions/workboard/src/lifecycle-sync.test.ts @@ -1,4 +1,8 @@ import type { WorkboardExecution } from "@openclaw/workboard-contract"; +import { + createHookRunner, + createMockPluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawPluginService } from "../api.js"; import { createWorkboardAutomationNudgeService } from "./automation-nudge.js"; @@ -522,26 +526,39 @@ describe("Workboard gateway lifecycle sync", () => { expect((await store.get(card.id))?.status).toBe("running"); }); - it("uses agent_end context to reconcile non-subagent linked sessions", async () => { + it.each([ + ["agent:main:dashboard:agent-end", false, "blocked"], + ["agent:main:dashboard:incognito-agent-end", false, "blocked"], + ["agent:main:dashboard:incognito-agent-end", true, "review"], + ] as const)("settles %s after agent_end success=%s", async (sessionKey, success, status) => { const store = createWorkboardSqliteTestStore(); - const sessionKey = "agent:main:dashboard:agent-end"; const card = await createLinkedCard(store, { sessionKey, runId: "run-agent", execution: execution(sessionKey, "run-agent"), }); - await syncWorkboardAgentEnded({ - store, - event: { runId: "run-agent", success: false }, - context: { sessionKey }, - now: card.updatedAt + 1, - }); - + const handler = vi.fn(async (...args: unknown[]) => + syncWorkboardAgentEnded({ + store, + event: args[0] as Parameters[0]["event"], + context: args[1] as Parameters[0]["context"], + now: card.updatedAt + 1, + }), + ); + const runner = createHookRunner(createMockPluginRegistry([{ hookName: "agent_end", handler }])); + await runner.runAgentEnd( + { messages: [{ role: "user", content: "PRIVATE_INPUT" }], error: "PRIVATE_ERROR", success }, + { runId: "run-agent", sessionKey }, + ); await expect(store.get(card.id)).resolves.toMatchObject({ - status: "blocked", - execution: { status: "blocked" }, + status, + execution: { status }, }); + expect(handler).toHaveBeenCalledOnce(); + if (sessionKey.includes("incognito-")) { + expect(JSON.stringify(handler.mock.calls)).not.toContain("PRIVATE_"); + } }); it("marks an inactive running session stale and clears it after recovery", async () => { diff --git a/src/plugins/hook-agent-observations.ts b/src/plugins/hook-agent-observations.ts new file mode 100644 index 000000000000..57eb9b10e2c7 --- /dev/null +++ b/src/plugins/hook-agent-observations.ts @@ -0,0 +1,34 @@ +import { isIncognitoSessionKey } from "../shared/incognito-session-key.js"; +import type { PluginHookAgentContext, PluginHookAgentEndEvent } from "./hook-types.js"; + +export function withAgentRunId( + event: TEvent, + ctx: PluginHookAgentContext, +): TEvent { + if (event.runId || !ctx.runId) { + return event; + } + return { ...event, runId: ctx.runId }; +} + +/** Terminal hooks release per-run state and settle work, even for private runs. */ +export function projectAgentEndEvent( + event: PluginHookAgentEndEvent, + ctx: PluginHookAgentContext, +): PluginHookAgentEndEvent { + const observedEvent = isIncognitoSessionKey(ctx.sessionKey) + ? { runId: event.runId, messages: [], success: event.success, durationMs: event.durationMs } + : event; + return withAgentRunId(observedEvent, ctx); +} + +/** Exclude optional prompt/response observers without changing policy hooks. */ +export function withoutIncognitoLlmContent( + run: (event: TEvent, ctx: PluginHookAgentContext) => Promise, +) { + return async (event: TEvent, ctx: PluginHookAgentContext): Promise => { + if (!isIncognitoSessionKey(ctx.sessionKey)) { + await run(event, ctx); + } + }; +} diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 72291b265a98..73c5720c63b9 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -17,6 +17,11 @@ import { formatHookErrorForLog } from "../hooks/fire-and-forget.js"; import { formatErrorMessage } from "../infra/errors.js"; import { projectModelContextMessages } from "../shared/model-context-message.js"; import { concatOptionalTextSegments } from "../shared/text/join-segments.js"; +import { + projectAgentEndEvent, + withAgentRunId, + withoutIncognitoLlmContent, +} from "./hook-agent-observations.js"; import { readClaimingHookAdmission, type ClaimingHookAdmission } from "./hook-claim-admission.js"; import { type GateHookResult, @@ -971,16 +976,6 @@ export function createHookRunner( // Agent Hooks // ========================================================================= - function withAgentRunId( - event: TEvent, - ctx: PluginHookAgentContext, - ): TEvent { - if (event.runId || !ctx.runId) { - return event; - } - return { ...event, runId: ctx.runId }; - } - /** * Run before_prompt_build hook. * Allows plugins to inject context and system prompt before prompt submission. @@ -1083,7 +1078,7 @@ export function createHookRunner( ctx: PluginHookAgentContext, optionsLocal?: VoidHookRunOptions, ): Promise { - return runVoidHook("agent_end", withAgentRunId(event, ctx), ctx, optionsLocal); + return runVoidHook("agent_end", projectAgentEndEvent(event, ctx), ctx, optionsLocal); } /** @@ -1445,8 +1440,8 @@ export function createHookRunner( runBeforeAgentReply: bindClaimingHook("before_agent_reply"), runModelCallStarted: bindVoidHook("model_call_started"), runModelCallEnded: bindVoidHook("model_call_ended"), - runLlmInput: bindVoidHook("llm_input"), - runLlmOutput: bindVoidHook("llm_output"), + runLlmInput: withoutIncognitoLlmContent(bindVoidHook("llm_input")), + runLlmOutput: withoutIncognitoLlmContent(bindVoidHook("llm_output")), runBeforeAgentFinalize, runAgentEnd, /** diff --git a/src/plugins/wired-hooks-llm.test.ts b/src/plugins/wired-hooks-llm.test.ts index 4549691ae011..e21c3383aee5 100644 --- a/src/plugins/wired-hooks-llm.test.ts +++ b/src/plugins/wired-hooks-llm.test.ts @@ -136,3 +136,89 @@ describe("llm hook runner methods", () => { expect(runner.hasHooks("llm_output")).toBe(false); }); }); + +describe("Incognito observation hooks", () => { + it.each(["dashboard", "subagent", "internal-session-effects"])( + "withholds %s conversation content while preserving terminal lifecycle hooks", + async (kind) => { + const llmInput = vi.fn(); + const llmOutput = vi.fn(); + const agentEnd = vi.fn(); + const { runner } = createHookRunnerWithRegistry([ + { hookName: "llm_input", handler: llmInput }, + { hookName: "llm_output", handler: llmOutput }, + { hookName: "agent_end", handler: agentEnd }, + ]); + const context = { + ...hookCtx, + sessionKey: `agent:main:${kind}:incognito-test`, + runId: "run-1", + }; + const identity = { + runId: "run-1", + sessionId: "session-1", + provider: "openai", + model: "gpt-5", + }; + await runner.runLlmInput( + { ...identity, prompt: "PRIVATE_INPUT", historyMessages: [], imagesCount: 0 }, + context, + ); + await runner.runLlmOutput({ ...identity, assistantTexts: ["PRIVATE_OUTPUT"] }, context); + const readMessages = vi.fn(() => [{ role: "user", content: "PRIVATE_INPUT" }]); + const readError = vi.fn(() => "PRIVATE_ERROR"); + await runner.runAgentEnd( + { + get messages() { + return readMessages(); + }, + get error() { + return readError(); + }, + success: false, + durationMs: 42, + }, + context, + ); + expect(llmInput).not.toHaveBeenCalled(); + expect(llmOutput).not.toHaveBeenCalled(); + expect(agentEnd).toHaveBeenCalledExactlyOnceWith( + { runId: "run-1", messages: [], success: false, durationMs: 42 }, + context, + ); + expect(readMessages).not.toHaveBeenCalled(); + expect(readError).not.toHaveBeenCalled(); + }, + ); + + it("preserves Incognito policy order and fail-closed behavior", async () => { + const calls: string[] = []; + const { runner } = createHookRunnerWithRegistry( + [ + { + hookName: "before_tool_call", + priority: 10, + handler: () => { + calls.push("first"); + }, + }, + { + hookName: "before_tool_call", + priority: 5, + handler: () => { + calls.push("second"); + throw new Error("policy unavailable"); + }, + }, + ], + { failurePolicyByHook: { before_tool_call: "fail-closed" } }, + ); + await expect( + runner.runBeforeToolCall( + { toolName: "exec", params: { command: "echo private" } }, + { sessionKey: "agent:main:dashboard:incognito-test", toolName: "exec" }, + ), + ).rejects.toThrow("policy unavailable"); + expect(calls).toEqual(["first", "second"]); + }); +});