Compare commits

...
Author SHA1 Message Date
dev-driver a9c6ca3649 test(daemon): managed seats keep main's proof at every readiness caller
Pins the version-label witness to the seat's persisted permission selection, not the binding a caller builds. Through a binding without permissionMode, a managed selection, an unreadable selection and a missing selection reader all leave the witness unconsulted. At the recovery consumer (ContextMonitor.pollOnce over real SQLite and NativePermissionStore), a managed seat with a version label and the wrong conversation, or with no OS path, stays attention_required; the ordinary claude label still reads ready, and an unmanaged witnessed label still does too.

Refs #86
2026-10-17 05:32:40 +00:00
dev-driver 4afc1c4a90 fix(daemon): the version-label witness requires argv0 claude
A versioned argv0 matches its own OS name, so the process rule accepts it without reading the OS path. The version-label witness now requires argv0 to name claude, which keeps the OS executable path the deciding evidence. A row with a versioned argv0 and an unrelated path stays refused.

Refs #86
2026-10-16 02:40:24 +00:00
dev-driver 776b94dade fix(daemon): read a native install's version label as claude with an OS executable witness
Claude's native installer makes ~/.local/bin/claude a symlink into its versions folder, and macOS names the process after the file it ran, so the pane label is the version (2.1.300), never claude. A claude label reads as Claude by name; a version label had to prove the exact conversation, which also needs Claude's prompt on screen and every argument parsed. So a resumed unmanaged seat could time out as attention required, and reconcile-session reported that the pane command did not confirm claude-code.

A version label now stands for claude only when the pane's one stable foreground Claude is that version-named process, witnessed by its OS executable path in the installer's versions layout with a basename equal to its process name, and the pane's process is unchanged. The label alone never counts, and a path read that fails or times out is no witness. The launch wait and readiness check and the legacy restore resume apply it to unmanaged launches only, and only when the label would change the result; reconcile-session applies it to its drift line. Like the claude label, it claims nothing about which conversation is running. Managed launches keep their exact proof, and shell labels, aliases and functions are unchanged.

Refs #86
2026-10-16 02:13:03 +00:00
dev-driver 01a00ef252 test(daemon): a native install's version label with an OS executable witness
Claude's native installer names its process, and so tmux's pane label, after its versioned file. These cases pin an unmanaged seat whose label is a version: the launch wait and checkReady resume it, the legacy restore resume verifies it, and reconcile-session reports no runtime drift, when the OS executable path witnesses native Claude. The refusals already hold, as do managed seats, shell labels and the claude label.

Refs #86
2026-10-16 02:12:37 +00:00
5 changed files with 292 additions and 18 deletions

No files matched your search

