mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 13:56:40 -10:00
fix(daemon): verify Claude resume lineage when pane identity is opaque (#335)
* fix(daemon): trust exact Claude resume lineage * fix(daemon): verify wrapped Claude composer identity * fix(restore): require strict foreground resume proof * fix(daemon): reject failed headerless reconciliation proof * test(daemon): retain inconclusive resume after main update
This commit is contained in:
1 parent
0a108305f4
commit
e6a391b66c
6 files changed
+190
-5
No files matched your search
@@ -3,6 +3,7 @@ import type { TmuxAdapter } from "./tmux.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { verifyClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
|
||||
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
|
||||
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
|
||||
@@ -18,6 +19,7 @@ const CLAUDE_TYPES = new Set(["claude_name", "claude_id"]);
|
||||
|
||||
interface ClaudeResumeOptions {
|
||||
claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
listProcesses?: NativeProcessLister;
|
||||
pollMs?: number;
|
||||
maxWaitMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
@@ -85,7 +87,7 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName).catch((error): ResumeResult => ({
|
||||
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -93,7 +95,7 @@ export class ClaudeResumeAdapter {
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
private async verifyResume(tmuxSessionName: string): Promise<ResumeResult> {
|
||||
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
|
||||
const pollMs = this.options.pollMs ?? 200;
|
||||
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
|
||||
const sleepFn = this.options.sleep ?? sleep;
|
||||
@@ -157,6 +159,29 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// The exact --resume identity is stronger evidence than a pane command or
|
||||
// a version/footer heuristic. Use it only with Claude's interactive prompt
|
||||
// visible, and after the untrusted screen classifiers have had their say.
|
||||
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|
||||
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
|
||||
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
|
||||
const identity = await verifyClaudePaneProcess({
|
||||
target: tmuxSessionName,
|
||||
tmux: this.tmux,
|
||||
...(this.options.listProcesses ? { listProcesses: this.options.listProcesses } : {}),
|
||||
expectedToken: resumeToken,
|
||||
});
|
||||
if (identity) {
|
||||
const verifiedProbe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: finalCommand,
|
||||
paneContent: finalContent,
|
||||
claudeResumeIdentityVerified: true,
|
||||
});
|
||||
if (verifiedProbe.status === "resumed") return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (finalProbe.code === "returned_to_shell") {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface NativeResumeProbeInput {
|
||||
paneContent: string | null;
|
||||
/** Only the managed adapter supplies this after exact, stable native-process proof. */
|
||||
claudeAutoIdentityVerified?: boolean;
|
||||
/** Only an exact --resume process-lineage proof may use the visible composer as readiness. */
|
||||
claudeResumeIdentityVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface NativeResumeProbeResult {
|
||||
@@ -126,7 +128,14 @@ export function assessNativeResumeProbe(
|
||||
detail: "Claude is running with an active interactive TUI in the probe pane.",
|
||||
};
|
||||
}
|
||||
if (paneCommand === "claude") {
|
||||
if (input.claudeResumeIdentityVerified && hasClaudeComposerPrompt(paneContent)) {
|
||||
return {
|
||||
status: "resumed",
|
||||
code: "verified_native_identity",
|
||||
detail: "Claude is at its interactive prompt and the exact managed resume process was verified.",
|
||||
};
|
||||
}
|
||||
if (paneCommand === "claude" && input.claudeResumeIdentityVerified !== false) {
|
||||
return {
|
||||
status: "resumed",
|
||||
code: "active_runtime",
|
||||
@@ -239,7 +248,7 @@ export function isProbeShellReady(input: ProbeShellReadyInput): boolean {
|
||||
}
|
||||
|
||||
function looksLikeClaudeTui(paneContent: string): boolean {
|
||||
const hasPrompt = /(^|\n)\s*❯/.test(paneContent);
|
||||
const hasPrompt = hasClaudeComposerPrompt(paneContent);
|
||||
if (!hasPrompt) return false;
|
||||
|
||||
return (
|
||||
@@ -248,6 +257,10 @@ function looksLikeClaudeTui(paneContent: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasClaudeComposerPrompt(paneContent: string): boolean {
|
||||
return /(^|\n)\s*❯/.test(paneContent);
|
||||
}
|
||||
|
||||
function looksLikeClaudeTrustPrompt(paneContent: string): boolean {
|
||||
return paneContent.includes("Accessing workspace:")
|
||||
&& paneContent.includes("Yes, I trust this folder");
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { PiResumeAdapter } from "../adapters/pi-resume.js";
|
||||
import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { verifyClaudePaneProcess } from "./native-process-lineage.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import type {
|
||||
RestoreOutcome,
|
||||
@@ -1844,7 +1845,21 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
const paneCommand = await this.tmuxAdapter.getPaneCommand(identity.pane);
|
||||
const paneContent = (await this.tmuxAdapter.capturePaneContent(identity.pane, 40)) ?? "";
|
||||
const probe = assessNativeResumeProbe({ runtime, paneCommand, paneContent });
|
||||
const claudeResumeIdentityVerified = runtime === "claude-code" && !!await verifyClaudePaneProcess({
|
||||
target: identity.pane,
|
||||
tmux: this.tmuxAdapter,
|
||||
expectedToken: expectedResumeToken,
|
||||
requireResume: true,
|
||||
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}),
|
||||
});
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime,
|
||||
paneCommand,
|
||||
paneContent,
|
||||
// Headerless readiness requires the same stable foreground/argv proof
|
||||
// as the resume adapter; a token-bearing descendant alone is insufficient.
|
||||
...(runtime === "claude-code" ? { claudeResumeIdentityVerified } : {}),
|
||||
});
|
||||
const fgProcess = runtime === "claude-code" ? "claude" as const : runtime === "codex" ? "codex" as const : null;
|
||||
if (!fgProcess) {
|
||||
return { ok: false, code: "fg_process_not_runtime", detail: `Node runtime is ${runtime ?? "unknown"}, not claude/codex.` };
|
||||
|
||||
@@ -15,12 +15,14 @@ function mockTmux(overrides?: {
|
||||
sendKeys?: (target: string, keys: string[]) => Promise<TmuxResult>;
|
||||
getPaneCommand?: (target: string) => Promise<string | null>;
|
||||
capturePaneContent?: (target: string, lines?: number) => Promise<string | null>;
|
||||
getPanePid?: (target: string) => Promise<number | null>;
|
||||
}) {
|
||||
return {
|
||||
sendText: overrides?.sendText ?? vi.fn(async () => ({ ok: true as const })),
|
||||
sendKeys: overrides?.sendKeys ?? vi.fn(async () => ({ ok: true as const })),
|
||||
getPaneCommand: overrides?.getPaneCommand ?? vi.fn(async () => "claude"),
|
||||
capturePaneContent: overrides?.capturePaneContent ?? vi.fn(async () => ""),
|
||||
getPanePid: overrides?.getPanePid ?? vi.fn(async () => null),
|
||||
createSession: async () => ({ ok: true as const }),
|
||||
killSession: async () => ({ ok: true as const }),
|
||||
listSessions: async () => [],
|
||||
@@ -376,5 +378,54 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it.each(["2.1.283", "sh"])("accepts a headerless bypass-mode prompt with exact resume lineage behind %s", async (paneCommand) => {
|
||||
const tmux = mockTmux({
|
||||
getPanePid: vi.fn(async () => 1234),
|
||||
getPaneCommand: vi.fn(async () => paneCommand),
|
||||
capturePaneContent: vi.fn(async () => [
|
||||
"Restored conversation",
|
||||
"❯",
|
||||
"⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n")),
|
||||
});
|
||||
const adapter = new ClaudeResumeAdapter(tmux, {
|
||||
pollMs: 0,
|
||||
maxWaitMs: 0,
|
||||
sleep: async () => {},
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, command: "bash", executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, command: "claude --permission-mode acceptEdits --resume resume-id", executableName: "claude", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_id", "resume-id", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["2.1.283", "attention_required"],
|
||||
["sh", "retry_fresh"],
|
||||
] as const)("does not accept the prompt behind %s when the native process carries another resume token", async (paneCommand, expectedCode) => {
|
||||
const tmux = mockTmux({
|
||||
getPanePid: vi.fn(async () => 1234),
|
||||
getPaneCommand: vi.fn(async () => paneCommand),
|
||||
capturePaneContent: vi.fn(async () => "Restored conversation\n❯\n⏵⏵ bypass permissions on"),
|
||||
});
|
||||
const adapter = new ClaudeResumeAdapter(tmux, {
|
||||
pollMs: 0,
|
||||
maxWaitMs: 0,
|
||||
sleep: async () => {},
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, command: "bash", executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, command: "claude --permission-mode acceptEdits --resume another-session", executableName: "claude", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_id", "resume-id", "/repo");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: expectedCode });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,35 @@ describe("native resume probe", () => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: `${screen}\n${panel}`, claudeAutoIdentityVerified: true }).code).toBe(code);
|
||||
});
|
||||
});
|
||||
describe("Claude's exact resume identity can validate the visible composer", () => {
|
||||
const screen = "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents\n";
|
||||
|
||||
it("accepts a headerless prompt only after exact resume-lineage proof", () => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "2.1.283", paneContent: screen }))
|
||||
.toMatchObject({ status: "inconclusive", code: "awaiting_runtime" });
|
||||
expect(assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: "2.1.283",
|
||||
paneContent: screen,
|
||||
claudeResumeIdentityVerified: true,
|
||||
})).toMatchObject({ status: "resumed", code: "verified_native_identity" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Accessing workspace:\nYes, I trust this folder", "inconclusive", "trust_gate"],
|
||||
["new MCP servers found in .mcp.json\nSelect any you wish to enable\nEnter to confirm", "inconclusive", "mcp_gate"],
|
||||
["Not logged in · Run /login", "failed", "login_required"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "attention_required", "claude_resume_selection_prompt"],
|
||||
["No conversation found", "failed", "no_conversation_found"],
|
||||
])("keeps known blocking prompt authoritative despite resume identity proof: %s", (panel, status, code) => {
|
||||
expect(assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: "2.1.283",
|
||||
paneContent: `${screen}\n${panel}`,
|
||||
claudeResumeIdentityVerified: true,
|
||||
})).toMatchObject({ status, code });
|
||||
});
|
||||
});
|
||||
describe("issue116 headerless custom status lines", () => {
|
||||
const reportedFooter = " 5h 71% left · weekly 24% left · GPT-6-Astra high · Context 81% left";
|
||||
it.each(["›", "»"])("recognizes the reported footer below a %s conversation prompt", (prompt) => {
|
||||
|
||||
@@ -3062,6 +3062,58 @@ describe("RestoreOrchestrator", () => {
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles a headerless Claude prompt when exact resume-token lineage is verified", async () => {
|
||||
const tmux = mockTmuxForReconciler();
|
||||
vi.mocked(tmux.hasSession).mockResolvedValue(true);
|
||||
vi.mocked(tmux.getPaneCommand).mockResolvedValue("2.1.283");
|
||||
vi.mocked(tmux.capturePaneContent).mockResolvedValue([
|
||||
"Restored conversation",
|
||||
"❯",
|
||||
"⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n"));
|
||||
const seeded = seedFailedAttempt({ restoreOutcome: "failed", withResumeToken: true });
|
||||
|
||||
const result = await createOrchestrator({
|
||||
tmux,
|
||||
listProcesses: async () => managedClaudeRows("tok-abc-123"),
|
||||
}).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, to: "operator_recovered" });
|
||||
expect(db.prepare("SELECT * FROM events WHERE type = 'restore.outcome_reconciled'").all()).toHaveLength(1);
|
||||
expect(tmux.sendKeys).not.toHaveBeenCalled();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["background", "ambiguous", "argv-lookalike", "missing-metadata"].flatMap((failure) =>
|
||||
["2.1.283", "claude"].map((paneCommand) => [failure, paneCommand])
|
||||
))("does not reconcile headerless %s evidence under %s", async (failure, paneCommand) => {
|
||||
const tmux = mockTmuxForReconciler();
|
||||
vi.mocked(tmux.hasSession).mockResolvedValue(true);
|
||||
vi.mocked(tmux.getPaneCommand).mockResolvedValue(paneCommand);
|
||||
vi.mocked(tmux.capturePaneContent).mockResolvedValue("Restored conversation\n❯\n⏵⏵ bypass permissions on");
|
||||
const seeded = seedFailedAttempt({ restoreOutcome: "failed", withResumeToken: true });
|
||||
const originalOutcome = db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get();
|
||||
const listProcesses = async () => {
|
||||
const rows = managedClaudeRows("tok-abc-123");
|
||||
if (failure === "background") {
|
||||
rows[2]!.pgid = 9000;
|
||||
rows.push({ ...rows[2]!, pid: 1237, pgid: 1235, command: "claude --resume another-session" });
|
||||
}
|
||||
if (failure === "ambiguous") rows.push({ ...rows[2]!, pid: 1237 });
|
||||
if (failure === "argv-lookalike") rows[2]!.command = "claude --model --resume tok-abc-123";
|
||||
if (failure === "missing-metadata") rows[2]!.startedAt = "";
|
||||
return rows;
|
||||
};
|
||||
|
||||
const result = await createOrchestrator({ tmux, listProcesses }).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(db.prepare("SELECT * FROM events WHERE type = 'restore.outcome_reconciled'").all()).toHaveLength(0);
|
||||
expect(db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get()).toEqual(originalOutcome);
|
||||
expect(tmux.sendKeys).not.toHaveBeenCalled();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upgrades failed -> operator_recovered when ALL four preconditions hold; emits audit event", async () => {
|
||||
const tmux = mockTmuxForReconciler();
|
||||
(tmux.hasSession as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
Reference in new issue
Block a user