fix(plugins): withhold Incognito content from observation hooks (#156417)

This commit is contained in:
Vincent Koc 2026-09-23 19:41:31 +08:00 • committed by GitHub
parent c357d827ba
commit bab3136d3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 163 additions and 25 deletions

View file

@ -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

View file

@ -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);
});

View file

@ -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<typeof syncWorkboardAgentEnded>[0]["event"],
context: args[1] as Parameters<typeof syncWorkboardAgentEnded>[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 () => {

View file

@ -0,0 +1,34 @@
import { isIncognitoSessionKey } from "../shared/incognito-session-key.js";
import type { PluginHookAgentContext, PluginHookAgentEndEvent } from "./hook-types.js";
export function withAgentRunId<TEvent extends { runId?: string }>(
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<TEvent>(
run: (event: TEvent, ctx: PluginHookAgentContext) => Promise<void>,
) {
return async (event: TEvent, ctx: PluginHookAgentContext): Promise<void> => {
if (!isIncognitoSessionKey(ctx.sessionKey)) {
await run(event, ctx);
}
};
}

View file

@ -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<TEvent extends { runId?: string }>(
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<void> {
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,
/**

View file

@ -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"]);
});
});