fix(daemon): prove a Claude launched through the native installer's symlink by its OS path (#2371)

A Claude seat's rebuild handover could stop at "Waiting to confirm the new Claude process in this pane" although the new Claude was running, when its argv[0] was spelled through the native installer's symlink (`~/.local/bin/claude`) rather than the versioned file it points to. A managed launch freezes the realpath, and readiness compared a path-shaped argv[0] only with that frozen path; the OS executable path was read only for other row shapes.

Now, when the exact-path match finds nothing for a launch that froze an executable, readiness admits a foreground process whose argv[0] is an absolute path named `claude` and whose OS executable path (on Linux, `/proc/<pid>/exe`) is exactly the frozen file, absolute with no `.` or `..` segments. The process must still name this launch's own conversation, be the only candidate, look the same in two samples under the same pane, and its first match is pinned. Only such a process has its OS path read: an exact launch, run with exactly the frozen path, is proved without it, as before, so a slow path read can't change its pinned fingerprint. Another install's binary, an unreadable or relative OS path, another conversation and a process outside the foreground group are still refused. The `#!` wrapper rule from #2354 is unchanged.

Runtime +25/-8, tests +64/-1. Refs #2341.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-01 19:05:19 -08:00
1 parent 82438318f5
commit f3f8319d1c
4 files changed
+89 -9

No files matched your search

