feat(daemon): codex auth-refusal surfaces as attention_required end-to-end

Closes the deferral recorded by the lifecycle scenario matrix slice: Codex
resume against a stored OAuth token whose refresh fails (operator logged out
elsewhere, account changed, device key revoked, etc.) is alive-but-recoverable,
not terminal `failed`. Operator runs `codex login` and the seat continues —
the same recovery affordance Claude already had for resume-selection prompts.

Three source surfaces patched, all classification-routing only:

1. native-resume-probe.ts — new `looksLikeCodexAuthRefusal` helper requiring
   BOTH the access-token phrase AND the operator-instruction phrase to avoid
   false-positives on incidental debug output. Returns
   `{status: "attention_required", code: "codex_auth_refusal"}`. Inserted in
   the Codex branch right after the explicit `no_saved_session` failure check
   so the explicit auth-refusal path takes precedence over the trust /
   model-selection / update inconclusive gates and over `paneCommand=codex`
   active-runtime detection.

2. codex-resume.ts — `verifyResume` adds `attention_required` pass-through
   mirroring `claude-resume.ts:92-99`. Returns `{ok: false, code:
   "attention_required", message, evidence}` where `evidence` is the last 12
   lines of pane content (verbatim Claude shape).

3. restore-orchestrator.ts — Codex branch of `attemptResume` (the
   non-pod-aware legacy resume path) now mirrors the Claude translation
   table: `ok → resumed`, `retry_fresh → retry_fresh`, `attention_required →
   attention_required` with `message+evidence`, else → `failed`. The
   runtime-agnostic per-node mapping at lines 725-735 emits `status:
   "attention_required"` with `attentionEvidence` for both runtimes — no
   further wiring needed. The polymorphic dispatcher return type at lines
   923-928 already declared all four kinds and is untouched.

NOT changed (and explicitly checked):
- restore-orchestrator.ts:725-735 per-node mapping (runtime-agnostic).
- restore-orchestrator.ts:923-928 dispatcher return type union.
- node-inventory.ts attention_required projection (covers both runtimes).
- context-monitor.ts attention_required reconciliation.
- startup-orchestrator.ts (does not gate Codex probe path).
- Schema migrations / nodes / sessions tables.

Pane patterns sourced verbatim from codex-cli 0.125.0 binary strings at
/opt/homebrew/Caskroom/codex/0.125.0/codex-aarch64-apple-darwin (driver did
not improvise patterns from training-data guesses per packet § Pattern
Research):

  "Your access token could not be refreshed because you have since
   logged out or signed in to another account. Please sign in again."
  "Your access token could not be refreshed. Please log out and sign
   in again."

Cross-runtime alignment audit flag (NOT settled in this slice — recorded as
a follow-up question for orch per packet § Out-Of-Scope Audit Flag): Claude's
`looksLikeClaudeLoginPrompt` at native-resume-probe.ts:79-85 returns
`{status: "failed", code: "login_required"}` (terminal); the new Codex
auth-refusal pattern returns `{status: "attention_required", code:
"codex_auth_refusal"}` (recoverable). Both runtimes can present a "needs
login" state. Whether Claude should align with Codex (recoverable) or Codex
should align with Claude (terminal) is a provider-auth-semantics decision
beyond a probe-pattern slice. Recommend a separate slice to settle.

Tests (3 files, 271 total insertions across source+test):
- native-resume-probe.test.ts: +5 cases (auth-refusal positive x2, both-anchors-
  required negative, no_saved_session/trust_gate/active_runtime non-collision).
- codex-resume.test.ts: +2 cases (verifyResume attention_required pass-through
  with evidence shape; both-anchors-required negative).
- lifecycle-scenario-matrix.test.ts: REPLACE the documented `it.skip` at line
  411 with an end-to-end orchestrator test asserting Codex auth-refusal →
  per-node `status: "attention_required"` + `attentionEvidence` + rig-level
  `partially_restored` rollup. The 1-skipped baseline from the prior
  lifecycle slice is now 0-skipped.

Daemon suite: 2185/2185 passing (was 2176/2176-ish + 1 skipped at fcfc7dc;
net +9 from this slice; deferral closed; zero regressions).

Slice rails: substrate/shared-docs/openrig-work/missions/recursive-self-
improvement-v2/primitive-hardening/slices/codex-auth-refusal-attention-
required/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-04-30 13:08:26 -07:00
1 parent fcfc7dc93c
commit 63ee206e93
6 files changed
+271 -20

