Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"

This reverts commit 6c31f02706 on the 0.7.1
candidate line only, as 0.7.0's line did (5380ca10). Main keeps #1345.

One conflict: the native-process-lineage and managed-blocks imports in
claude-code-adapter.ts. Resolved by dropping only #1345's
witnessedClaudeVersionLabel and VersionLabelWitnessWait, and keeping #1840's
isPerSeatRoleGuidance and perSeatRoleSkipWarning.
This commit is contained in:
dev-driver committed 2026-10-24 14:42:50 +00:00
1 parent 7923e13e35
commit ef07562d02
9 files changed
+31 -419

No files matched your search

@@ -15,7 +15,7 @@ import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-plann
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { resumeProbeEvidence } from "../domain/native-resume-probe-evidence.js";
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, 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";
@@ -63,7 +63,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private nodeExecutable: string;
private autoDriveProviderPrompts: boolean;
private listProcesses?: NativeProcessLister;
private resolvePermissionMode?: (nodeId: string) => string | undefined;
private readinessTimeoutMs?: number;
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; freshInPlace?: boolean; executable?: string; scriptLaunch?: boolean; fingerprint?: string; process?: ClaudeLaunchedProcess }>();
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -85,9 +84,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
nodeExecutable?: string;
autoDriveProviderPrompts?: boolean;
listProcesses?: NativeProcessLister;
/** #86 — the seat's persisted managed permission mode (NativePermissionStore.resolve), undefined when
* unmanaged; throws when it can't be read. Absent → every seat counts as managed for the version-label witness. */
resolvePermissionMode?: (nodeId: string) => string | undefined;
/** Bound on a slow resume's wait, from the launch (default: runtime.readiness_timeout_seconds). */
readinessTimeoutMs?: number;
claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -110,7 +106,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.nodeExecutable = deps.nodeExecutable ?? process.execPath;
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
this.listProcesses = deps.listProcesses;
this.resolvePermissionMode = deps.resolvePermissionMode;
this.readinessTimeoutMs = deps.readinessTimeoutMs;
this.claudeManagedLaunch = deps.claudeManagedLaunch;
this.activityRelayPath = deps.activityRelayPath ?? null;
@@ -420,13 +415,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// -- Private helpers --
/** #86: only a launch binding carries permissionMode, so the seat's persisted selection decides for every other
* caller. Unmanaged needs both to say so; a missing or unreadable selection counts as managed. */
private isUnmanaged(binding: NodeBinding): boolean {
if (binding.permissionMode !== undefined || !this.resolvePermissionMode) return false;
try { return this.resolvePermissionMode(binding.nodeId) === undefined; } catch { return false; }
}
/** A fresh in-place launch keeps the retiree's scrollback. Qualify a history-based ready result
* with the current frame, only for the exact launch that requested it. Unavailable observations
* retain the old admission (including its ambiguity); visible gates and conflicting identity do not. */
@@ -465,16 +453,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return withIdentity.status === "resumed" && occupant !== "absent" ? historyProbe : current;
}
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions, witnessWait?: VersionLabelWitnessWait) {
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions) {
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 seat keeps its exact proof below.
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (probe.status !== "resumed" && asClaude.status === "resumed" && this.isUnmanaged(binding) && binding.tmuxSession
&& await witnessedClaudeVersionLabel(paneCommand, { target: binding.tmuxSession, tmux: this.tmux, listProcesses: this.listProcesses }, witnessWait)) {
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.
@@ -594,13 +575,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const checkEvery = 5;
let attempts = windowAttempts;
let slowStart = false;
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
const paneContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent, undefined, witnessWait);
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
if (probe.code === "no_conversation_found") {
return {
@@ -687,7 +666,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSession);
const finalContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent, undefined, witnessWait);
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent);
if (finalProbe.status === "resumed") {
return { ok: true };
+13 -18
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, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { verifyClaudePaneProcess, verifyPaneIdleShell, 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";
@@ -114,7 +114,7 @@ export class ClaudeResumeAdapter {
return { ok: false, code: "resume_failed", message: keyResult.message };
}
const result = await this.verifyResume(tmuxSessionName, resumeToken!, !managed).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)}`,
@@ -122,29 +122,20 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch } : result;
}
/** #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, witnessWait: VersionLabelWitnessWait) {
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 }, witnessWait)) return asClaude;
return probe;
}
private async verifyResume(tmuxSessionName: string, resumeToken: string, unmanaged: boolean): 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;
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const probe = await this.assessProbe(tmuxSessionName, paneCommand, paneContent, unmanaged, witnessWait);
const probe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand,
paneContent,
});
if (probe.code === "no_conversation_found") {
return {
@@ -179,7 +170,11 @@ export class ClaudeResumeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const finalContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const finalProbe = await this.assessProbe(tmuxSessionName, finalCommand, finalContent, unmanaged, witnessWait);
const finalProbe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: finalCommand,
paneContent: finalContent,
});
if (finalProbe.code === "no_conversation_found") {
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
+6 -8
View File
@@ -10,7 +10,7 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import { startTmuxTranscriptCapture } from "./transcript-capture.js";
import { deriveResumeToken, type ResumeTokenCaptureDeps } from "./resume-token-capture.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, witnessedClaudeVersionLabel, type NativeProcessLister } from "./native-process-lineage.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
import {
observeSolePane,
paneObservationVerdict,
@@ -113,7 +113,7 @@ interface ClaimServiceDeps {
};
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is skipped. */
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
/** Process census for the runtime proof in `reconcileSession` and #86's native version-label witness; defaults to the live process table. */
/** Process census for the runtime proof in `reconcileSession`; defaults to the live process table. */
listProcesses?: NativeProcessLister;
resumeTokenCapturer?: {
captureCodexThreadId(sessionName: string): Promise<string | undefined>;
@@ -575,10 +575,11 @@ 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(paneTarget);
paneCommand = await this.tmuxAdapter.getPaneCommand(
paneObservation.ok ? paneObservation.pane : sessionName,
);
} catch { paneCommand = null; }
if (nodeRow.runtime) {
const expectation: Record<string, string[]> = {
@@ -590,10 +591,7 @@ export class ClaimService {
if (!paneCommand) {
projectionDrift.push(`runtime unverified: pane command unreadable; node declares runtime "${nodeRow.runtime}"`);
} 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 }))
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneTarget)) {
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneObservation.ok ? paneObservation.pane : sessionName)) {
// A script-launched runtime reads as its launcher (`sh`); only a pane whose process tree shows no such runtime is drift.
projectionDrift.push(`runtime unverified: pane command "${paneCommand}" does not confirm runtime "${nodeRow.runtime}"`);
}
@@ -18,16 +18,9 @@ function run(argv) {
}));
}`;
/** execFile's own timeout kill (Node sets killed with the kill signal); a maxBuffer overflow or a failed run isn't one. */
function isTimeoutKill(error: unknown): boolean {
const e = error as { killed?: unknown; signal?: unknown } | null;
return e?.killed === true && e.signal === "SIGTERM";
}
/** Optional OS executable witnesses. Unavailable, exited and over-budget PIDs
* have no witness; this never substitutes argv or a version string for a path.
* `onTimeout` hears only an actual read timeout, never any other missing path. */
export async function readNativeExecutablePaths(requested: number[], opts: { onTimeout?: () => void } = {}): Promise<Map<number, string>> {
* have no witness; this never substitutes argv or a version string for a path. */
export async function readNativeExecutablePaths(requested: number[]): Promise<Map<number, string>> {
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, 1024);
const result = new Map<number, string>();
if (pids.length === 0) return result;
@@ -49,9 +42,6 @@ export async function readNativeExecutablePaths(requested: number[], opts: { onT
if (typeof path === "string" && path.startsWith("/") && !path.includes("\0")
&& !path.split("/").some(part => part === "." || part === "..")) result.set(entry[0], path);
}
} catch (error) {
// A missing OS witness is unknown, not positive identity.
if (isTimeoutKill(error)) opts.onTimeout?.();
}
} catch { /* A missing OS witness is unknown, not positive identity. */ }
return result;
}
@@ -485,28 +485,22 @@ function mayBeClaudeRuntime(row: NativeProcessRow): boolean {
/** The same OS observation serves menu input, restore proof and periodic identity.
* Older callers may carry only pid/ppid/command; that is insufficient positive Codex proof. */
export async function listNativeProcesses(opts: NativeProcessListOptions = {}): Promise<NativeProcessRow[]> {
export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean } = {}): Promise<NativeProcessRow[]> {
try {
// Copies: the table is shared with concurrent readers, and executable paths are added below.
const rows: NativeProcessRow[] = (await runAsyncSite("codex.runtime.list_processes", readProcessTable)).map((row) => ({ ...row }));
// `claudeLaunchPaths`: the caller is proving a launch through a `#!` script, which needs the OS path of the
// foreground path-named Claude too. Every other listing skips that read (on macOS it starts a helper process).
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (opts.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (options.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
if (candidates.length > 0) {
const pids = candidates.map(row => row.pid);
const onTimeout = opts?.onPathReadTimeout;
const paths = await (onTimeout ? readNativeExecutablePaths(pids, { onTimeout }) : readNativeExecutablePaths(pids));
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
for (const row of candidates) row.executablePath = paths.get(row.pid);
}
return rows;
} catch { return []; }
}
/** `claudeLaunchPaths`: also read the OS path of a foreground path-named Claude (a `#!` script launch).
* `onPathReadTimeout` (#86): told when the OS path read actually timed out. */
export interface NativeProcessListOptions { claudeLaunchPaths?: boolean; onPathReadTimeout?: () => void }
export type NativeProcessLister = (opts?: NativeProcessListOptions) => NativeProcessRow[] | Promise<NativeProcessRow[]>;
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
/** One OS process, by pid and `ps` start time: a reused pid has another start time. */
export interface ClaudeLaunchedProcess { pid: number; startedAt: string }
@@ -779,30 +773,6 @@ export async function verifyClaudePaneRuntime(input: Parameters<typeof observeCl
return second?.fingerprint === first.fingerprint ? second : null;
}
const NATIVE_VERSION_LABEL = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
/** One wait's record that the optional path witness timed out, so the rest of that wait skips it. */
export interface VersionLabelWitnessWait { pathReadTimedOut: boolean }
/** #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.
* Within one `wait`, once a path read actually times out, the witness isn't tried again; a new wait starts fresh. */
export async function witnessedClaudeVersionLabel(paneCommand: string | null,
input: Omit<Parameters<typeof observeClaudePaneRuntime>[0], "selectedExecutable">, wait?: VersionLabelWitnessWait): Promise<boolean> {
const label = paneCommand?.trim() ?? "";
if (!NATIVE_VERSION_LABEL.test(label) || wait?.pathReadTimedOut) return false;
const lister = input.listProcesses ?? listNativeProcesses;
const onPathReadTimeout = () => { if (wait) wait.pathReadTimedOut = true; };
const native = await verifyClaudePaneRuntime({ ...input, listProcesses: () => lister({ onPathReadTimeout }) });
// 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> {
+1 -2
View File
@@ -24,7 +24,6 @@ import { migrate } from "./db/migrate.js";
import { ALL_MIGRATIONS } from "./db/all-migrations.js";
import { RigRepository } from "./domain/rig-repository.js";
import { SessionRegistry } from "./domain/session-registry.js";
import { NativePermissionStore } from "./domain/native-permission-store.js";
import { deriveCanonicalSessionName, isHumanSeatSessionRef, parseSessionName } from "./domain/session-name.js";
import { resolveExternal } from "./domain/gateway/external-admission.js";
import { makeTopologyDestinationCheck } from "./domain/queue-destination-check.js";
@@ -866,7 +865,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, resolvePermissionMode: (nodeId: string) => new NativePermissionStore(db).resolve(nodeId, "claude-code").permissionMode, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
@@ -1,293 +0,0 @@
// #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 { NativeProcessLister, 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.
// Also used as a process lister, which may be handed list options: only a string names a command.
function rows(command?: unknown): NativeProcessRow[] {
if (typeof command !== "string") command = "claude --permission-mode acceptEdits --resume tok --name seat";
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: command as string, 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 over an idle shell stays a return to the shell", async () => {
// Claude has exited: the pane's shell leads the foreground group again, with nothing else in it.
const shellAlone = () => [{ ...rows()[0], tpgid: 10 }];
expect(await adapter(mockTmux("zsh"), shellAlone).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);
});
});
describe("#86 an actual path-read timeout ends the witness for the rest of one wait", () => {
const unwitnessed = () => rows().map(r => ({ ...r, executablePath: undefined }));
// As listNativeProcesses does when the OS read times out: the path stays unset and the caller is told.
const timedOut: NativeProcessLister = (opts) => { opts?.onPathReadTimeout?.(); return unwitnessed(); };
const launch = (claude: ClaudeCodeAdapter) => claude.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
// The witness's own reads carry its timeout listener; main's process checks call the same lister without one.
const witnessReads = (list: ReturnType<typeof vi.fn>) =>
list.mock.calls.filter(([opts]) => typeof opts === "object" && opts !== null && "onPathReadTimeout" in opts).length;
// Main's result for the same process evidence: a managed seat never consults the witness.
const mainResult = () => launch(adapter(mockTmux(VERSION), vi.fn(unwitnessed), () => "auto"));
it.each<[string, () => NativeProcessLister, number]>([
["a first-sample timeout", () => vi.fn(timedOut), 1],
["a second-sample timeout", () => vi.fn<NativeProcessLister>().mockImplementationOnce(() => rows()).mockImplementation(timedOut), 2],
])("the launch wait stops reading after %s and ends with main's result", async (_name, makeList, reads) => {
const list = makeList();
const result = await launch(adapter(mockTmux(VERSION), list));
expect(result).toEqual(await mainResult());
expect(result.ok).toBe(false);
expect(witnessReads(list as ReturnType<typeof vi.fn>)).toBe(reads);
});
it("a missing path without a timeout isn't one: every observation still tries", async () => {
const list = vi.fn(unwitnessed);
const tmux = mockTmux(VERSION);
expect(await launch(adapter(tmux, list))).toMatchObject({ ok: false });
const observations = vi.mocked(tmux.capturePaneContent).mock.calls.length;
expect(observations).toBeGreaterThan(16);
expect(witnessReads(list)).toBe(observations);
});
it("a later, independent launch wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const claude = adapter(mockTmux(VERSION), (opts) => read(opts));
expect(await launch(claude)).toMatchObject({ ok: false });
read = () => rows();
expect(await launch(claude)).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("the legacy restore wait reads once after a timeout, and its next wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const list = vi.fn<NativeProcessLister>((opts) => read(opts));
const resume = new ClaudeResumeAdapter(mockTmux(VERSION), { listProcesses: list, sleep: async () => {} });
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: false, code: "attention_required" });
expect(list).toHaveBeenCalledTimes(1);
read = () => rows();
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: true });
});
});
@@ -34,24 +34,6 @@ describe("OS executable path witness", () => {
else execute.mockResolvedValue({ stdout: mode === "malformed" ? "not json" : JSON.stringify([[12, "relative"], [13, "/a/../b"], [14, "/a\0b"], [999, "/a"]]) });
expect((await readNativeExecutablePaths([12, 13, 14])).size).toBe(0);
});
it("tells a caller only about an actual read timeout (#86)", async () => {
setPlatform("darwin");
const onTimeout = vi.fn();
// Node's own timeout kill: killed by Node with the kill signal.
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM", code: null }));
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
for (const error of [
Object.assign(new Error("denied"), { killed: false, signal: null, code: 1 }),
Object.assign(new Error("too much output"), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }),
]) {
execute.mockRejectedValueOnce(error);
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
}
execute.mockResolvedValueOnce({ stdout: "not json" });
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
});
it("does no work for an empty request or unsupported platform, and caps batch size", async () => {
setPlatform("darwin");
expect((await readNativeExecutablePaths([])).size).toBe(0);
@@ -53,12 +53,4 @@ describe.each(Object.entries(SOURCES))("from %s", (source, serve) => {
expect(paths).toHaveBeenCalledExactlyOnceWith([20, 21]);
expect(rows.find(r => r.pid === 20)?.executablePath).toBe("/nix/store/x-claude-code-2.1.280/bin/.claude-unwrapped");
});
it("forwards a caller's path-read timeout listener only when one is given (#86)", async () => {
serve([row(15, "2.1.289", "claude --resume token")]);
const onPathReadTimeout = vi.fn();
paths.mockImplementation(async (_pids: number[], opts?: { onTimeout?: () => void }) => { opts?.onTimeout?.(); return new Map(); });
await listNativeProcesses({ onPathReadTimeout });
expect(paths).toHaveBeenCalledExactlyOnceWith([15], { onTimeout: onPathReadTimeout });
expect(onPathReadTimeout).toHaveBeenCalledOnce();
});
});