@@ -645,7 +645,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const launch = this.autoLaunches.get(binding.nodeId);
if (!binding.tmuxPane || !launch) return looksReady ? "another" : "unknown";
let rows: Awaited<ReturnType<NativeProcessLister>>;
try { rows = await (this.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: launch.scriptLaunch === true })))(); } catch { return "unknown"; }
try { rows = await (this.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: launch.scriptLaunch === true, frozenExecutable: launch.executable })))(); } catch { return "unknown"; }
if (looksReady) return "another";
const observation = { target: binding.tmuxPane, tmux: this.tmux, listProcesses: () => rows, selectedExecutable: launch.executable,
scriptLaunch: launch.scriptLaunch };
@@ -528,13 +528,17 @@ 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(options: { claudeLaunchPaths?: boolean } = {}): Promise<NativeProcessRow[]> {
export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean; frozenExecutable?: string } = {}): 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) || (options.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
// foreground path-named Claude too. `frozenExecutable`: the caller is proving a launch that froze that file (the
// installer's symlink resolved to its versioned file), which needs the path only of a path-named Claude whose
// argv[0] isn't already the file; an exact launch is proved without it, and a helper timeout can't change its
// fingerprint. Every other listing skips that read (on macOS it starts a helper process).
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (mayBeClaudeAfterScript(row) && (options.claudeLaunchPaths === true
|| (!!options.frozenExecutable && tokens(row.command)[0] !== options.frozenExecutable))));
if (candidates.length > 0) {
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
for (const row of candidates) row.executablePath = paths.get(row.pid);
@@ -570,7 +574,7 @@ function rotationProcess(row: NativeProcessRow, rotation: ClaudeResumeRotation |
return !!rotation && row.pid === rotation.process.pid && !!row.startedAt && row.startedAt === rotation.process.startedAt;
}
/** `afterScript`: found by the script-launch rule, not the exact-path match; its first proof must be pinned. */
/** `afterScript`: found by the script-launch or symlink rule, not the exact-path match; its first proof must be pinned. */
export type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string; afterScript?: true };
export type CodexProcessObservation = NativeProcessObservation;
@@ -581,9 +585,22 @@ function runsFrozenScript(row: NativeProcessRow, selectedExecutable: string): bo
return first === selectedExecutable || (first?.startsWith("-") === true && second === selectedExecutable);
}
/** A process whose argv[0] is an absolute path named `claude` and whose OS executable path is exactly the frozen file. */
function runsFrozenFile(row: NativeProcessRow, selectedExecutable: string): boolean {
const argv0 = tokens(row.command)[0] ?? "";
const path = row.executablePath;
return !!path && path === selectedExecutable && path.startsWith("/") && !path.split("/").some(part => part === "." || part === "..")
&& argv0.startsWith("/") && executableName(argv0) === "claude";
}
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string, scriptLaunch?: boolean): NativeProcessObservation[] {
const exact = candidatesMatching(rows, panePid, runtime, row => claudeProcess(row, selectedExecutable));
if (exact.length > 0 || runtime !== "claude-code" || !selectedExecutable || scriptLaunch !== true) return exact;
if (exact.length > 0 || runtime !== "claude-code" || !selectedExecutable) return exact;
// #2341: the native installer's `claude` is a symlink to `versions/<ver>`, and the launch froze its realpath. A Claude
// spelling argv[0] through the link is that launch when the OS says it runs exactly the frozen file.
const linked = candidatesMatching(rows, panePid, runtime, row => runsFrozenFile(row, selectedExecutable));
if (linked.length > 0) return linked.map(candidate => ({ ...candidate, afterScript: true as const }));
if (scriptLaunch !== true) return exact;
const named = candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript);
if (named.length > 0) return named.map(candidate => ({ ...candidate, afterScript: true as const }));
// #2341: a wrapper that starts Claude without `exec` keeps running the frozen script as the foreground parent, and
@@ -738,7 +755,7 @@ async function observeNativePaneProcess(input: {
try {
const pid = await input.tmux.getPanePid(input.target);
if (!pid) return null;
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true })))();
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true, frozenExecutable: input.selectedExecutable })))();
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable,
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch, input.requireArgv);
} catch { return null; }
@@ -850,7 +867,7 @@ export async function observeClaudePaneRuntime(input: Omit<Parameters<typeof obs
try {
const pid = await input.tmux.getPanePid(input.target);
if (!pid) return null;
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true })))();
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true, frozenExecutable: input.selectedExecutable })))();
const candidates = nativeProcessCandidates(rows, pid, "claude-code", input.selectedExecutable, input.scriptLaunch);
return candidates.length === 1 ? candidates[0]! : null;
} catch { return null; }
@@ -149,3 +149,43 @@ it("refuses a non-exec child whose process changes between the two samples", asy
let reads = 0;
expect(await verifyClaudePaneProcess(nonExecInput(() => nonExec(reads++ === 0 ? {} : { pid: 24 })))).toBeNull();
});
// #2341, the reported frame: the native installer's `~/.local/bin/claude` is a symlink to `versions/<ver>`, and a managed
// launch freezes its realpath. The live Claude can still spell argv[0] through the link, with the OS name `claude`; the
// OS's own executable path says it runs exactly the frozen file.
const link = "/home/user/.local/bin/claude";
const installed = "/home/user/.local/share/claude/versions/2.1.316";
function throughLink(patch: Partial<NativeProcessRow> = {}): NativeProcessRow[] {
return [
{ pid: 30, ppid: 1, pgid: 30, tpgid: 32, executableName: "fish", command: "-fish", startedAt },
{ pid: 31, ppid: 30, pgid: 31, tpgid: 32, executableName: "fish", command: "fish", startedAt },
{ pid: 32, ppid: 31, pgid: 32, tpgid: 32, executableName: "claude", executablePath: installed, command: `${link} ${launched}`, startedAt, ...patch },
];
}
const linkInput = (list: () => NativeProcessRow[], extra: Record<string, unknown> = {}) =>
input(list, { tmux: { getPanePid: async () => 30 }, selectedExecutable: installed, scriptLaunch: false, ...extra });
it("proves a Claude spelled through the installer's symlink when the OS says it runs the frozen file", async () => {
expect((await verifyClaudePaneProcess(linkInput(() => throughLink())))?.process.pid).toBe(32);
expect((await verifyClaudePaneRuntime(linkInput(() => throughLink())))?.process.pid).toBe(32);
});
const linkRefused: [string, Partial<NativeProcessRow> | ((r: NativeProcessRow[]) => NativeProcessRow[])][] = [
["another install's binary", { executablePath: "/home/user/.local/share/claude/versions/2.1.315" }],
["no readable OS path", { executablePath: undefined }],
["a relative OS path", { executablePath: "home/user/.local/share/claude/versions/2.1.316" }],
["an OS path with a dot segment", { executablePath: "/home/user/.local/share/claude/versions/../versions/2.1.316" }],
["another conversation", { command: `${link} --permission-mode auto --session-id 00000000-0000-4000-8000-000000000002 --name seat@rig` }],
["an argv[0] that isn't Claude", { command: `/usr/bin/node ${launched}` }],
["a process outside the foreground group", { pgid: 88 }],
["a second candidate", r => [...r, { ...r[2]!, pid: 33 }]],
];
it.each(linkRefused)("still refuses a symlink-spelled process with %s", async (_name, patch) => {
const list = () => typeof patch === "function" ? patch(throughLink()) : throughLink(patch);
expect(await verifyClaudePaneProcess(linkInput(list))).toBeNull();
});
it("refuses a symlink-spelled process that changes between the two samples", async () => {
let reads = 0;
expect(await verifyClaudePaneProcess(linkInput(() => throughLink(reads++ === 0 ? {} : { pid: 34 })))).toBeNull();
});
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { execute, paths } = vi.hoisted(() => ({ execute: vi.fn(), paths: vi.fn() }));
vi.mock("node:child_process", () => ({ execFile: Object.assign(vi.fn(), { [Symbol.for("nodejs.util.promisify.custom")]: execute }) }));
vi.mock("../src/domain/native-process-executable.js", () => ({ readNativeExecutablePaths: paths }));
import { listNativeProcesses } from "../src/domain/native-process-lineage.js";
import { listNativeProcesses, observeClaudePaneProcess, verifyClaudePaneProcess } from "../src/domain/native-process-lineage.js";
import { restoreProc, serveFakeProc, type FakeProcess } from "./helpers/fake-proc.js";
const row = (pid: number, name: string, command: string): FakeProcess => ({ pid, ppid: 10, pgid: 11, tpgid: 11, name, startedAt: "Sun Oct 4 15:21:03 2026", command });
// The process table's two sources: ps off Linux, /proc on Linux.
@@ -45,6 +45,29 @@ describe.each(Object.entries(SOURCES))("from %s", (source, serve) => {
expect(rows.find(r => r.pid === 30)?.executablePath).toBe(installed);
expect(rows.find(r => r.pid === 31)?.executablePath).toBeUndefined();
});
// #2341: a launch that froze a native file is proved by argv[0] when argv[0] is that file, as before; only a Claude
// spelling argv[0] another way (the installer's symlink) needs the OS path. The path helper can time out (on macOS
// it starts a subprocess), so an exact launch must not depend on it.
const token = "00000000-0000-4000-8000-000000002371";
const observation = (frozen: string) => ({ target: "%1", tmux: { getPanePid: async () => 10 }, expectedToken: token,
selectedExecutable: frozen, scriptLaunch: false });
it("proves an exact launch without the OS path, so a helper that times out and then recovers keeps it admitted", async () => {
const native = "/opt/native/bin/claude";
serve([row(10, "zsh", "-zsh"), row(11, "claude", `${native} --session-id ${token}`)]);
// The first helper times out (no witness), and every later one succeeds.
paths.mockResolvedValueOnce(new Map()).mockResolvedValue(new Map([[11, native]]));
const pinned = await observeClaudePaneProcess(observation(native));
expect(pinned?.process.pid).toBe(11);
expect((await verifyClaudePaneProcess(observation(native)))?.fingerprint).toBe(pinned?.fingerprint);
expect(paths).not.toHaveBeenCalled();
});
it("still reads the OS path of a Claude spelled through the installer's symlink when a launch froze a file", async () => {
const installed = "/x/.local/share/claude/versions/2.1.316";
serve([row(10, "zsh", "-zsh"), row(11, "claude", `/x/.local/bin/claude --session-id ${token}`)]);
paths.mockResolvedValue(new Map([[11, installed]]));
expect((await verifyClaudePaneProcess(observation(installed)))?.process.executablePath).toBe(installed);
expect(paths).toHaveBeenCalledWith([11]);
});
it("reads the OS path for a Nix-wrapped Claude whose ucomm is the truncated `.claude-unwrapped`", async () => {
serve([row(20, ".claude-unwrapp", "claude --session-id token"), row(21, ".claude-wrapped", "/nix/store/x-claude-code-2.1.280/bin/claude"),
row(22, ".claude-unwrapp", "ugrep -G --hidden"), row(23, ".node-unwrapped", "claude")]);