No files matched your search

+17 -5
View File
@@ -62,11 +62,10 @@ export class CodexResumeAdapter {
}
// Mirrors ClaudeResumeAdapter.verifyResume: poll the pane, run the native
// probe, return resumed / retry_fresh / resume_failed based on observable
// runtime state. Closes the false-positive `resumed` shape that fire-and-
// forget left open. Does NOT introduce a Codex `attention_required` outcome
// — that requires new probe patterns for Codex auth refusal and is deferred
// to a follow-up provider-auth-attention slice.
// probe, return resumed / retry_fresh / attention_required / resume_failed
// based on observable runtime state. The `attention_required` outcome
// (Codex auth refusal — stored OAuth token can no longer be refreshed)
// closes the deferral recorded by the lifecycle scenario matrix slice.
private async verifyResume(tmuxSessionName: string): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 200;
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
@@ -90,6 +89,19 @@ export class CodexResumeAdapter {
};
}
// Codex auth-refusal is alive-but-recoverable: the stored access token
// can no longer be refreshed. Surface evidence (last 12 pane lines) so
// the operator/UI can decide whether to `codex login` and continue, or
// mark the seat permanently rebuilt. Mirror Claude's evidence shape.
if (probe.status === "attention_required") {
return {
ok: false,
code: "attention_required",
message: probe.detail,
evidence: paneContent.split("\n").slice(-12).join("\n"),
};
}
if (probe.status === "resumed") {
return { ok: true };
}
@@ -119,6 +119,13 @@ export function assessNativeResumeProbe(
detail: "Codex reported that the requested saved session does not exist.",
};
}
if (looksLikeCodexAuthRefusal(paneContent)) {
return {
status: "attention_required",
code: "codex_auth_refusal",
detail: "Codex could not refresh the stored access token; an operator must sign in again before the session can resume.",
};
}
if (looksLikeCodexTrustPrompt(paneContent)) {
return {
status: "inconclusive",
@@ -242,6 +249,27 @@ function looksLikeCodexTui(paneContent: string): boolean {
return hasPromptLine && hasModelFooter;
}
// Codex prints these messages when its stored OAuth access token can no
// longer be refreshed — operator logged out elsewhere, account changed,
// device key revoked, etc. Operator recovers via `codex login`. Both
// anchors required: the access-token phrase distinguishes from generic
// errors; the operator-instruction phrase distinguishes from internal
// debug logs that may include the access-token phrase incidentally.
//
// Source verified at codex-cli 0.125.0 binary at
// /opt/homebrew/Caskroom/codex/0.125.0/codex-aarch64-apple-darwin:
// "Your access token could not be refreshed because you have since
// logged out or signed in to another account. Please sign in again."
// "Your access token could not be refreshed. Please log out and sign
// in again."
function looksLikeCodexAuthRefusal(paneContent: string): boolean {
if (!paneContent.includes("access token could not be refreshed")) return false;
return (
paneContent.includes("Please sign in again")
|| paneContent.includes("Please log out and sign in again")
);
}
function looksLikeCodexTrustPrompt(paneContent: string): boolean {
return paneContent.includes("Do you trust the contents of this directory?")
&& paneContent.includes("Yes, continue");
@@ -943,7 +943,20 @@ export class RestoreOrchestrator {
if (this.codexResume.canResume(resumeType, resumeToken)) {
const result = await this.codexResume.resume(sessionName, resumeType, resumeToken, cwd);
return result.ok ? { kind: "resumed" } : { kind: "failed", message: result.message };
if (result.ok) return { kind: "resumed" };
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
// Codex auth-refusal: stored OAuth token can no longer be refreshed.
// Recoverable — operator runs `codex login` and the seat continues.
// Per-node mapping at lines 725-735 emits `status: "attention_required"`
// with `attentionEvidence` for both runtimes; no further wiring needed.
if (result.code === "attention_required") {
return {
kind: "attention_required",
message: result.message,
evidence: (result as { evidence?: string }).evidence,
};
}
return { kind: "failed", message: result.message };
}
return { kind: "failed", message: "No resume adapter available for this runtime/token combination." };
+48
View File
@@ -246,5 +246,53 @@ describe("CodexResumeAdapter", () => {
expect(result).toEqual({ ok: true });
expect(attempt).toBeGreaterThanOrEqual(2);
});
// Codex auth-refusal -> attention_required pass-through. Mirrors
// ClaudeResumeAdapter.verifyResume's evidence shape (last 12 pane lines).
// Closes the lifecycle scenario matrix slice's documented Codex deferral.
it("probe says attention_required (codex auth-refusal) -> { ok: false, code: 'attention_required', evidence }", async () => {
const refusalPane = [
"$ codex resume 019d-token",
"Error: Your access token could not be refreshed because you have since",
"logged out or signed in to another account. Please sign in again.",
].join("\n");
const adapter = new CodexResumeAdapter(
mockTmux({
getPaneCommand: async () => "zsh",
capturePaneContent: async () => refusalPane,
}),
fastOptions,
);
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("attention_required");
expect(result.message).toContain("sign in again");
// Evidence should be the last-12-lines tail of pane content
// (mirrors claude-resume.ts:97 shape exactly).
expect((result as { evidence?: string }).evidence).toBeDefined();
expect((result as { evidence?: string }).evidence).toContain("access token could not be refreshed");
expect((result as { evidence?: string }).evidence).toContain("Please sign in again");
}
});
it("attention_required does NOT trigger when only access-token phrase present (no operator instruction)", async () => {
const adapter = new CodexResumeAdapter(
mockTmux({
getPaneCommand: async () => "codex",
capturePaneContent: async () => "debug: access token could not be refreshed (retrying...)",
}),
fastOptions,
);
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
// Probe should fall through to inconclusive (paneCommand=codex still
// returns active_runtime / resumed); auth-refusal pattern requires
// both anchors.
expect(result.ok).toBe(true);
});
});
});
@@ -397,20 +397,88 @@ describe("Lifecycle reboot/recovery scenario matrix (Tier 1)", () => {
if (!r.ok) expect(r.code).toBe("retry_fresh");
});
// DOCUMENTED DEFERRAL (slice closeout):
// Codex auth-refusal → attention_required is NOT a current outcome.
// Adding it requires (a) Codex auth-refusal pane patterns researched
// and added to native-resume-probe.ts, (b) a Codex `attention_required`
// outcome added to the probe, (c) a verifyResume branch returning that
// outcome with evidence, (d) restore-orchestrator wiring to map it the
// same way Claude is mapped at lines 725-735. That is a multi-surface
// feature that grows beyond smallest-honest-patch in this matrix slice.
// Tier 2 (real Codex auth refusal in disposable VM) is human-gated per
// disposable-vm-proof-default rule. Defer to a follow-up provider-auth-
// attention slice.
it.skip("DEFERRED: Codex auth-refusal → attention_required (probe extension required)", () => {
// Intentionally skipped; documents the deferred gap in-tree so a
// future driver picks it up alongside the smallest-honest-patch.
// Codex auth-refusal → attention_required end-to-end. Closes the
// deferral previously documented in this file. Implemented by the
// codex-auth-refusal-attention-required slice via:
// (a) `looksLikeCodexAuthRefusal` in native-resume-probe.ts;
// (b) `attention_required` pass-through in codex-resume.ts;
// (c) Codex-branch translation in restore-orchestrator.ts:944-960.
// The runtime-agnostic per-node mapping at restore-orchestrator.ts:725-735
// emits `status: "attention_required"` with `attentionEvidence` for
// both runtimes — no further wiring needed.
it("Codex auth-refusal → node status=attention_required with auth-refusal pane evidence", async () => {
const db = createFullTestDb();
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({
db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore,
});
const tmux = mockTmuxForRestore({ hasSession: false });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
// Codex adapter stub returns the same shape the real adapter would
// emit for auth-refusal: { ok: false, code: "attention_required",
// message, evidence } where evidence is the last 12 lines of pane
// content. Exercises restore-orchestrator's Codex branch translation
// patched in this slice.
const refusalEvidence = [
"$ codex resume tok-codex",
"Error: Your access token could not be refreshed because you have since",
"logged out or signed in to another account. Please sign in again.",
].join("\n");
const codexAttentionResult: ResumeResult = {
ok: false,
code: "attention_required",
message: "Codex could not refresh the stored access token; an operator must sign in again before the session can resume.",
evidence: refusalEvidence,
};
const orchestrator = new RestoreOrchestrator({
db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux,
claudeResume: mockClaudeResumeReturning({ ok: true }),
codexResume: mockCodexResumeReturning(codexAttentionResult),
});
const rig = rigRepo.createRig("r97");
const node = rigRepo.addNode(rig.id, "codex-worker", { role: "worker", runtime: "codex", cwd: "/tmp" });
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
const fullSnap = snapshotRepo.getSnapshot(snap.id)!;
const data = JSON.parse(JSON.stringify(fullSnap.data));
data.sessions = [{
id: "sess-codex-1",
nodeId: node.id,
sessionName: "r97-codex-worker",
status: "running",
resumeType: "codex_id",
resumeToken: "tok-codex",
restorePolicy: "resume_if_possible",
}];
db.prepare("UPDATE snapshots SET data = ? WHERE id = ?")
.run(JSON.stringify(data), snap.id);
const outcome = await orchestrator.restore(snap.id);
expect(outcome.ok).toBe(true);
if (!outcome.ok) throw new Error(`restore failed: ${outcome.code}`);
// Per-node status: attention_required (NOT failed, NOT resumed).
const codexNode = outcome.result.nodes.find((n) => n.nodeId === node.id);
expect(codexNode?.status).toBe("attention_required");
// Evidence preserved on the node result via the runtime-agnostic
// mapping at restore-orchestrator.ts:725-735.
expect(codexNode?.attentionEvidence).toBeDefined();
expect(codexNode?.attentionEvidence).toContain("access token could not be refreshed");
expect(codexNode?.attentionEvidence).toContain("Please sign in again");
// Rig-level rollup: single attention_required node → partially_restored
// (NOT failed). Aggregation at restore-orchestrator.ts:65 already
// includes attention_required in the mixed-status set; this test
// confirms Codex participates honestly.
expect(outcome.result.rigResult).toBe("partially_restored");
db.close();
});
});
});
@@ -455,4 +455,86 @@ describe("native resume probe", () => {
expect(result.status).not.toBe("attention_required");
});
});
// Codex auth-refusal -> attention_required. Closes the deferral recorded by
// the lifecycle scenario matrix slice. Pane patterns sourced verbatim from
// codex-cli 0.125.0 binary strings (token-refresh failure paths).
describe("Codex auth-refusal recognition", () => {
it("classifies Codex post-logout token-refresh failure as attention_required", () => {
const paneContent = [
"$ codex resume 019d-token",
"Error: Your access token could not be refreshed because you have since",
"logged out or signed in to another account. Please sign in again.",
].join("\n");
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "zsh",
paneContent,
});
expect(result).toEqual({
status: "attention_required",
code: "codex_auth_refusal",
detail: "Codex could not refresh the stored access token; an operator must sign in again before the session can resume.",
});
});
it("classifies Codex token-refresh failure with `log out and sign in` guidance as attention_required", () => {
const paneContent = [
"$ codex resume 019d-token",
"Your access token could not be refreshed.",
"Please log out and sign in again.",
].join("\n");
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "zsh",
paneContent,
});
expect(result.status).toBe("attention_required");
expect(result.code).toBe("codex_auth_refusal");
});
it("requires BOTH the access-token phrase AND operator-instruction phrase (negative)", () => {
// Access-token phrase alone (without operator instruction) does not
// qualify — could appear in incidental debug output.
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "codex",
paneContent: "debug: access token could not be refreshed (retrying...)",
});
expect(result.status).not.toBe("attention_required");
});
it("does NOT collide with no_saved_session (different code path)", () => {
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "zsh",
paneContent: "ERROR: No saved session found with ID 019d...",
});
expect(result.code).toBe("no_saved_session");
expect(result.status).toBe("failed");
});
it("does NOT collide with trust_gate", () => {
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "codex",
paneContent: [
"Do you trust the contents of this directory?",
"› 1. Yes, continue",
" 2. No, quit",
].join("\n"),
});
expect(result.code).toBe("trust_gate");
});
it("does NOT collide with active_runtime when codex is foreground without auth-refusal text", () => {
const result = assessNativeResumeProbe({
runtime: "codex",
paneCommand: "codex",
paneContent: "OpenAI Codex (v0.125.0)\n › ready\n gpt-5 · context",
});
expect(result.status).toBe("resumed");
expect(result.code).toBe("active_runtime");
});
});
});