mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(plugins): withhold Incognito content from observation hooks (#156417)
This commit is contained in:
parent
c357d827ba
commit
bab3136d3c
6 changed files with 163 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
34
src/plugins/hook-agent-observations.ts
Normal file
34
src/plugins/hook-agent-observations.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue