Files
paperclip/packages/adapters/codex-local/src/server/parse.test.ts
T
Harshit Khemani c297ba2a80 fix(codex-local): replace stale auth.json copy with symlink on prepare (#5028) (#5240)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - codex_local runs Codex CLI under a per-company "managed home" so
multiple companies don't trample on each other's session state
> - For `auth.json` specifically, the managed home keeps a SYMLINK to
the user's real `~/.codex/auth.json` rather than a copy — Codex refresh
tokens rotate and are single-use, so any copy goes stale the moment the
source rotates and every subsequent run dies with `401
refresh_token_reused`
> - Older Paperclip versions copied `auth.json` instead. After
upgrading, `ensureSymlink()` saw a regular file at the target, hit `if
(!existing.isSymbolicLink()) return;`, and silently kept the stale copy
> - This pull request makes the upgrade path self-healing inside
`ensureSymlink()` itself: when the target is a regular file, unlink it
and create the symlink, since the target lives under the
Paperclip-managed home and is safe to delete. Directories are skipped to
avoid `EISDIR` on Unix (and inconsistent behavior on Windows)
> - The benefit is operators who upgraded from a copy-based version stop
getting refresh-token-reused failures without having to manually purge
`companies/<id>/codex-home/auth.json`, and the healing is
defense-in-depth even outside the `prepareManagedCodexHome` cleanup path

## What Changed

- `packages/adapters/codex-local/src/server/codex-home.ts` —
`ensureSymlink()` previously bailed out of the
`!existing.isSymbolicLink()` branch, leaving any pre-existing regular
file untouched. Now unlinks and recreates the symlink in that branch via
the existing `createExpectedSymlink()` helper (preserves the EEXIST
race-tolerance behavior added in #5119). A guard skips directories so
the call never throws `EISDIR` and aborts `prepareManagedCodexHome`.
Inline comment explains the safety: target is always under the
company-scoped managed home
(`<paperclipHome>/instances/<id>/companies/<companyId>/codex-home/`),
never the user's real `~/.codex`.
- `packages/adapters/codex-local/src/server/codex-home.test.ts` — adds a
regression test for #5028: pre-seed a stale copy at the target, run
`prepareManagedCodexHome`, assert the target is now a symlink and reads
through to the fresh source. The existing concurrent-symlink test is
preserved.

## Verification

```
pnpm --filter @paperclipai/adapter-codex-local exec vitest run
# Test Files  8 passed (8)
# Tests       26 passed (26)
pnpm --filter @paperclipai/adapter-codex-local exec tsc --noEmit
# clean
```

Manual repro flow that the regression test mirrors:
1. Create a stale copy: `echo '{"token":"old"}' >
<managedHome>/auth.json`.
2. Rotate source: `echo '{"token":"new"}' > ~/.codex/auth.json`.
3. Trigger any codex_local run — `prepareManagedCodexHome` is called
from the execute path, the managed file is now a symlink to the source,
and the CLI sees the fresh token.

## Risks

- **Low risk.** The new branch only fires when the target file is a
regular file (the upgrade path) — a pure copy that Codex couldn't have
written, since Codex never writes into the managed home. Operators in
steady-state on the symlink-based version are unaffected.
- The `fs.unlink` only runs against the per-company managed-home path,
never the user's real `~/.codex`. Inline comment makes this guarantee
explicit.
- A directory at the auth.json path is left in place (no silent `EISDIR`
crash) — this requires operator inspection rather than autonomous
deletion.
- The healing uses `createExpectedSymlink()` so it remains tolerant of
EEXIST races with concurrent prepare calls (the concurrent-symlink test
still passes).
- No DB / migration / schema impact.

## Model Used

- Anthropic Claude Opus 4.7 (claude-opus-4-7), via Claude Code CLI with
extended tool use (Read / Edit / Bash / Grep). No extended-thinking
budget consumed beyond default.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, adapter-only
- [x] I have updated relevant documentation to reflect my changes —
inline comment explains the why and the safety of the unlink
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
- [x] I searched the GitHub PR list for similar PRs and confirmed this
is not a duplicate

Fixes #5028.

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-10 06:50:20 -07:00

142 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
extractCodexRetryNotBefore,
isCodexTransientUpstreamError,
isCodexUnknownSessionError,
parseCodexJsonl,
} from "./parse.js";
describe("parseCodexJsonl", () => {
it("captures session id, assistant summary, usage, and error message", () => {
const stdout = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Recovered response" },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
JSON.stringify({ type: "turn.failed", error: { message: "resume failed" } }),
].join("\n");
expect(parseCodexJsonl(stdout)).toEqual({
sessionId: "thread_123",
summary: "Recovered response",
usage: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 4,
},
errorMessage: "resume failed",
});
});
it("uses the last agent message as the summary when commentary updates precede the final answer", () => {
const stdout = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "reasoning", text: "Checking the heartbeat procedure" },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Im checking out the issue and reading the docs now." },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Fixed the issue and verified the targeted tests pass." },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
].join("\n");
expect(parseCodexJsonl(stdout)).toEqual({
sessionId: "thread_123",
summary: "Fixed the issue and verified the targeted tests pass.",
usage: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 4,
},
errorMessage: null,
});
});
});
describe("isCodexUnknownSessionError", () => {
it("detects the current missing-rollout thread error", () => {
expect(
isCodexUnknownSessionError(
"",
"Error: thread/resume: thread/resume failed: no rollout found for thread id d448e715-7607-4bcc-91fc-7a3c0c5a9632",
),
).toBe(true);
});
it("still detects existing stale-session wordings", () => {
expect(isCodexUnknownSessionError("unknown thread id", "")).toBe(true);
expect(isCodexUnknownSessionError("", "state db missing rollout path for thread abc")).toBe(true);
expect(isCodexUnknownSessionError("", "state db returned stale rollout path for thread abc")).toBe(true);
});
it("does not classify unrelated Codex failures as stale sessions", () => {
expect(isCodexUnknownSessionError("", "model overloaded")).toBe(false);
});
});
describe("isCodexTransientUpstreamError", () => {
it("classifies the remote-compaction high-demand failure as transient upstream", () => {
expect(
isCodexTransientUpstreamError({
errorMessage:
"Error running remote compact task: We're currently experiencing high demand, which may cause temporary errors.",
}),
).toBe(true);
expect(
isCodexTransientUpstreamError({
stderr: "We're currently experiencing high demand, which may cause temporary errors.",
}),
).toBe(true);
});
it("classifies usage-limit windows as transient and extracts the retry time", () => {
const errorMessage = "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 11:31 PM.";
const now = new Date(2026, 3, 22, 22, 29, 2);
expect(isCodexTransientUpstreamError({ errorMessage })).toBe(true);
expect(extractCodexRetryNotBefore({ errorMessage }, now)?.getTime()).toBe(
new Date(2026, 3, 22, 23, 31, 0, 0).getTime(),
);
});
it("parses explicit timezone hints on usage-limit retry windows", () => {
const errorMessage = "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 11:31 PM (America/Chicago).";
const now = new Date("2026-04-23T03:29:02.000Z");
expect(extractCodexRetryNotBefore({ errorMessage }, now)?.toISOString()).toBe(
"2026-04-23T04:31:00.000Z",
);
});
it("does not classify deterministic compaction errors as transient", () => {
expect(
isCodexTransientUpstreamError({
errorMessage: [
"Error running remote compact task: {",
' "error": {',
' "message": "Unknown parameter: \'prompt_cache_retention\'.",',
' "type": "invalid_request_error",',
' "param": "prompt_cache_retention",',
' "code": "unknown_parameter"',
" }",
"}",
].join("\n"),
}),
).toBe(false);
});
});