Skip to content

Commit 762e7ad

Browse files
fix(agents): gate CLI user persistence behind hooks
1 parent 22dbafb commit 762e7ad

2 files changed

Lines changed: 160 additions & 41 deletions

File tree

src/agents/command/attempt-execution.cli.test.ts

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,37 @@ function firstEmbeddedAgentArg(callIndex = 0) {
167167
return requireMockArg(runEmbeddedAgentMock, callIndex, "embedded OpenClaw agent argument");
168168
}
169169

170+
async function persistApprovedCliUserTurnFromMock(args: Record<string, unknown>) {
171+
const recorder = requireRecord(args.userTurnTranscriptRecorder, "user turn recorder") as {
172+
persistApproved: (params: {
173+
target: {
174+
transcriptPath: string;
175+
sessionId: string;
176+
agentId?: string;
177+
sessionKey?: string;
178+
cwd: string;
179+
config?: OpenClawConfig;
180+
};
181+
}) => Promise<{ message?: unknown } | undefined>;
182+
};
183+
const persisted = await recorder.persistApproved({
184+
target: {
185+
transcriptPath: args.sessionFile as string,
186+
sessionId: args.sessionId as string,
187+
agentId: args.agentId as string | undefined,
188+
sessionKey: args.sessionKey as string | undefined,
189+
cwd: (args.cwd as string | undefined) ?? (args.workspaceDir as string),
190+
config: args.config as OpenClawConfig | undefined,
191+
},
192+
});
193+
if (persisted?.message) {
194+
const notify = args.onUserMessagePersisted;
195+
if (typeof notify === "function") {
196+
await notify(persisted.message);
197+
}
198+
}
199+
}
200+
170201
describe("CLI attempt execution", () => {
171202
let tmpDir: string;
172203
let storePath: string;
@@ -807,7 +838,10 @@ describe("CLI attempt execution", () => {
807838
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
808839
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
809840
const onUserMessagePersisted = vi.fn();
810-
runCliAgentMock.mockRejectedValueOnce(new Error("cli crashed before reply"));
841+
runCliAgentMock.mockImplementationOnce(async (args: unknown) => {
842+
await persistApprovedCliUserTurnFromMock(requireRecord(args, "run CLI agent argument"));
843+
throw new Error("cli crashed before reply");
844+
});
811845

812846
await expect(
813847
runAgentAttempt({
@@ -846,7 +880,7 @@ describe("CLI attempt execution", () => {
846880

847881
expect(onUserMessagePersisted).toHaveBeenCalledTimes(1);
848882
expect(firstRunCliAgentArg().cwd).toBe(taskCwd);
849-
const sessionFile = sessionStore[sessionKey]?.sessionFile ?? "";
883+
const sessionFile = path.join(tmpDir, "session.jsonl");
850884
const entries = await readSessionFileEntries(sessionFile);
851885
expectRecordFields(requireRecord(entries[0], "session entry"), {
852886
type: "session",
@@ -860,6 +894,97 @@ describe("CLI attempt execution", () => {
860894
});
861895
});
862896

897+
it("does not persist raw CLI prompts when before_agent_run blocks", async () => {
898+
const sessionKey = "agent:main:subagent:cli-before-run-blocked";
899+
const sessionFile = path.join(tmpDir, "blocked-session.jsonl");
900+
const sessionEntry: SessionEntry = {
901+
sessionId: "session-cli-before-run-blocked",
902+
updatedAt: Date.now(),
903+
};
904+
const onUserMessagePersisted = vi.fn();
905+
runCliAgentMock.mockImplementationOnce(async (args: unknown) => {
906+
const cliArgs = requireRecord(args, "run CLI agent argument");
907+
await appendSessionTranscriptMessage({
908+
transcriptPath: cliArgs.sessionFile as string,
909+
sessionId: cliArgs.sessionId as string,
910+
cwd: (cliArgs.cwd as string | undefined) ?? (cliArgs.workspaceDir as string),
911+
config: {},
912+
message: {
913+
role: "user",
914+
content: [
915+
{
916+
type: "text",
917+
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
918+
},
919+
],
920+
timestamp: Date.now(),
921+
__openclaw: {
922+
beforeAgentRunBlocked: {
923+
blockedBy: "policy-plugin",
924+
blockedAt: Date.now(),
925+
},
926+
},
927+
},
928+
});
929+
return {
930+
payloads: [
931+
{
932+
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
933+
isError: true,
934+
},
935+
],
936+
meta: {
937+
durationMs: 1,
938+
finalAssistantVisibleText:
939+
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
940+
livenessState: "blocked",
941+
executionTrace: {
942+
winnerProvider: "claude-cli",
943+
winnerModel: "opus",
944+
runner: "cli",
945+
},
946+
},
947+
} satisfies EmbeddedAgentRunResult;
948+
});
949+
950+
const result = await runAgentAttempt({
951+
providerOverride: "claude-cli",
952+
originalProvider: "claude-cli",
953+
modelOverride: "opus",
954+
cfg: {} as OpenClawConfig,
955+
sessionEntry,
956+
sessionId: sessionEntry.sessionId,
957+
sessionKey,
958+
sessionAgentId: "main",
959+
sessionFile,
960+
workspaceDir: tmpDir,
961+
body: "runtime wrapper\nsecret prompt",
962+
transcriptBody: "secret prompt",
963+
isFallbackRetry: false,
964+
resolvedThinkLevel: "medium",
965+
timeoutMs: 1_000,
966+
runId: "run-cli-before-run-blocked",
967+
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
968+
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
969+
spawnedBy: undefined,
970+
messageChannel: undefined,
971+
skillsSnapshot: undefined,
972+
resolvedVerboseLevel: undefined,
973+
agentDir: tmpDir,
974+
onAgentEvent: vi.fn(),
975+
authProfileProvider: "claude-cli",
976+
sessionHasHistory: false,
977+
onUserMessagePersisted,
978+
});
979+
980+
expect(result.meta.livenessState).toBe("blocked");
981+
expect(onUserMessagePersisted).not.toHaveBeenCalled();
982+
const rawTranscript = await fs.readFile(sessionFile, "utf-8");
983+
expect(rawTranscript).toContain("beforeAgentRunBlocked");
984+
expect(rawTranscript).toContain("The agent cannot read this message");
985+
expect(rawTranscript).not.toContain("secret prompt");
986+
});
987+
863988
it("persists internal CLI user turns to the active attempt transcript", async () => {
864989
const sessionKey = "agent:main:subagent:cli-internal-user-before-failure";
865990
const visibleSessionFile = path.join(tmpDir, "visible-session.jsonl");
@@ -880,7 +1005,10 @@ describe("CLI attempt execution", () => {
8801005
timestamp: Date.now(),
8811006
},
8821007
});
883-
runCliAgentMock.mockRejectedValueOnce(new Error("cli crashed before internal reply"));
1008+
runCliAgentMock.mockImplementationOnce(async (args: unknown) => {
1009+
await persistApprovedCliUserTurnFromMock(requireRecord(args, "run CLI agent argument"));
1010+
throw new Error("cli crashed before internal reply");
1011+
});
8841012

8851013
await expect(
8861014
runAgentAttempt({

src/agents/command/attempt-execution.ts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { annotateInterSessionPromptText } from "../../sessions/input-provenance.
2626
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
2727
import {
2828
appendUserTurnTranscriptMessage,
29+
createUserTurnTranscriptRecorder,
2930
type PersistedUserTurnMessage,
3031
} from "../../sessions/user-turn-transcript.js";
3132
import { buildWorkspaceSkillSnapshot } from "../../skills/loading/workspace.js";
@@ -615,6 +616,31 @@ export function runAgentAttempt(params: {
615616
params.opts.inputProvenance?.kind === "inter_session"
616617
? effectivePrompt
617618
: injectTimestamp(effectivePrompt, timestampOptsFromConfig(params.cfg));
619+
const cliTranscriptPrompt = params.transcriptBody ?? params.body;
620+
const cliUserTurnTranscriptRecorder =
621+
params.suppressPromptPersistenceOnRetry === true || !cliTranscriptPrompt
622+
? undefined
623+
: createUserTurnTranscriptRecorder({
624+
input: {
625+
text: cliTranscriptPrompt,
626+
timestamp: Date.now(),
627+
},
628+
target: {
629+
transcriptPath: params.sessionFile,
630+
sessionId: params.sessionId,
631+
agentId: params.sessionAgentId,
632+
sessionKey: params.sessionKey ?? params.sessionId,
633+
cwd: cliProcessCwd,
634+
config: params.cfg,
635+
},
636+
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
637+
errorContext: `CLI user turn transcript for ${params.sessionKey ?? params.sessionId}`,
638+
onPersistenceError: (error) => {
639+
log.warn(
640+
`CLI user turn transcript persistence failed for ${params.sessionKey ?? params.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
641+
);
642+
},
643+
});
618644
const mutableCliSessionStore =
619645
params.sessionKey && params.sessionStore && params.storePath
620646
? {
@@ -691,6 +717,9 @@ export function runAgentAttempt(params: {
691717
agentAccountId: params.runContext.accountId,
692718
senderIsOwner: params.opts.senderIsOwner,
693719
toolsAllow: params.opts.toolsAllow,
720+
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
721+
userTurnTranscriptRecorder: cliUserTurnTranscriptRecorder,
722+
onUserMessagePersisted: params.onUserMessagePersisted,
694723
cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd,
695724
cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd,
696725
...(mutableCliSessionStore
@@ -714,45 +743,7 @@ export function runAgentAttempt(params: {
714743
}
715744
: {}),
716745
});
717-
const persistCurrentCliUserTurn = async () => {
718-
if (params.suppressPromptPersistenceOnRetry === true) {
719-
return;
720-
}
721-
const transcriptSessionEntry: SessionEntry = {
722-
...(params.sessionEntry ?? {
723-
sessionId: params.sessionId,
724-
updatedAt: Date.now(),
725-
sessionStartedAt: Date.now(),
726-
}),
727-
sessionId: params.sessionId,
728-
sessionFile: params.sessionFile,
729-
};
730-
try {
731-
params.sessionEntry =
732-
(await persistUserTurnTranscript({
733-
body: params.body,
734-
transcriptBody: params.transcriptBody,
735-
sessionId: params.sessionId,
736-
sessionKey: params.sessionKey ?? params.sessionId,
737-
sessionEntry: transcriptSessionEntry,
738-
sessionFileOverride:
739-
params.sessionStore && params.storePath ? undefined : params.sessionFile,
740-
sessionStore: params.sessionStore,
741-
storePath: params.storePath,
742-
sessionAgentId: params.sessionAgentId,
743-
threadId: params.opts.threadId,
744-
sessionCwd: cliProcessCwd,
745-
config: params.cfg,
746-
onUserMessagePersisted: params.onUserMessagePersisted,
747-
})) ?? params.sessionEntry;
748-
} catch (error) {
749-
log.warn(
750-
`CLI user turn transcript persistence failed for ${params.sessionKey ?? params.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
751-
);
752-
}
753-
};
754746
return resolveReusableCliSessionBinding().then(async (activeCliSessionBinding) => {
755-
await persistCurrentCliUserTurn();
756747
try {
757748
return await runCliWithSession(activeCliSessionBinding?.sessionId, activeCliSessionBinding);
758749
} catch (err) {

0 commit comments

Comments
 (0)