Skip to content

Commit 5817e47

Browse files
authored
fix(agents): clear poisoned claude cli sessions (#81247)
1 parent 7c2425a commit 5817e47

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,23 @@ Docs: https://docs.openclaw.ai
580580
- OC Path: restore YAML/YML/.lobster support through the bundled YAML document parser and add `$first` positional addressing alongside `$last`.
581581
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.
582582
- Codex harness: make the live test wrapper portable to Windows and defer locked temp cleanup so native Windows and WSL2 live runs complete.
583+
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
584+
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
585+
- Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.
586+
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
587+
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
588+
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
589+
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
590+
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
591+
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
592+
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
593+
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
594+
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
595+
- Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)
596+
- Claude CLI: clear a reused stored session id after aborts or non-expired failover errors so the next turn does not resume a poisoned CLI session. Fixes #78785.
597+
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
598+
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime and MCP projection SDK helpers, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
599+
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
583600
- Link understanding: fetch page content through the SSRF guard before running configured CLI summarizers, preventing curl/wget-style link fetchers from reaching private redirect or DNS-rebound targets.
584601
- fix: harden safe-bin argument validation [AI]. (#80999) Thanks @pgondhi987.
585602
- Codex/status: align `/codex status` rate-limit wording with `/status` by showing remaining quota and compact reset durations instead of used quota and raw ISO timestamps. Thanks @MatthewSchleder.

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,39 @@ describe("CLI attempt execution", () => {
187187
});
188188
}
189189

190+
async function writeClaudeCliAssistantTranscript(cliSessionId: string) {
191+
const homeDir = path.join(tmpDir, `home-${cliSessionId}`);
192+
const projectsDir = path.join(homeDir, ".claude", "projects", "demo-workspace");
193+
process.env.HOME = homeDir;
194+
await fs.mkdir(projectsDir, { recursive: true });
195+
await fs.writeFile(
196+
path.join(projectsDir, `${cliSessionId}.jsonl`),
197+
`${JSON.stringify({
198+
type: "assistant",
199+
message: { role: "assistant", content: [{ type: "text", text: "old reply" }] },
200+
})}\n`,
201+
"utf-8",
202+
);
203+
}
204+
205+
function makeClaudeCliSessionEntry(
206+
openclawSessionId: string,
207+
cliSessionId: string,
208+
): SessionEntry {
209+
return {
210+
sessionId: openclawSessionId,
211+
updatedAt: Date.now(),
212+
cliSessionBindings: {
213+
"claude-cli": {
214+
sessionId: cliSessionId,
215+
authProfileId: "anthropic:claude-cli",
216+
},
217+
},
218+
cliSessionIds: { "claude-cli": cliSessionId },
219+
claudeCliSessionId: cliSessionId,
220+
};
221+
}
222+
190223
it("clears stale Claude CLI session IDs before retrying after session expiration", async () => {
191224
const sessionKey = "agent:main:subagent:cli-expired";
192225
const homeDir = path.join(tmpDir, "home");
@@ -265,6 +298,73 @@ describe("CLI attempt execution", () => {
265298
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
266299
});
267300

301+
it("clears reused Claude CLI session IDs after AbortError without retrying", async () => {
302+
const sessionKey = "agent:main:direct:cli-abort";
303+
const cliSessionId = "abort-poisoned-session";
304+
await writeClaudeCliAssistantTranscript(cliSessionId);
305+
const sessionEntry = makeClaudeCliSessionEntry("session-cli-abort", cliSessionId);
306+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
307+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
308+
const abortError = Object.assign(new Error("aborted"), { name: "AbortError" });
309+
runCliAgentMock.mockRejectedValueOnce(abortError);
310+
311+
await expect(
312+
runClaudeCliAttempt({
313+
sessionKey,
314+
sessionEntry,
315+
sessionStore,
316+
body: "resume after abort",
317+
runId: "run-cli-abort",
318+
}),
319+
).rejects.toMatchObject({ name: "AbortError" });
320+
321+
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
322+
expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId);
323+
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
324+
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
325+
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
326+
327+
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
328+
string,
329+
SessionEntry
330+
>;
331+
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
332+
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
333+
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
334+
});
335+
336+
it("clears reused Claude CLI session IDs after non-expired failover without retrying", async () => {
337+
const sessionKey = "agent:main:direct:cli-timeout";
338+
const cliSessionId = "timeout-poisoned-session";
339+
await writeClaudeCliAssistantTranscript(cliSessionId);
340+
const sessionEntry = makeClaudeCliSessionEntry("session-cli-timeout", cliSessionId);
341+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
342+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
343+
runCliAgentMock.mockRejectedValueOnce(
344+
new FailoverError("CLI produced no output for 60s", {
345+
reason: "timeout",
346+
provider: "claude-cli",
347+
model: "opus",
348+
}),
349+
);
350+
351+
await expect(
352+
runClaudeCliAttempt({
353+
sessionKey,
354+
sessionEntry,
355+
sessionStore,
356+
body: "resume after timeout",
357+
runId: "run-cli-timeout",
358+
}),
359+
).rejects.toMatchObject({ name: "FailoverError", reason: "timeout" });
360+
361+
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
362+
expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId);
363+
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
364+
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
365+
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
366+
});
367+
268368
it("does not pass --resume when the stored Claude CLI transcript is missing", async () => {
269369
const sessionKey = "agent:main:direct:claude-missing-transcript";
270370
const homeDir = path.join(tmpDir, "home");

src/agents/command/attempt-execution.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import type { SessionEntry } from "../../config/sessions/types.js";
1111
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1212
import { emitAgentEvent } from "../../infra/agent-events.js";
13+
import { readErrorName } from "../../infra/errors.js";
1314
import { createSubsystemLogger } from "../../logging/subsystem.js";
1415
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
1516
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
@@ -50,6 +51,20 @@ export {
5051

5152
const log = createSubsystemLogger("agents/agent-command");
5253

54+
function shouldClearReusedCliSessionAfterError(err: unknown): boolean {
55+
if (readErrorName(err) === "AbortError") {
56+
return true;
57+
}
58+
return err instanceof FailoverError && err.reason !== "session_expired";
59+
}
60+
61+
function resolveClearedCliSessionReason(err: unknown): string {
62+
if (err instanceof FailoverError) {
63+
return err.reason;
64+
}
65+
return readErrorName(err) || "error";
66+
}
67+
5368
function normalizeTranscriptMirrorText(value: string): string {
5469
return value.trim().replace(/\s+/gu, " ");
5570
}
@@ -587,6 +602,26 @@ export function runAgentAttempt(params: {
587602
return result;
588603
});
589604
}
605+
if (
606+
isClaudeCliProvider(cliExecutionProvider) &&
607+
shouldClearReusedCliSessionAfterError(err) &&
608+
activeCliSessionBinding?.sessionId &&
609+
params.sessionKey &&
610+
params.sessionStore &&
611+
params.storePath
612+
) {
613+
log.warn(
614+
`CLI session cleared after failed reused turn: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${params.sessionKey} reason=${sanitizeForLog(resolveClearedCliSessionReason(err))}`,
615+
);
616+
617+
params.sessionEntry =
618+
(await clearCliSessionInStore({
619+
provider: cliExecutionProvider,
620+
sessionKey: params.sessionKey,
621+
sessionStore: params.sessionStore,
622+
storePath: params.storePath,
623+
})) ?? params.sessionEntry;
624+
}
590625
throw err;
591626
}
592627
});

0 commit comments

Comments
 (0)