@@ -13,7 +13,7 @@ import type {
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { observeClaudePaneProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePaneProcess, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
@@ -383,6 +383,13 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
// #86: an unmanaged native install's version label reads as `claude` once its OS executable is witnessed. The
// witness runs only when the label would change the result; a managed launch keeps its exact proof below.
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (probe.status !== "resumed" && asClaude.status === "resumed" && binding.permissionMode === undefined && binding.tmuxSession
&& await witnessedClaudeVersionLabel(paneCommand, { target: binding.tmuxSession, tmux: this.tmux, listProcesses: this.listProcesses })) {
return asClaude;
}
// Headerless managed launches (including bypass mode) use the same readiness
// precedence as resume. This candidate result is returned only after the
// exact launch identity below is proved; screen text alone is insufficient.
+16 -13
View File
@@ -6,7 +6,7 @@ import { shellQuote } from "./shell-quote.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { verifyClaudePaneProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { verifyClaudePaneProcess, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, 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";
@@ -106,7 +106,7 @@ export class ClaudeResumeAdapter {
return { ok: false, code: "resume_failed", message: keyResult.message };
}
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
const result = await this.verifyResume(tmuxSessionName, resumeToken!, !managed).catch((error): ResumeResult => ({
ok: false,
code: "attention_required",
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
@@ -114,7 +114,18 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch } : result;
}
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
/** #86: as the launch adapter's probe, an unmanaged native install's version label reads as `claude` once its OS
* executable is witnessed. The witness runs only when the label would change the result. */
private async assessProbe(target: string, paneCommand: string | null, paneContent: string, unmanaged: boolean) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (unmanaged && probe.status !== "resumed" && asClaude.status === "resumed"
&& await witnessedClaudeVersionLabel(paneCommand, { target, tmux: this.tmux, listProcesses: this.options.listProcesses })) return asClaude;
return probe;
}
private async verifyResume(tmuxSessionName: string, resumeToken: string, unmanaged: boolean): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 200;
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
const sleepFn = this.options.sleep ?? sleep;
@@ -123,11 +134,7 @@ export class ClaudeResumeAdapter {
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const probe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand,
paneContent,
});
const probe = await this.assessProbe(tmuxSessionName, paneCommand, paneContent, unmanaged);
if (probe.code === "no_conversation_found") {
return {
@@ -162,11 +169,7 @@ export class ClaudeResumeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const finalContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const finalProbe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: finalCommand,
paneContent: finalContent,
});
const finalProbe = await this.assessProbe(tmuxSessionName, finalCommand, finalContent, unmanaged);
if (finalProbe.code === "no_conversation_found") {
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
+11 -4
View File
@@ -14,6 +14,7 @@ import {
type PaneBindingObservation,
} from "./pane-binding-observation.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { witnessedClaudeVersionLabel, type NativeProcessLister } from "./native-process-lineage.js";
export type ClaimResult =
| { ok: true; nodeId: string; sessionId: string }
@@ -84,6 +85,8 @@ interface ClaimServiceDeps {
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string };
};
ompRunnerStateStore?: ResumeTokenCaptureDeps["ompRunnerStateStore"];
/** #86 — process rows for the native version-label witness; defaults to the OS listing. */
listProcesses?: NativeProcessLister;
}
interface BindOptions {
@@ -119,6 +122,7 @@ export class ClaimService {
private resumeTokenCapturer: ClaimServiceDeps["resumeTokenCapturer"] | null;
private piRunnerStateStore: ClaimServiceDeps["piRunnerStateStore"] | null;
private ompRunnerStateStore: ClaimServiceDeps["ompRunnerStateStore"] | null;
private listProcesses: NativeProcessLister | undefined;
constructor(deps: ClaimServiceDeps) {
if (deps.db !== deps.rigRepo.db) throw new Error("ClaimService: rigRepo must share the same db handle");
@@ -138,6 +142,7 @@ export class ClaimService {
this.resumeTokenCapturer = deps.resumeTokenCapturer ?? null;
this.piRunnerStateStore = deps.piRunnerStateStore ?? null;
this.ompRunnerStateStore = deps.ompRunnerStateStore ?? null;
this.listProcesses = deps.listProcesses;
}
private async observeBindingPane(
@@ -505,11 +510,10 @@ export class ClaimService {
const projectionDrift: string[] = [];
const paneObservation = await this.observeBindingPane(sessionName);
if (!paneObservation.ok) projectionDrift.push(paneObservation.detail);
const paneTarget = paneObservation.ok ? paneObservation.pane : sessionName;
let paneCommand: string | null = null;
try {
paneCommand = await this.tmuxAdapter.getPaneCommand(
paneObservation.ok ? paneObservation.pane : sessionName,
);
paneCommand = await this.tmuxAdapter.getPaneCommand(paneTarget);
} catch { paneCommand = null; }
if (nodeRow.runtime) {
const expectation: Record<string, string[]> = {
@@ -520,7 +524,10 @@ export class ClaimService {
const expected = expectation[nodeRow.runtime];
if (!paneCommand) {
projectionDrift.push(`runtime unverified: pane command unreadable; node declares runtime "${nodeRow.runtime}"`);
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)) {
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)
// #86: a native install's version label confirms claude-code only with its OS executable witness.
&& !(nodeRow.runtime === "claude-code" && await witnessedClaudeVersionLabel(paneCommand,
{ target: paneTarget, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }))) {
projectionDrift.push(`runtime unverified: pane command "${paneCommand}" does not confirm runtime "${nodeRow.runtime}"`);
}
}
@@ -597,6 +597,24 @@ export async function verifyClaudePaneRuntime(input: Parameters<typeof observeCl
return second?.fingerprint === first.fingerprint ? second : null;
}
const NATIVE_VERSION_LABEL = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
/** #86: the native installer names Claude's process, and so the pane label, after its versioned file. Whether that
* label may stand for `claude`: only when the pane's one stable foreground Claude is the version-named process
* itself, witnessed by its OS executable path (`claudeProcess`: argv0 claude, the installer's versions layout, a
* basename equal to the OS name), and the pane's pid is unchanged. The label alone is never identity, and a path
* read that fails or times out is no witness. Like a `claude` label, this says nothing about the conversation. */
export async function witnessedClaudeVersionLabel(paneCommand: string | null,
input: Omit<Parameters<typeof observeClaudePaneRuntime>[0], "selectedExecutable">): Promise<boolean> {
const label = paneCommand?.trim() ?? "";
if (!NATIVE_VERSION_LABEL.test(label)) return false;
const native = await verifyClaudePaneRuntime(input);
// argv0 `claude` keeps the OS path the deciding evidence: a versioned argv0 matches the OS name without it.
if (!native || executableName(tokens(native.process.command)[0] ?? "") !== "claude"
|| native.process.executableName !== label || !native.process.executablePath) return false;
return await input.tmux.getPanePid(input.target).catch(() => null) === native.panePid;
}
/** The `ps` start time (lstart, local time) of the one stable Claude foreground
* process. This makes no claim about its conversation token. */
export async function observeClaudePaneStartedAt(input: Parameters<typeof observeClaudePaneRuntime>[0]): Promise<string | null> {
@@ -0,0 +1,239 @@
// #86 — Claude's native installer names its process, and so tmux's pane label, after its versioned file (2.1.300).
// An unmanaged seat whose label is a version reads as `claude` only when the OS executable path witnesses native
// Claude. Every case runs through existing seams, so the same file runs unchanged on main: the witnessed cases fail
// there (timeout, no verified resume, reconcile drift) and the refusals already hold.
import { describe, it, expect, vi } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
import type { NativeProcessRow } from "../src/domain/native-process-lineage.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import { ClaimService } from "../src/domain/claim-service.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
import { ContextMonitor } from "../src/domain/context-monitor.js";
const VERSION = "2.1.300";
const NATIVE_PATH = `/fixture/.local/share/claude/versions/${VERSION}`;
const startedAt = "Fri Oct 16 01:00:00 2026";
// The staging subshell exec'd Claude, so Claude leads the pane's foreground group under the pane shell.
function rows(command = "claude --permission-mode acceptEdits --resume tok --name seat"): NativeProcessRow[] {
return [
{ pid: 10, ppid: 1, pgid: 10, tpgid: 12, executableName: "zsh", command: "-zsh", startedAt },
{ pid: 12, ppid: 10, pgid: 12, tpgid: 12, executableName: VERSION, executablePath: NATIVE_PATH, command, startedAt },
];
}
function mockTmux(label: string, panePid: () => Promise<number | null> = async () => 10): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
sendKeys: vi.fn(async () => ({ ok: true as const })),
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => label),
// Claude hasn't drawn its prompt yet: no screen text can prove the resume.
capturePaneContent: vi.fn(async () => ""),
getPanePid: vi.fn(panePid),
listPanes: vi.fn(async () => []),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
startPipePane: vi.fn(async () => ({ ok: true as const })),
stopPipePane: vi.fn(async () => ({ ok: true as const })),
setSessionOption: vi.fn(async () => ({ ok: true as const })),
getSessionOption: vi.fn(async () => null),
} as unknown as TmuxAdapter;
}
const fsOps = {
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {}, listFiles: () => [],
} as unknown as ClaudeAdapterFsOps;
function binding(extra: Partial<NodeBinding> = {}): NodeBinding {
return { id: "b1", nodeId: "n1", tmuxSession: "dev-impl@rig", tmuxWindow: null, tmuxPane: null,
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/project", ...extra };
}
// The seat's persisted managed permission mode; by default the seat has no managed selection.
type PermissionModeReader = (nodeId: string) => string | undefined;
const adapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[] | Promise<NativeProcessRow[]>,
resolvePermissionMode: PermissionModeReader | null = () => undefined) =>
new ClaudeCodeAdapter({ tmux, fsOps, listProcesses, sleep: async () => {}, ...(resolvePermissionMode ? { resolvePermissionMode } : {}) });
describe("#86 unmanaged launch wait", () => {
it.each([
["OpenRig's own resume command", rows()],
["a flag from the user's own claude, which exact proof can't parse", rows("claude --strict-mcp-config --permission-mode acceptEdits --resume tok --name seat")],
])("resumes a witnessed native version label: %s", async (_name, processRows) => {
const result = await adapter(mockTmux(VERSION), async () => processRows)
.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
expect(result).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("checkReady reads a witnessed native version label as Claude", async () => {
expect(await adapter(mockTmux(VERSION), rows).checkReady(binding())).toEqual({ ready: true });
});
});
describe("#86 refusals that stay refused", () => {
const awaiting = { ready: false, code: "awaiting_runtime" };
const cases: [string, NativeProcessRow[] | (() => NativeProcessRow[]), string?, (() => Promise<number | null>)?][] = [
["a path outside the native layout", rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r)],
["a basename that isn't the OS name", rows().map(r => r.pid === 12 ? { ...r, executablePath: NATIVE_PATH.replace("300", "299") } : r)],
["argv0 that isn't claude", rows("node /fixture/cli.js --resume tok")],
["a version-only argv0", rows(`${VERSION} --resume tok`)],
["two candidates", [...rows(), { ...rows()[1]!, pid: 13 }]],
// listNativeProcesses leaves the path unset when the OS read fails or times out.
["a path read that failed or timed out", rows().map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with no OS path read", rows(`${NATIVE_PATH} --resume tok`).map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with an unrelated OS path", rows(`${NATIVE_PATH} --resume tok`).map(r => r.pid === 12 ? { ...r, executablePath: "/opt/other/claude" } : r)],
["a background process", rows().map(r => r.pid === 12 ? { ...r, pgid: 88 } : r)],
["a label naming a different version", rows(), "2.1.301"],
["a pane pid change after the samples", rows(), VERSION, vi.fn<() => Promise<number | null>>()
.mockResolvedValueOnce(10).mockResolvedValueOnce(10).mockResolvedValue(11)],
];
it.each(cases)("%s", async (_name, processRows, label = VERSION, panePid) => {
const list = typeof processRows === "function" ? processRows : () => processRows;
expect(await adapter(mockTmux(label, panePid), list).checkReady(binding())).toMatchObject(awaiting);
});
it("a process that changes between the two samples", async () => {
const list = vi.fn<() => NativeProcessRow[]>().mockReturnValueOnce(rows())
.mockReturnValue(rows().map(r => r.pid === 12 ? { ...r, startedAt: startedAt.replace("01:00:00", "01:00:01") } : r));
expect(await adapter(mockTmux(VERSION), list).checkReady(binding())).toMatchObject(awaiting);
});
});
describe("#86 results that don't change", () => {
it("a managed seat keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list).checkReady(binding({ permissionMode: "auto" })))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it.each<[string, PermissionModeReader | null]>([
["a persisted managed selection, through a binding without permissionMode", () => "auto"],
["an unreadable selection", () => { throw new Error("Invalid persisted native permission selection; launch refused."); }],
["no selection reader", null],
])("the witness isn't consulted with %s", async (_name, resolvePermissionMode) => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list, resolvePermissionMode).checkReady(binding()))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it("a shell label stays a return to the shell", async () => {
expect(await adapter(mockTmux("zsh"), rows).checkReady(binding())).toMatchObject({ ready: false, code: "returned_to_shell" });
});
it("a claude label stays ready by name, with no process read", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux("claude"), list).checkReady(binding())).toEqual({ ready: true });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 legacy restore resume", () => {
const resumeAdapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[], extra = {}) =>
new ClaudeResumeAdapter(tmux, { listProcesses, pollMs: 1, maxWaitMs: 2, sleep: async () => {}, ...extra });
it("an unmanaged resume with a witnessed native version label is verified", async () => {
expect(await resumeAdapter(mockTmux(VERSION), rows).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: true });
});
it("an unwitnessed version label stays unverified", async () => {
const list = () => rows().map(r => ({ ...r, executablePath: undefined }));
expect(await resumeAdapter(mockTmux(VERSION), list).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: false, code: "attention_required" });
});
it("a managed resume keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
const claudeManagedLaunch = { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
assertCurrent: () => {}, configDir: "/fixture", executable: NATIVE_PATH }) } as unknown as ClaudeManagedLaunch;
const result = await resumeAdapter(mockTmux(VERSION), list, { claudeManagedLaunch })
.resume("dev-impl@rig", "claude_id", "tok", "/project", undefined, undefined, "auto", "n1");
expect(result).toMatchObject({ ok: false, code: "attention_required" });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 reconcile-session drift", () => {
async function reconcile(listProcesses: () => NativeProcessRow[]) {
const db = createFullTestDb();
const tmux = mockTmux(VERSION);
const setup = createTestApp(db, { tmux });
const deps = { db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, discoveryRepo: setup.discoveryRepo,
eventBus: setup.eventBus, tmuxAdapter: tmux, listProcesses };
const claims = new ClaimService(deps as ConstructorParameters<typeof ClaimService>[0]);
const rig = setup.rigRepo.createRig("drift-rig");
const node = setup.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/repo", podId: null });
setup.sessionRegistry.markDetached(setup.sessionRegistry.registerSession(node.id, "dev-impl@drift-rig").id);
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@drift-rig" });
const result = await claims.reconcileSession({ sessionName: "dev-impl@drift-rig" });
db.close();
if (!result.ok) throw new Error(result.message);
return result.result;
}
it("a witnessed native version label confirms the runtime; continuity stays unverified", async () => {
const result = await reconcile(rows);
expect(result.projectionDrift.join(" ")).not.toContain("runtime unverified");
expect(result.continuity).toBe("unverified");
});
it("an unwitnessed version label keeps the drift line", async () => {
const result = await reconcile(() => rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r));
expect(result.projectionDrift.join(" ")).toContain(`runtime unverified: pane command "${VERSION}" does not confirm runtime "claude-code"`);
});
});
describe("#86 recovery readiness (ContextMonitor.pollOnce) follows the seat's persisted selection", () => {
async function poll(opts: { selection?: string; label?: string; rows: () => NativeProcessRow[] }): Promise<string> {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db);
const sessions = new SessionRegistry(db);
const node = rigRepo.addNode(rigRepo.createRig("recovery-rig").id, "dev.impl", { runtime: "claude-code", cwd: "/project" });
const session = sessions.registerSession(node.id, "dev-impl@recovery-rig");
db.prepare("UPDATE sessions SET status = 'running', startup_status = 'attention_required' WHERE id = ?").run(session.id);
const permissions = new NativePermissionStore(db);
if (opts.selection) permissions.write(node.id, { runtime: "claude-code", mode: opts.selection }, "test", "managed seat");
const stateDir = mkdtempSync(join(tmpdir(), "label-witness-"));
const claude = new ClaudeCodeAdapter({ tmux: mockTmux(opts.label ?? VERSION), fsOps, listProcesses: opts.rows, sleep: async () => {},
resolvePermissionMode: (nodeId) => permissions.resolve(nodeId, "claude-code").permissionMode });
const monitor = new ContextMonitor(db, new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir }),
{ ensureContextCollector: () => {}, checkReady: (b) => claude.checkReady(b) });
try {
await monitor.pollOnce();
return (db.prepare("SELECT startup_status FROM sessions WHERE id = ?").get(session.id) as { startup_status: string }).startup_status;
} finally {
monitor.stop();
db.close();
rmSync(stateDir, { recursive: true, force: true });
}
}
it.each<[string, Parameters<typeof poll>[0], string]>([
["managed auto, a version label, the wrong conversation", { selection: "auto", rows: () => rows("claude --permission-mode auto --resume other --name seat") }, "attention_required"],
["managed auto, a version label, no OS path", { selection: "auto", rows: () => rows().map(r => ({ ...r, executablePath: undefined })) }, "attention_required"],
["managed auto, the ordinary claude label, as on main", { selection: "auto", label: "claude", rows }, "ready"],
["unmanaged, a witnessed version label", { rows }, "ready"],
])("%s", async (_name, opts, expected) => {
expect(await poll(opts)).toBe(expected);
});
});