Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

github-router

Package Overview
Dependencies
Maintainers
1
Versions
52
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

github-router - npm Package Compare versions

Comparing version
0.3.41
to
0.3.42
+310
dist/lifecycle-DU0UI2t5.js
import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-lwEqM5-i.js";
import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
//#region src/lib/worker-agent/lifecycle.ts
/**
* Same regex worktree.ts uses for its per-call age sweep — kept in
* sync intentionally. `<pid>-<uuid>-<8hex>` strictly.
*/
const WORKTREE_DIR_NAME_RE = /^(\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/;
/**
* Cap on the ledger: how many repos we remember across boots, and how
* old an entry may be before it's pruned. Both are belt-and-suspenders
* — the per-call age sweep is the primary guard against accumulation
* inside any single repo.
*/
const LEDGER_MAX_ENTRIES = 100;
const LEDGER_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
/**
* Set-like in-memory registry of worktrees this proxy created. Engine
* passes it to `createWorktree` so per-call cleanup deletes the entry
* on success; the signal handlers walk what's left at shutdown.
*
* Not a bare `Set` because we want to expose only the operations we
* actually use, and we want a stable testable surface.
*/
var WorktreeRegistry = class {
entries = /* @__PURE__ */ new Set();
add(entry) {
this.entries.add(entry);
}
delete(entry) {
this.entries.delete(entry);
}
has(entry) {
return this.entries.has(entry);
}
values() {
return this.entries.values();
}
get size() {
return this.entries.size;
}
clear() {
this.entries.clear();
}
};
let _instanceUuid = null;
/**
* Stable UUID4 generated once per proxy process. Used in worktree
* dir/branch names so the boot sweep can reliably distinguish "this
* proxy's still-live worktrees" from "stranded dirs from a prior
* proxy that happens to have a recycled PID" — Docker PID-1 across
* container restarts is the classic case (peer-review HIGH finding).
*/
function getInstanceUuid() {
if (_instanceUuid === null) _instanceUuid = randomUUID();
return _instanceUuid;
}
let _registered = false;
let _activeRegistry = null;
let _exitHandler = null;
let _sigintHandler = null;
let _sigtermHandler = null;
/**
* Synchronous cleanup of every registry entry. Best-effort:
* `execFileSync` failures are swallowed (the dir may have been
* removed already, or git may not be on PATH any more in some
* environments). After a successful removal we drop the entry from
* the registry so a second call is a true no-op.
*
* Synchronous on purpose — exit handlers can't reliably await async
* work; the process would die before the promise settled.
*/
function sweepRegistry() {
if (!_activeRegistry) return;
const snapshot = [..._activeRegistry.values()];
for (const entry of snapshot) {
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"worktree",
"remove",
"--force",
entry.dir
], {
stdio: "ignore",
timeout: 1e4,
windowsHide: true
});
} catch {}
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"branch",
"-D",
entry.branch
], {
stdio: "ignore",
timeout: 5e3,
windowsHide: true
});
} catch {}
_activeRegistry.delete(entry);
}
}
/**
* Windows ConPTY / node-pty signal behavior:
*
* When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes
* the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the
* process group. Node.js translates this into SIGINT (NOT SIGTERM). The
* process has a ~5-second window before forced termination.
*
* Implication: the SIGTERM handler below may NEVER fire in node-pty
* environments. This is by design — the three-layer cleanup architecture
* ensures coverage:
* 1. Per-call cleanup (engine.ts finally block) — happy path
* 2. SIGINT handler (this file) — ConPTY close, Ctrl+C
* 3. `exit` handler (this file) — unconditional, fires on any exit
* 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery
*
* Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.
*/
/**
* Wire up SIGINT/SIGTERM/exit handlers that walk the registry and
* remove every entry. Idempotent: subsequent calls swap the registry
* pointer but do NOT register additional process listeners (otherwise
* we'd leak listeners on every `runWorkerAgent`).
*
* Signal handlers re-raise the signal after sweeping. Naively running
* the sweep on SIGINT/SIGTERM and returning would *suppress* the
* signal: Node defaults to terminating the process on these, but only
* if no user listener is attached. Once we attach a listener, the
* default action is cancelled and the process keeps running — which
* means Ctrl-C would clean worktrees but not actually exit, leaving
* orphan processes in dev. The `process.kill(pid, sig)` re-raise
* after removing our own listener restores the default behaviour
* (the second delivery now hits an empty listener list, so Node
* terminates with the conventional `128 + signum` exit code).
*/
function registerExitHandlers(registry) {
_activeRegistry = registry;
if (_registered) return;
_registered = true;
_exitHandler = () => sweepRegistry();
_sigintHandler = () => {
sweepRegistry();
if (_sigintHandler) process.off("SIGINT", _sigintHandler);
process.kill(process.pid, "SIGINT");
};
_sigtermHandler = () => {
sweepRegistry();
if (_sigtermHandler) process.off("SIGTERM", _sigtermHandler);
process.kill(process.pid, "SIGTERM");
};
process.on("SIGINT", _sigintHandler);
process.on("SIGTERM", _sigtermHandler);
process.on("exit", _exitHandler);
}
function ledgerPath() {
return path.join(PATHS.APP_DIR, "worker-repos.json");
}
async function readLedger() {
let raw;
try {
raw = await fs.readFile(ledgerPath(), "utf8");
} catch (err) {
if (err.code === "ENOENT") return { entries: [] };
return { entries: [] };
}
try {
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
const cleaned = [];
for (const e of parsed.entries) if (e && typeof e === "object" && typeof e.repoRoot === "string" && typeof e.lastSeenMs === "number") cleaned.push({
repoRoot: e.repoRoot,
lastSeenMs: e.lastSeenMs
});
return { entries: cleaned };
} catch {
return { entries: [] };
}
}
/**
* Per-process serializer for ledger writes. Multiple concurrent
* `recordWorkerRepo` calls (legitimate: several workers may start at
* once) would otherwise race read-modify-write on the JSON file. Each
* call chains onto the previous so the on-disk sequence is
* deterministic from this process's perspective.
*
* Cross-process safety is provided by the atomic temp+rename below,
* which makes the final state of the file always be a well-formed
* full snapshot from ONE writer — never a partial write or
* interleaved JSON.
*/
let _ledgerChain = Promise.resolve();
/**
* Append `repoRoot` to the ledger (or update its `lastSeenMs`).
* Atomic temp+rename per peer review.
*/
function recordWorkerRepo(repoRoot) {
const next = _ledgerChain.then(async () => {
await fs.mkdir(PATHS.APP_DIR, { recursive: true });
const filtered = (await readLedger()).entries.filter((e) => e.repoRoot !== repoRoot);
filtered.push({
repoRoot,
lastSeenMs: Date.now()
});
const now = Date.now();
const ledger = { entries: filtered.filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS).slice(-LEDGER_MAX_ENTRIES) };
const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
try {
await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2));
await fs.rename(tmp, ledgerPath());
} catch (err) {
await fs.unlink(tmp).catch(() => {});
throw err;
}
});
_ledgerChain = next.catch(() => void 0);
return next;
}
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err.code === "EPERM") return true;
return false;
}
}
/**
* Boot-time sweep. For every repo we recorded in the ledger,
* enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional
* location — for repos already inside a worktree, the actual
* `git-common-dir` may differ, in which case we'll miss this batch
* and the per-call age sweep will catch them within 7 days) and
* remove dirs that aren't owned by THIS proxy.
*
* Ownership rule: dir is "ours" iff its embedded PID is alive AND
* its embedded UUID equals `getInstanceUuid()`. Either condition
* failing → remove.
*/
async function sweepStaleWorktreesAtBoot() {
const ledger = await readLedger();
if (ledger.entries.length === 0) return;
const currentUuid = getInstanceUuid();
for (const entry of ledger.entries) {
const parent = path.join(entry.repoRoot, ".git", "worker-worktrees");
let names;
try {
names = await fs.readdir(parent);
} catch {
continue;
}
for (const name of names) {
const m = WORKTREE_DIR_NAME_RE.exec(name);
if (!m) continue;
const pid = Number.parseInt(m[1], 10);
const uuid = m[2];
if (isPidAlive(pid) && uuid === currentUuid) continue;
const fullDir = path.join(parent, name);
const branch = `worker/${pid}-${uuid}-${m[3]}`;
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"worktree",
"remove",
"--force",
fullDir
], {
stdio: "ignore",
timeout: 1e4,
windowsHide: true
});
} catch {}
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"branch",
"-D",
branch
], {
stdio: "ignore",
timeout: 5e3,
windowsHide: true
});
} catch {}
try {
await fs.rm(fullDir, {
recursive: true,
force: true
});
} catch {}
}
}
}
//#endregion
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
//# sourceMappingURL=lifecycle-DU0UI2t5.js.map
{"version":3,"file":"lifecycle-DU0UI2t5.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
import "./paths-lwEqM5-i.js";
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-DU0UI2t5.js";
export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
import consola from "consola";
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
//#region src/lib/paths.ts
function appDir() {
return path.join(os.homedir(), ".local", "share", "github-router");
}
const PATHS = {
get APP_DIR() {
return appDir();
},
get GITHUB_TOKEN_PATH() {
return path.join(appDir(), "github_token");
},
get ERROR_LOG_PATH() {
return path.join(appDir(), "error.log");
},
get CODEX_HOME() {
return path.join(appDir(), "codex-isolated");
},
get CLAUDE_RUNTIME_DIR() {
return path.join(appDir(), "runtime");
},
get CLAUDE_CONFIG_DIR() {
return path.join(appDir(), "claude-config", claudeConfigDirSuffix());
}
};
/**
* Per-launch suffix for `PATHS.CLAUDE_CONFIG_DIR`. Lazily generated on
* first access and cached for the lifetime of the process so every
* caller (env-var injection in `getClaudeCodeEnvVars`,
* `ensureClaudeConfigMirror` provisioning, peer-agent `.md` writes
* under `<dir>/agents/`, the shutdown cleanup) resolves the same path.
*
* Shape: `<pid>-<8 hex>`. The PID prefix is what
* `sweepStaleClaudeConfigMirrors` keys off to drop orphans from
* crashed prior sessions; the 8-hex random suffix prevents collision
* if a future caller (tests, internal relaunch) ever clears the cache
* within a single PID lifetime.
*
* NOT exported — every consumer should go through `PATHS.CLAUDE_CONFIG_DIR`
* so the homedir-mock pattern used in the test suite keeps working.
*/
let _claudeConfigDirSuffix;
function claudeConfigDirSuffix() {
if (_claudeConfigDirSuffix === void 0) _claudeConfigDirSuffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
return _claudeConfigDirSuffix;
}
async function ensurePaths() {
await fs.mkdir(PATHS.APP_DIR, { recursive: true });
await fs.mkdir(PATHS.CODEX_HOME, { recursive: true });
await fs.mkdir(PATHS.CLAUDE_RUNTIME_DIR, { recursive: true });
await chmodIfPossible(PATHS.CLAUDE_RUNTIME_DIR, 448);
await ensureFile(PATHS.GITHUB_TOKEN_PATH);
await sweepStaleRuntimeFiles().catch((err) => {
consola.debug("Runtime sweep skipped:", err);
});
await sweepStaleClaudeConfigMirrors().catch((err) => {
consola.debug("Per-launch claude-config sweep skipped:", err);
});
await sweepStalePeerAgentMdFiles().catch((err) => {
consola.debug("Peer-agent .md sweep skipped:", err);
});
await (async () => {
await (await import("./lifecycle-zr19Ot-e.js")).sweepStaleWorktreesAtBoot();
})().catch((err) => {
consola.debug("Worker worktree boot sweep skipped:", err);
});
}
const CLAUDE_HOME_POLICY = new Map([
[".credentials.json", "ISOLATED"],
[".credentials.json.lock", "ISOLATED"],
[".oauth_refresh.lock", "ISOLATED"],
[".github-router-managed", "ISOLATED"],
["statsig", "ISOLATED"],
["cache", "ISOLATED"],
["logs", "ISOLATED"],
["paste-cache", "ISOLATED"],
["jobs", "ISOLATED"],
["daemon", "ISOLATED"],
["daemon.log", "ISOLATED"],
["projects", "SHARED"],
["sessions", "SHARED"],
["tasks", "SHARED"],
["todos", "SHARED"],
["transcripts", "SHARED"],
["shell-snapshots", "SHARED"],
["shell_snapshots", "SHARED"],
["plans", "SHARED"],
["file-history", "SHARED"],
["backups", "SHARED"]
]);
function policyFor(name) {
return CLAUDE_HOME_POLICY.get(name) ?? "MIRRORED";
}
/**
* Names with `SHARED` policy, materialized once for iteration in
* `ensureClaudeConfigMirror`'s post-copy phase.
*/
const SHARED_TOPLEVEL_NAMES = Array.from(CLAUDE_HOME_POLICY.entries()).filter(([, kind]) => kind === "SHARED").map(([name]) => name);
/**
* Marker file written into the router-owned CLAUDE_CONFIG_DIR so users
* (and our own future sweeps) can identify that the dir is managed by
* github-router. Content is informational only; no logic depends on
* its presence.
*/
const MANAGED_MARKER_FILENAME = ".github-router-managed";
/**
* Synthetic Console OAuth credential the router writes into its own
* `CLAUDE_CONFIG_DIR/.credentials.json` so spawned Claude Code (and
* any teammates it spawns) can authenticate without a real user
* `/login`.
*
* Schema verified verbatim from `claude` v2.1.140 binary, function
* `guH` (the credentials-save mutation). Fields:
* - `accessToken` — sent as `Authorization: Bearer ...` to the
* proxy. Proxy accepts any bearer (per CLAUDE.md "doesn't enforce
* auth").
* - `refreshToken` — only used by Claude Code's reactive refresh
* path (function `nH8`), which fires on 401 from upstream. The
* proxy maintains the no-401 invariant on the Anthropic-shape
* boundary, so this is never invoked. Synthetic value is fine.
* - `expiresAt` — far-future (2099-01-01 ms epoch). Sidesteps the
* proactive refresh path (`R8H(expiresAt)` returns false).
* - `scopes` — claude-ai-shaped so `tB(scopes)` returns true,
* making `Hq()` true (full feature surface, not "inference only").
* - `subscriptionType` — `"max"`. Pure client-side label
* (`e7()` / `Zc_()` / `CZ1()`); no server validation since
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` suppresses
* subscription-validation calls. Picks the most-permissive gating.
* - `rateLimitTier` — `"default_claude_max_20x"`. Paired with
* `subscriptionType:"max"` this is the real Max-20x tier, so the
* credential is internally consistent (vs the prior odd `max`+`null`).
* Verified live (claude v2.1.158) call-sites are cosmetic billing /
* upsell-suppression UI plus the `getPlanModeV2AgentCount` (`bGK`)
* `max && 20x → 3` branch — which `CLAUDE_CODE_PLAN_V2_AGENT_COUNT`
* (set to 7 in server-setup) already overrides, so this is
* belt-and-suspenders for the natural code path. No client-side quota
* enforcement keys off the tier (rate-limit UI reads server
* `x-ratelimit-*` headers; the proxy holds the no-429 invariant).
*/
const SYNTHETIC_CREDENTIAL = { claudeAiOauth: {
accessToken: "github-router-synthetic",
refreshToken: "github-router-synthetic",
expiresAt: 40709088e5,
scopes: ["user:inference", "user:profile"],
subscriptionType: "max",
rateLimitTier: "default_claude_max_20x",
clientId: "github-router"
} };
/**
* Router-owned fields injected into `<CLAUDE_CONFIG_DIR>/.claude.json` so
* Claude Code skips its first-launch onboarding wizard. The synthetic
* `.credentials.json` covers the OAuth *token*, but on a fresh machine
* the wizard runs anyway — and one of its steps is the browser-OAuth
* "Sign in to your Anthropic account" prompt that the user sees today.
*
* Schema verified against `claude` v2.1.158 binary strings:
*
* - `hasCompletedOnboarding: true` — single load-bearing gate.
* Binary code path: `if (!E_().hasCompletedOnboarding) { ... await
* Onboarding.default(...) }`. Setting true causes the wizard
* (including the OAuth login step) to be skipped entirely.
*
* - `bypassPermissionsModeAccepted: true` — skips the
* `--dangerously-skip-permissions` trust disclaimer. Binary gate:
* `K = Ip() || Boolean(E_().bypassPermissionsModeAccepted)`. The
* proxy passes `--dangerously-skip-permissions` so `Ip()` typically
* suffices, but pinning the field covers edge code paths (e.g.
* `--bg --dangerously-skip-permissions`) that still consult it
* directly and would otherwise throw "requires accepting the
* disclaimer first."
*
* - `oauthAccount` — nice-to-have for UI consistency. Claude Code
* reads sub-fields with optional chaining (`oauthAccount?.accountUuid`,
* `oauthAccount?.organizationUuid`, `oauthAccount?.organizationRole`)
* so undefined doesn't trigger login — but populating gives any
* "logged in as X" / status-line UI something deterministic and
* obviously-synthetic to display. Values are intentionally
* non-credible (`github-router@local`, all-zero UUIDs) so any
* leak into logs is self-documenting, not impersonation.
*
* Merge semantics: the two boolean gates are **force-overridden** —
* even an existing `false` is flipped to `true`. (A user logging out
* via the Claude Code UI cannot defeat the proxy-session bypass.)
* `oauthAccount` is overwritten only when the existing value isn't a
* *structurally usable* one (presence-only would preserve `{}` or
* `{foo:1}` shapes that defeat the fix); a real OAuth identity with
* non-empty `accountUuid` and `organizationUuid` is preserved as-is.
* Every OTHER top-level field the user brought in via the mirror walk
* (settings, project history, user-side MCP entries, etc.) is preserved
* verbatim. The mirror is a router-controlled view, not a faithful
* copy — same trade-off the synthetic credential already makes.
*/
const SYNTHETIC_CLAUDE_JSON_FIELDS = {
hasCompletedOnboarding: true,
bypassPermissionsModeAccepted: true,
oauthAccount: {
accountUuid: "00000000-0000-0000-0000-000000000000",
organizationUuid: "00000000-0000-0000-0000-000000000000",
organizationRole: "member",
emailAddress: "github-router@local",
organizationName: "github-router"
}
};
/**
* Snapshot-copy the user's `~/.claude/` into the router-owned
* CLAUDE_CONFIG_DIR (real files, not symlinks — symlinks don't isolate
* writes), classifying each top-level entry per `CLAUDE_HOME_POLICY`:
* ISOLATED entries are skipped, MIRRORED entries are copied, and
* SHARED entries become directory symlinks back to `~/.claude/<X>` so
* chat history (in `projects/<cwd-hash>/<session-uuid>.jsonl`) and
* other durable user state flow between proxy and plain-`claude`
* sessions. Then writes the synthetic `.credentials.json` so spawned
* Claude Code (and teammates that inherit `CLAUDE_CONFIG_DIR`)
* authenticate.
*
* Idempotent: only re-copies files whose source `mtime` is newer than
* target; SHARED-symlink creation no-ops when the symlink already
* points at the right target. Concurrent-safe: `mkdir({recursive:true})`
* is idempotent; symlinks are created via atomic temp+rename so two
* parallel github-router-claude startups can't race to EEXIST; the
* credentials write uses temp-file + atomic rename so Claude Code's
* `EZ1()` mtime watcher never sees a partial write.
*
* Walks with `lstat` (does NOT follow symlinks during traversal — a
* symlink-into-`/` would otherwise let the walk escape). Symlink leaves
* in the source tree are skipped during the MIRRORED copy walk (per the
* symlink-confused-deputy security finding); SHARED symlinks are
* created on the mirror side only, pointing at predetermined targets
* inside the user's real `~/.claude/`.
*
* Caller is expected to invoke this after `ensurePaths()` and before
* spawning Claude Code (`launchChild`). The mirror must exist before
* the child reads it. Currently called from the `claude` subcommand
* entry point only; `start` and `codex` subcommands don't need it.
*/
async function ensureClaudeConfigMirror(opts = {}) {
const realHome = opts.realHome ?? os.homedir();
const sourceDir = path.join(realHome, ".claude");
const targetDir = PATHS.CLAUDE_CONFIG_DIR;
await fs.mkdir(targetDir, {
recursive: true,
mode: 448
});
await chmodIfPossible(targetDir, 448);
let sourceExists = false;
try {
sourceExists = (await fs.stat(sourceDir)).isDirectory();
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot stat ${sourceDir}:`, err);
}
if (sourceExists) await mirrorDirRecursive(sourceDir, targetDir, "");
await fs.mkdir(path.join(targetDir, "agents"), { recursive: true });
for (const name of SHARED_TOPLEVEL_NAMES) await ensureSharedSymlink(name, sourceDir, targetDir).catch((err) => {
consola.debug(`ensureClaudeConfigMirror: SHARED symlink for ${name} skipped:`, err);
});
const credentialsPath = path.join(targetDir, ".credentials.json");
const desiredJson = JSON.stringify(SYNTHETIC_CREDENTIAL, null, 2);
let needsWrite = true;
try {
needsWrite = (await fs.readFile(credentialsPath, "utf8")).trim() !== desiredJson.trim();
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot read existing credentials:`, err);
}
if (needsWrite) {
const tempPath = `${credentialsPath}.${process.pid}.tmp`;
try {
await fs.writeFile(tempPath, desiredJson + "\n", {
mode: 384,
flag: "wx"
});
await fs.rename(tempPath, credentialsPath);
} catch (err) {
if (err.code === "EEXIST") consola.debug("ensureClaudeConfigMirror: concurrent credentials-write detected, skipping");
else {
await fs.unlink(tempPath).catch(() => {});
throw err;
}
}
}
await chmodIfPossible(credentialsPath, 384);
await injectSyntheticClaudeJsonFields(targetDir, sourceDir, realHome);
await assertOnboardingGateInjected(targetDir);
const markerPath = path.join(targetDir, MANAGED_MARKER_FILENAME);
let markerExists = false;
try {
const markerStat = await fs.lstat(markerPath);
if (markerStat.isFile()) markerExists = true;
else {
consola.warn(`ensureClaudeConfigMirror: ${markerPath} exists but is not a regular file (mode=${markerStat.mode.toString(8)}); refusing to overwrite. Inspect and remove manually if safe.`);
markerExists = true;
}
} catch (err) {
if (err.code !== "ENOENT") {
consola.debug(`ensureClaudeConfigMirror: cannot lstat marker:`, err);
markerExists = true;
}
}
if (!markerExists) {
const body = `Managed by github-router. Created ${(/* @__PURE__ */ new Date()).toISOString()}. Safe to delete (will be recreated).\n`;
await fs.writeFile(markerPath, body, {
mode: 384,
flag: "wx"
}).catch((err) => {
consola.debug(`ensureClaudeConfigMirror: marker write skipped:`, err);
});
}
}
/**
* Read-merge-atomic-write the router-required onboarding-skip fields
* (see `SYNTHETIC_CLAUDE_JSON_FIELDS`) into `<targetDir>/.claude.json`.
*
* Inputs:
* - `targetDir` — the per-launch mirror dir we own.
* - `sourceDir` — the user's `~/.claude/` (subdir-level legacy path).
* - `realHome` — the user's `$HOME`. Used to locate the **canonical**
* `~/.claude.json` (HOME level), which is what Claude Code's path
* resolver `qG` actually reads when `CLAUDE_CONFIG_DIR` is unset:
* `path.join(process.env.CLAUDE_CONFIG_DIR || homedir(),
* ".claude.json")`. The mirror walk operates on `~/.claude/` and
* thus can only see the subdir-level `~/.claude/.claude.json`
* (often a stale shadow); the canonical HOME-level file — where
* `claude mcp add` writes and Claude Code reads on default
* invocations — would otherwise be invisible to the proxy session.
*
* Behaviour:
* - Mirror's `.claude.json` is a non-regular file (symlink, dir,
* etc.) → log warn, refuse to touch. The postcondition check in
* `ensureClaudeConfigMirror` then throws, blocking launch — the
* user investigates the warn-flagged path rather than landing in
* the OAuth wizard.
* - Read existing content from the first VALID source, in priority
* order:
* (a) `~/.claude.json` HOME-level (canonical Claude Code path)
* (b) The mirror file itself (snapshot-walk's product; only
* consulted if it's a regular file per step 1)
* (c) `~/.claude/.claude.json` subdir-level (legacy)
* A candidate that's missing (ENOENT), too large (> 50 MiB cap),
* or fails to parse as a non-empty object is **skipped** — the
* loop falls through to the next candidate, so a malformed
* higher-priority source can't drop valid lower-priority content
* on the floor. HOME-level non-ENOENT read failures warn
* (user-visible — corp perms / OneDrive). All other failures
* debug-log. `fs.readFile` follows symlinks; reading user-owned
* content into a per-launch user-owned dir is not a privilege
* escalation (same uid).
* - Merge: **force-override** all three synthetic fields
* unconditionally — `hasCompletedOnboarding`,
* `bypassPermissionsModeAccepted`, AND `oauthAccount`. A
* within-proxy "log out" is impossible by design. `oauthAccount`
* is overwritten even when the user has a real one, because
* pairing the synthetic OAuth token (from `.credentials.json`)
* with a real-user identity blob creates a split-brain auth
* state that Claude Code's identity-vs-token cross-checks may
* treat as session corruption (triggering re-login → defeats
* the fix). The user's real identity remains intact in their
* own `~/.claude.json`; only the proxy session sees synthetic.
* `Object.assign(Object.create(null), existing)` instead of spread
* defuses the `__proto__` prototype-pollution sink from JSON.parse.
* - Idempotency: skip the write IFF the source we read FROM is the
* mirror itself AND the current mirror content is byte-equivalent
* to what we'd write. Bytes-on-disk comparison is robust against
* filesystem mtime resolution (FAT/exFAT 2 s buckets) and the
* source-presence-vs-mirror-presence conflation that broke
* round-2's `needsWrite = !hadExisting` heuristic.
*
* Atomic write: tempfile (per-PID + 4-byte random suffix matching
* `injectPeerMcpIntoMirror`'s pattern, so collision is effectively
* impossible) + rename, mode 0o600. Pre-chmod the destination before
* rename to defuse Windows read-only attribute issues. Any write
* error throws to the caller — `src/claude.ts` fails the launch
* loudly. Combined with the postcondition check, every silent-
* degradation path is converted to a fail-loud launch error.
*/
async function injectSyntheticClaudeJsonFields(targetDir, sourceDir, realHome) {
const claudeJsonPath = path.join(targetDir, ".claude.json");
let mirrorIsRegularFile = false;
try {
const mirrorStat = await fs.lstat(claudeJsonPath);
if (mirrorStat.isFile()) mirrorIsRegularFile = true;
else {
consola.warn(`ensureClaudeConfigMirror: ${claudeJsonPath} exists but is not a regular file (mode=${mirrorStat.mode.toString(8)}); refusing to inject synthetic fields. Inspect and remove manually if safe.`);
return;
}
} catch (err) {
if (err.code !== "ENOENT") {
consola.warn(`ensureClaudeConfigMirror: cannot lstat ${claudeJsonPath}; refusing to inject synthetic fields (overwriting an unreadable file would risk destroying user content):`, err);
return;
}
}
let existing = {};
let selectedSource = null;
const homeLevelClaudeJson = path.join(realHome, ".claude.json");
const subdirClaudeJson = path.join(sourceDir, ".claude.json");
const sourceCandidates = [
homeLevelClaudeJson,
mirrorIsRegularFile ? claudeJsonPath : null,
subdirClaudeJson
];
for (const sourceCandidate of sourceCandidates) {
if (sourceCandidate === null) continue;
try {
const stat$1 = await fs.stat(sourceCandidate);
if (stat$1.size > MAX_CLAUDE_JSON_BYTES) {
consola.warn(`ensureClaudeConfigMirror: ${sourceCandidate} is ${stat$1.size} bytes (> ${MAX_CLAUDE_JSON_BYTES} cap); skipping this source to avoid OOM. Inspect for accidental log/state accumulation.`);
continue;
}
const parsed = parseExistingClaudeJson(await fs.readFile(sourceCandidate, "utf8"), sourceCandidate);
if (parsed === null) continue;
existing = parsed;
selectedSource = sourceCandidate;
consola.debug(`ensureClaudeConfigMirror: read .claude.json content from ${sourceCandidate}`);
break;
} catch (err) {
if (err.code === "ENOENT") continue;
if (sourceCandidate === homeLevelClaudeJson) consola.warn(`ensureClaudeConfigMirror: cannot read canonical ${homeLevelClaudeJson}; falling back to other sources. User-scope MCPs added via 'claude mcp add' may be invisible to the proxy session:`, err);
else consola.debug(`ensureClaudeConfigMirror: cannot read source ${sourceCandidate}:`, err);
}
}
const merged = Object.assign(Object.create(null), existing);
merged.hasCompletedOnboarding = SYNTHETIC_CLAUDE_JSON_FIELDS.hasCompletedOnboarding;
merged.bypassPermissionsModeAccepted = SYNTHETIC_CLAUDE_JSON_FIELDS.bypassPermissionsModeAccepted;
merged.oauthAccount = SYNTHETIC_CLAUDE_JSON_FIELDS.oauthAccount;
const desiredJson = JSON.stringify(merged, null, 2) + "\n";
if (selectedSource === claudeJsonPath) try {
if (await fs.readFile(claudeJsonPath, "utf8") === desiredJson) {
await chmodIfPossible(claudeJsonPath, 384).catch(() => {});
return;
}
} catch {}
const tempPath = `${claudeJsonPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
try {
await fs.writeFile(tempPath, desiredJson, {
mode: 384,
flag: "wx"
});
if (mirrorIsRegularFile) await chmodIfPossible(claudeJsonPath, 384).catch(() => {});
await fs.rename(tempPath, claudeJsonPath);
} catch (err) {
await fs.unlink(tempPath).catch(() => {});
try {
if (await fs.readFile(claudeJsonPath, "utf8") === desiredJson) {
consola.debug(`ensureClaudeConfigMirror: rename failed but mirror already holds expected content (concurrent racer won the race):`, err);
await chmodIfPossible(claudeJsonPath, 384).catch(() => {});
return;
}
} catch {}
throw err;
}
await chmodIfPossible(claudeJsonPath, 384).catch(() => {});
}
/**
* Size cap on candidate `.claude.json` reads in
* `injectSyntheticClaudeJsonFields`. Real-world files are KB-MB scale;
* if a `.claude.json` has grown to hundreds of MB (corruption, runaway
* project history accumulation, accidental log redirect) reading it
* blocks the event loop and risks OOM. 50 MiB is comfortably above
* any legitimate Claude Code state and well below typical RSS budgets.
*/
const MAX_CLAUDE_JSON_BYTES = 50 * 1024 * 1024;
/**
* Structural check for `oauthAccount`: must be a non-null, non-array
* object whose `accountUuid` AND `organizationUuid` are non-empty
* strings. Empty objects / partial blobs (missing UUIDs) do NOT count
* as "usable" — preserving them would let Claude Code's various
* "logged in as X" UIs read undefined sub-fields and (in some flows)
* fall back to a re-login prompt that defeats the synthetic-credential
* fix. Field names match the binary's optional-chain reads
* (`oauthAccount?.accountUuid`, `oauthAccount?.organizationUuid`).
*/
function isUsableOauthAccount(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const obj = value;
const accountUuid = obj.accountUuid;
const organizationUuid = obj.organizationUuid;
return typeof accountUuid === "string" && accountUuid.length > 0 && typeof organizationUuid === "string" && organizationUuid.length > 0;
}
/**
* Parse a JSON blob expected to be an object. Returns `null` on parse
* failure or non-object/array value (so callers can SKIP this source
* rather than treating an empty object as a successful read — a
* round-1 finding from gemini-critic). Logs a warn so the user can
* investigate. Used in `injectSyntheticClaudeJsonFields`'s source-
* priority loop: a malformed HOME-level file falls through to the
* mirror or subdir candidate instead of dropping valid lower-priority
* content on the floor.
*/
function parseExistingClaudeJson(raw, contextPath) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
consola.warn(`ensureClaudeConfigMirror: ${contextPath} parsed to non-object (typeof=${typeof parsed}); skipping this source.`);
return null;
} catch (err) {
consola.warn(`ensureClaudeConfigMirror: cannot parse ${contextPath} as JSON; skipping this source:`, err);
return null;
}
}
/**
* Postcondition for `ensureClaudeConfigMirror`: re-reads the mirror's
* `.claude.json` and verifies that both boolean gate fields actually
* landed. Converts every "warn and return" branch in
* `injectSyntheticClaudeJsonFields` (non-regular target, non-ENOENT
* read failure, postcondition lstat race) into a launch-failing
* throw — `src/claude.ts:197`'s catch path turns it into a clear
* `process.exit(1)` rather than silently spawning Claude Code into
* the OAuth wizard. The whole point of this subsystem is preventing
* that silent degradation; a warn that scrolls by in the launch log
* is not enough.
*
* Cheap (one extra readFile + JSON.parse on a small file we just
* wrote) and load-bearing: every future contributor who adds a new
* bail-out branch in the helper above automatically gets fail-loud
* for free instead of having to remember to thread the failure
* upward by hand.
*/
async function assertOnboardingGateInjected(targetDir) {
const claudeJsonPath = path.join(targetDir, ".claude.json");
let lst;
try {
lst = await fs.lstat(claudeJsonPath);
} catch (err) {
throw new Error(`ensureClaudeConfigMirror: postcondition failed — cannot lstat ${claudeJsonPath} after injection (synthetic onboarding-skip fields are required to prevent Claude Code's first-launch wizard, which would defeat the synthetic credential): ${err.message}`);
}
if (!lst.isFile()) throw new Error(`ensureClaudeConfigMirror: postcondition failed — ${claudeJsonPath} exists but is not a regular file (mode=${lst.mode.toString(8)}). Check the preceding warn-level log lines for the underlying cause; without a regular file holding the gate fields, Claude Code's first-launch wizard fires and defeats the synthetic credential.`);
let raw;
try {
raw = await fs.readFile(claudeJsonPath, "utf8");
} catch (err) {
throw new Error(`ensureClaudeConfigMirror: postcondition failed — cannot read ${claudeJsonPath} after injection: ${err.message}`);
}
let parsed;
try {
const json = JSON.parse(raw);
if (!json || typeof json !== "object" || Array.isArray(json)) throw new Error(`parsed to non-object (typeof=${typeof json})`);
parsed = json;
} catch (err) {
throw new Error(`ensureClaudeConfigMirror: postcondition failed — ${claudeJsonPath} is not a valid JSON object after injection: ${err.message}`);
}
if (parsed.hasCompletedOnboarding !== true || parsed.bypassPermissionsModeAccepted !== true) throw new Error(`ensureClaudeConfigMirror: postcondition failed — ${claudeJsonPath} is missing required gate fields after injection (hasCompletedOnboarding=${String(parsed.hasCompletedOnboarding)}, bypassPermissionsModeAccepted=${String(parsed.bypassPermissionsModeAccepted)}). Check the preceding warn-level log lines for the underlying cause; without these fields Claude Code's first-launch wizard fires and defeats the synthetic credential.`);
if (!isUsableOauthAccount(parsed.oauthAccount)) throw new Error(`ensureClaudeConfigMirror: postcondition failed — ${claudeJsonPath} has invalid oauthAccount after injection (got ${JSON.stringify(parsed.oauthAccount)}). The synthetic identity blob is required so Claude Code's identity-vs-token cross-checks don't trigger a re-login that defeats the synthetic credential.`);
}
/**
* Recursive snapshot-copy helper for `ensureClaudeConfigMirror`. Walks
* `sourceDir/relPath` and mirrors each entry into `targetDir/relPath`.
* - Top-level entries are dispatched on `policyFor(name)`:
* - `ISOLATED` → skipped entirely (no presence in mirror).
* - `SHARED` → skipped from the copy walk; handled by
* `ensureSharedSymlink` in the post-copy phase.
* - `MIRRORED` → copied as today.
* - Symlinks are skipped (not recreated) so the walk never follows out
* of `sourceDir` and we don't reintroduce a confused-deputy vector.
* - Files copy only if source mtime > target mtime (idempotent).
*/
async function mirrorDirRecursive(sourceDir, targetDir, relPath) {
const sourcePath = path.join(sourceDir, relPath);
let entries;
try {
entries = await fs.readdir(sourcePath);
} catch (err) {
if (err.code === "ENOENT") return;
consola.debug(`mirrorDirRecursive: cannot readdir ${sourcePath}:`, err);
return;
}
for (const name of entries) {
if (relPath === "") {
const policy = policyFor(name);
if (policy === "ISOLATED" || policy === "SHARED") continue;
}
const childRel = relPath === "" ? name : path.join(relPath, name);
const childSource = path.join(sourceDir, childRel);
const childTarget = path.join(targetDir, childRel);
let stats;
try {
stats = await fs.lstat(childSource);
} catch (err) {
consola.debug(`mirrorDirRecursive: cannot lstat ${childSource}:`, err);
continue;
}
if (stats.isSymbolicLink()) {
consola.debug(`mirrorDirRecursive: skipping symlink ${childSource} (security policy)`);
continue;
}
if (stats.isDirectory()) {
await fs.mkdir(childTarget, { recursive: true });
await mirrorDirRecursive(sourceDir, targetDir, childRel);
continue;
}
if (stats.isFile()) {
let needsCopy = true;
try {
const targetStat = await fs.lstat(childTarget);
if (targetStat.isFile() && targetStat.mtimeMs >= stats.mtimeMs) needsCopy = false;
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`mirrorDirRecursive: lstat target ${childTarget}:`, err);
}
if (!needsCopy) continue;
try {
await fs.copyFile(childSource, childTarget, fs.constants.COPYFILE_FICLONE);
} catch (err) {
consola.debug(`mirrorDirRecursive: copy ${childSource} → ${childTarget}:`, err);
}
continue;
}
}
}
/**
* Create or refresh a directory symlink `<mirrorDir>/<name>` →
* `<sourceDir>/<name>` (i.e. `~/.local/share/github-router/claude-config/<X>`
* → `~/.claude/<X>`). Idempotent and concurrent-safe.
*
* Behavior depending on what's already at `<mirrorDir>/<name>`:
* - Symlink with the correct target → no-op.
* - Symlink with the wrong target → replace atomically.
* - Empty real directory (legacy mirror leftover with no proxy-session
* writes accumulated yet) → `rmdir` and replace with the symlink.
* Safe by definition: `fs.rmdir` only succeeds on empty dirs (POSIX),
* so there is nothing to lose. Smooths the upgrade path for users
* whose legacy mirror dirs were never written to.
* - Non-empty real directory or regular file → loud-warn and skip.
* Auto-deleting would destroy proxy-session writes from the prior
* version. The user is told the exact path and remediation.
* - ENOENT → create symlink atomically.
*
* Atomic-creation: symlinks are first written at a unique side-path
* (`<mirrorDir>/<name>.tmp.<pid>.<8 hex>`) and then `fs.rename()`d into
* place. POSIX `rename` is atomic and replaces an existing symlink in
* a single step, so two concurrent `github-router claude` startups can't
* race to `EEXIST` — the loser's rename just overwrites the winner's
* symlink with an identical one. Gemini-critic 3-lab-review finding.
*
* Pre-creates `~/.claude/<name>/` as a real directory if missing so
* Claude Code's writes through the symlink don't fail with ENOENT.
*/
async function ensureSharedSymlink(name, sourceDir, mirrorDir) {
const sourcePath = path.join(sourceDir, name);
const mirrorPath = path.join(mirrorDir, name);
try {
await fs.mkdir(sourcePath, { recursive: true });
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): cannot mkdir source ${sourcePath}:`, err);
return;
}
let existing = null;
try {
existing = await fs.lstat(mirrorPath);
} catch (err) {
if (err.code !== "ENOENT") {
consola.warn(`ensureSharedSymlink(${name}): cannot lstat ${mirrorPath}:`, err);
return;
}
}
if (existing?.isSymbolicLink()) {
const sourceReal = await fs.realpath(sourcePath).catch(() => null);
if (sourceReal === null) {
consola.warn(`ensureSharedSymlink(${name}): cannot resolve source ${sourcePath} — skipping junction creation to avoid silent every-startup churn. Inspect the source dir's permissions / OneDrive sync state and re-launch.`);
return;
}
const currentReal = await fs.realpath(mirrorPath).catch(() => null);
if (currentReal !== null && currentReal === sourceReal) return;
} else if (existing?.isDirectory()) try {
await fs.rmdir(mirrorPath);
} catch (err) {
consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory from an older github-router version; refusing to clobber. If you want chat-history continuity for "${name}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink (junction on Windows) on next launch. (rmdir error: ${err.code ?? "unknown"})`);
return;
}
else if (existing) {
consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a regular file at a SHARED symlink slot; refusing to clobber. Inspect and remove manually if safe; the mirror will create a symlink on next launch.`);
return;
}
const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
try {
await fs.symlink(sourcePath, tempPath, process.platform === "win32" ? "junction" : "dir");
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): symlink ${tempPath} failed:`, err);
return;
}
if (process.platform === "win32" && existing?.isSymbolicLink()) await fs.unlink(mirrorPath).catch(() => {});
try {
await fs.rename(tempPath, mirrorPath);
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): rename ${tempPath} → ${mirrorPath} failed:`, err);
await fs.unlink(tempPath).catch(() => {});
}
}
async function ensureFile(filePath) {
try {
await fs.access(filePath, fs.constants.W_OK);
} catch {
await fs.writeFile(filePath, "");
await fs.chmod(filePath, 384);
}
}
async function chmodIfPossible(target, mode) {
if (process.platform === "win32") return;
try {
await fs.chmod(target, mode);
} catch (err) {
consola.debug(`chmod ${target} ${mode.toString(8)} failed:`, err);
}
}
/**
* Write a runtime tempfile securely.
*
* - Mode `0o600` so other local users (multi-tenant boxes, shared
* dev containers) can't read the per-launch nonce or runtime URL.
* - `flag: "wx"` (O_CREAT | O_EXCL | O_WRONLY) refuses to overwrite
* an existing path. POSIX open(2) with O_EXCL also rejects
* pre-placed symlinks, killing the symlink-clobber attack vector.
* - The caller's responsibility to pick a path NOT yet in use.
* We intentionally do NOT pre-unlink: an `lstat` + `unlink` +
* `open(O_EXCL)` sequence still has a TOCTOU window where an
* attacker can drop a symlink between unlink and open. Letting
* `wx` fail is the safer behavior — surfaces the conflict
* instead of silently following.
*/
async function writeRuntimeFileSecure(filePath, content) {
await fs.writeFile(filePath, content, {
mode: 384,
flag: "wx"
});
}
/**
* Sweep stale runtime tempfiles. Removes files whose embedded PID is no
* longer a live process. A proxy crash (`kill -9`, OS reboot) leaves
* orphans that would otherwise accumulate forever — and worse, a stale
* config pointing at a now-recycled port could route MCP traffic to
* whatever process bound that port next.
*
* Naming convention: `peer-mcp-<pid>.json` and `peer-agents-<pid>.json`.
* Files not matching either pattern are left alone — this directory
* is shared with future runtime artifacts.
*
* We deliberately do NOT age-prune files whose PID is alive. A
* legitimately long-running proxy can have a tempfile older than any
* arbitrary threshold; deleting it out from under the live process
* breaks the spawned Claude Code child's MCP/agent wiring with no clean
* recovery. PID-wraparound risk is mitigated by (a) PID reuse on Linux
* being slow under typical loads, and (b) the file is only consulted by
* github-router itself — an unrelated process that inherits the PID
* never reads it.
*/
async function sweepStaleRuntimeFiles() {
const dir = PATHS.CLAUDE_RUNTIME_DIR;
let entries;
try {
entries = await fs.readdir(dir);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = /^peer-(?:mcp|agents)-(\d+)(?:-[0-9a-f]+)?\.json$/.exec(name);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
const filePath = path.join(dir, name);
if (isPidAlive(pid)) continue;
await fs.unlink(filePath).catch(() => {});
}
}
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err.code === "EPERM") return true;
return false;
}
}
/**
* Sweep stale peer-* subagent .md files from the router-owned
* `CLAUDE_CONFIG_DIR/agents/`. Phase 2.5 writes one .md per peer agent
* into Claude Code's agents directory (now our config dir's `agents/`
* subdir, since `getClaudeCodeEnvVars` points `CLAUDE_CONFIG_DIR` at
* `PATHS.CLAUDE_CONFIG_DIR`) so they appear in Claude Code's Task
* `subagent_type` enum. Files are named `peer-<pid>-<rand>-<agentName>.md`
* so this sweep can drop orphans from crashed prior proxy sessions
* without touching the user's own .md files (which were copied into
* the same dir during `ensureClaudeConfigMirror`).
*
* Same liveness rule as `sweepStaleRuntimeFiles`: only delete when the
* file's embedded PID is no longer alive. Live PIDs keep their files —
* a long-running proxy doesn't lose its agent registrations.
*
* Regex tightening (Phase 2.6, codex-critic + gemini-critic 2-lab finding):
* the original sweep regex `^peer-(\d+)(?:-[0-9a-f]+)?-.+\.md$` was too
* permissive — a user-authored `peer-12345-meeting-notes.md` matches
* (`12345` = "PID", `-meeting-notes` = trailing `.+`) and would be
* silently unlinked when 12345 happens to be a dead PID (overwhelmingly
* likely). Tightened to require BOTH the 8-hex-char random suffix AND
* an exact-match persona name suffix, eliminating the risk for any
* realistic user filename.
*/
async function sweepStalePeerAgentMdFiles() {
const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
let entries;
try {
entries = await fs.readdir(dir);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = PEER_AGENT_MD_FILENAME.exec(name);
if (!match) continue;
if (isPidAlive(Number.parseInt(match[1], 10))) continue;
await fs.unlink(path.join(dir, name)).catch(() => {});
}
}
/**
* Strict regex matching only files this proxy writes:
* peer-<pid>-<8 hex>-<exact persona/coordinator name>.md
* The persona-name allowlist is the load-bearing protection against
* deleting user files. Update this list whenever a new persona is added
* to `PERSONAS_READ` / `PERSONAS_WRITE` in `peer-mcp-personas.ts` or a
* new coordinator-style agent is added in `codex-mcp-config.ts`.
*/
const PEER_AGENT_MD_FILENAME = /^peer-(\d+)-[0-9a-f]{8}-(?:codex-critic|codex-reviewer|gemini-critic|codex-implementer|peer-review-coordinator)\.md$/;
/**
* Strict regex matching only per-launch claude-config mirror dirs this
* proxy creates: `<pid>-<8 hex>`. Anchored to the entire entry name so
* user-authored siblings under `<appDir>/claude-config/` (if any) are
* untouchable. The PID prefix is what `sweepStaleClaudeConfigMirrors`
* keys off; the 8-hex random suffix matches `randomBytes(4)` exactly
* (no `?` — files created by a different shape are not ours).
*/
const CLAUDE_CONFIG_MIRROR_DIR = /^(\d+)-[0-9a-f]{8}$/;
/**
* Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by
* crashed prior proxy sessions. Symmetric to `sweepStalePeerAgentMdFiles`
* — same liveness rule (only delete when the embedded PID is dead),
* same strict regex (the dir-name allowlist is the load-bearing
* protection against deleting user-authored siblings).
*
* Scans `<appDir>/claude-config/` (the parent of the per-launch dirs).
* Each entry whose name matches `<pid>-<8 hex>` AND whose PID is no
* longer alive is removed recursively. `fs.rm({recursive: true})`
* walks the tree calling `unlink` on symlinks/junctions rather than
* following them, so the SHARED junctions back to `~/.claude/<X>`
* are removed without touching their targets.
*
* Tolerates missing parent dir (first-ever launch, or user wiped it).
*/
async function sweepStaleClaudeConfigMirrors() {
const parent = path.join(appDir(), "claude-config");
let entries;
try {
entries = await fs.readdir(parent);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = CLAUDE_CONFIG_MIRROR_DIR.exec(name);
if (!match) continue;
if (isPidAlive(Number.parseInt(match[1], 10))) continue;
await fs.rm(path.join(parent, name), {
recursive: true,
force: true
}).catch((err) => {
consola.debug(`sweepStaleClaudeConfigMirrors: cannot rm ${name}:`, err);
});
}
}
/**
* Remove THIS launch's per-launch CLAUDE_CONFIG_DIR on shutdown.
* Best-effort: a failure here must not block process exit (the caller
* wraps this in a `.catch`-equivalent via `launchChild`'s onShutdown
* try/catch). Symmetric to `writePeerMcpRuntimeFiles`'s `cleanup()`:
* we own this dir for the lifetime of the proxy, so removing it on
* normal shutdown is correct; the boot-time sweep handles the
* abnormal-exit case.
*
* `fs.rm({recursive: true})` removes SHARED junctions via unlink
* (does NOT follow them into the user's real `~/.claude/<X>`).
*/
async function removeOwnClaudeConfigMirror() {
const dir = PATHS.CLAUDE_CONFIG_DIR;
await fs.rm(dir, {
recursive: true,
force: true
}).catch((err) => {
consola.debug(`removeOwnClaudeConfigMirror: rm ${dir} skipped:`, err);
});
}
//#endregion
export { sweepStaleClaudeConfigMirrors as a, writeRuntimeFileSecure as c, removeOwnClaudeConfigMirror as i, ensureClaudeConfigMirror as n, sweepStalePeerAgentMdFiles as o, ensurePaths as r, sweepStaleRuntimeFiles as s, PATHS as t };
//# sourceMappingURL=paths-lwEqM5-i.js.map

Sorry, the diff of this file is too big to display

import { a as sweepStaleClaudeConfigMirrors, c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, o as sweepStalePeerAgentMdFiles, r as ensurePaths, s as sweepStaleRuntimeFiles, t as PATHS } from "./paths-lwEqM5-i.js";
export { PATHS, ensureClaudeConfigMirror, ensurePaths, removeOwnClaudeConfigMirror, sweepStaleClaudeConfigMirrors, sweepStalePeerAgentMdFiles, sweepStaleRuntimeFiles, writeRuntimeFileSecure };
+1
-1
{
"name": "github-router",
"version": "0.3.41",
"version": "0.3.42",
"license": "MIT",

@@ -5,0 +5,0 @@ "description": "A reverse proxy that exposes GitHub Copilot as OpenAI and Anthropic compatible API endpoints.",

import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-cZle37Jp.js";
import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
//#region src/lib/worker-agent/lifecycle.ts
/**
* Same regex worktree.ts uses for its per-call age sweep — kept in
* sync intentionally. `<pid>-<uuid>-<8hex>` strictly.
*/
const WORKTREE_DIR_NAME_RE = /^(\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/;
/**
* Cap on the ledger: how many repos we remember across boots, and how
* old an entry may be before it's pruned. Both are belt-and-suspenders
* — the per-call age sweep is the primary guard against accumulation
* inside any single repo.
*/
const LEDGER_MAX_ENTRIES = 100;
const LEDGER_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
/**
* Set-like in-memory registry of worktrees this proxy created. Engine
* passes it to `createWorktree` so per-call cleanup deletes the entry
* on success; the signal handlers walk what's left at shutdown.
*
* Not a bare `Set` because we want to expose only the operations we
* actually use, and we want a stable testable surface.
*/
var WorktreeRegistry = class {
entries = /* @__PURE__ */ new Set();
add(entry) {
this.entries.add(entry);
}
delete(entry) {
this.entries.delete(entry);
}
has(entry) {
return this.entries.has(entry);
}
values() {
return this.entries.values();
}
get size() {
return this.entries.size;
}
clear() {
this.entries.clear();
}
};
let _instanceUuid = null;
/**
* Stable UUID4 generated once per proxy process. Used in worktree
* dir/branch names so the boot sweep can reliably distinguish "this
* proxy's still-live worktrees" from "stranded dirs from a prior
* proxy that happens to have a recycled PID" — Docker PID-1 across
* container restarts is the classic case (peer-review HIGH finding).
*/
function getInstanceUuid() {
if (_instanceUuid === null) _instanceUuid = randomUUID();
return _instanceUuid;
}
let _registered = false;
let _activeRegistry = null;
let _exitHandler = null;
let _sigintHandler = null;
let _sigtermHandler = null;
/**
* Synchronous cleanup of every registry entry. Best-effort:
* `execFileSync` failures are swallowed (the dir may have been
* removed already, or git may not be on PATH any more in some
* environments). After a successful removal we drop the entry from
* the registry so a second call is a true no-op.
*
* Synchronous on purpose — exit handlers can't reliably await async
* work; the process would die before the promise settled.
*/
function sweepRegistry() {
if (!_activeRegistry) return;
const snapshot = [..._activeRegistry.values()];
for (const entry of snapshot) {
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"worktree",
"remove",
"--force",
entry.dir
], {
stdio: "ignore",
timeout: 1e4,
windowsHide: true
});
} catch {}
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"branch",
"-D",
entry.branch
], {
stdio: "ignore",
timeout: 5e3,
windowsHide: true
});
} catch {}
_activeRegistry.delete(entry);
}
}
/**
* Windows ConPTY / node-pty signal behavior:
*
* When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes
* the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the
* process group. Node.js translates this into SIGINT (NOT SIGTERM). The
* process has a ~5-second window before forced termination.
*
* Implication: the SIGTERM handler below may NEVER fire in node-pty
* environments. This is by design — the three-layer cleanup architecture
* ensures coverage:
* 1. Per-call cleanup (engine.ts finally block) — happy path
* 2. SIGINT handler (this file) — ConPTY close, Ctrl+C
* 3. `exit` handler (this file) — unconditional, fires on any exit
* 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery
*
* Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.
*/
/**
* Wire up SIGINT/SIGTERM/exit handlers that walk the registry and
* remove every entry. Idempotent: subsequent calls swap the registry
* pointer but do NOT register additional process listeners (otherwise
* we'd leak listeners on every `runWorkerAgent`).
*
* Signal handlers re-raise the signal after sweeping. Naively running
* the sweep on SIGINT/SIGTERM and returning would *suppress* the
* signal: Node defaults to terminating the process on these, but only
* if no user listener is attached. Once we attach a listener, the
* default action is cancelled and the process keeps running — which
* means Ctrl-C would clean worktrees but not actually exit, leaving
* orphan processes in dev. The `process.kill(pid, sig)` re-raise
* after removing our own listener restores the default behaviour
* (the second delivery now hits an empty listener list, so Node
* terminates with the conventional `128 + signum` exit code).
*/
function registerExitHandlers(registry) {
_activeRegistry = registry;
if (_registered) return;
_registered = true;
_exitHandler = () => sweepRegistry();
_sigintHandler = () => {
sweepRegistry();
if (_sigintHandler) process.off("SIGINT", _sigintHandler);
process.kill(process.pid, "SIGINT");
};
_sigtermHandler = () => {
sweepRegistry();
if (_sigtermHandler) process.off("SIGTERM", _sigtermHandler);
process.kill(process.pid, "SIGTERM");
};
process.on("SIGINT", _sigintHandler);
process.on("SIGTERM", _sigtermHandler);
process.on("exit", _exitHandler);
}
function ledgerPath() {
return path.join(PATHS.APP_DIR, "worker-repos.json");
}
async function readLedger() {
let raw;
try {
raw = await fs.readFile(ledgerPath(), "utf8");
} catch (err) {
if (err.code === "ENOENT") return { entries: [] };
return { entries: [] };
}
try {
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
const cleaned = [];
for (const e of parsed.entries) if (e && typeof e === "object" && typeof e.repoRoot === "string" && typeof e.lastSeenMs === "number") cleaned.push({
repoRoot: e.repoRoot,
lastSeenMs: e.lastSeenMs
});
return { entries: cleaned };
} catch {
return { entries: [] };
}
}
/**
* Per-process serializer for ledger writes. Multiple concurrent
* `recordWorkerRepo` calls (legitimate: several workers may start at
* once) would otherwise race read-modify-write on the JSON file. Each
* call chains onto the previous so the on-disk sequence is
* deterministic from this process's perspective.
*
* Cross-process safety is provided by the atomic temp+rename below,
* which makes the final state of the file always be a well-formed
* full snapshot from ONE writer — never a partial write or
* interleaved JSON.
*/
let _ledgerChain = Promise.resolve();
/**
* Append `repoRoot` to the ledger (or update its `lastSeenMs`).
* Atomic temp+rename per peer review.
*/
function recordWorkerRepo(repoRoot) {
const next = _ledgerChain.then(async () => {
await fs.mkdir(PATHS.APP_DIR, { recursive: true });
const filtered = (await readLedger()).entries.filter((e) => e.repoRoot !== repoRoot);
filtered.push({
repoRoot,
lastSeenMs: Date.now()
});
const now = Date.now();
const ledger = { entries: filtered.filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS).slice(-LEDGER_MAX_ENTRIES) };
const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
try {
await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2));
await fs.rename(tmp, ledgerPath());
} catch (err) {
await fs.unlink(tmp).catch(() => {});
throw err;
}
});
_ledgerChain = next.catch(() => void 0);
return next;
}
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err.code === "EPERM") return true;
return false;
}
}
/**
* Boot-time sweep. For every repo we recorded in the ledger,
* enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional
* location — for repos already inside a worktree, the actual
* `git-common-dir` may differ, in which case we'll miss this batch
* and the per-call age sweep will catch them within 7 days) and
* remove dirs that aren't owned by THIS proxy.
*
* Ownership rule: dir is "ours" iff its embedded PID is alive AND
* its embedded UUID equals `getInstanceUuid()`. Either condition
* failing → remove.
*/
async function sweepStaleWorktreesAtBoot() {
const ledger = await readLedger();
if (ledger.entries.length === 0) return;
const currentUuid = getInstanceUuid();
for (const entry of ledger.entries) {
const parent = path.join(entry.repoRoot, ".git", "worker-worktrees");
let names;
try {
names = await fs.readdir(parent);
} catch {
continue;
}
for (const name of names) {
const m = WORKTREE_DIR_NAME_RE.exec(name);
if (!m) continue;
const pid = Number.parseInt(m[1], 10);
const uuid = m[2];
if (isPidAlive(pid) && uuid === currentUuid) continue;
const fullDir = path.join(parent, name);
const branch = `worker/${pid}-${uuid}-${m[3]}`;
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"worktree",
"remove",
"--force",
fullDir
], {
stdio: "ignore",
timeout: 1e4,
windowsHide: true
});
} catch {}
try {
execFileSync("git", [
"-C",
entry.repoRoot,
"branch",
"-D",
branch
], {
stdio: "ignore",
timeout: 5e3,
windowsHide: true
});
} catch {}
try {
await fs.rm(fullDir, {
recursive: true,
force: true
});
} catch {}
}
}
}
//#endregion
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
//# sourceMappingURL=lifecycle-CpnAVVQ_.js.map
{"version":3,"file":"lifecycle-CpnAVVQ_.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
import "./paths-cZle37Jp.js";
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-CpnAVVQ_.js";
export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
import { a as sweepStaleClaudeConfigMirrors, c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, o as sweepStalePeerAgentMdFiles, r as ensurePaths, s as sweepStaleRuntimeFiles, t as PATHS } from "./paths-cZle37Jp.js";
export { PATHS, ensureClaudeConfigMirror, ensurePaths, removeOwnClaudeConfigMirror, sweepStaleClaudeConfigMirrors, sweepStalePeerAgentMdFiles, sweepStaleRuntimeFiles, writeRuntimeFileSecure };
import consola from "consola";
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
//#region src/lib/paths.ts
function appDir() {
return path.join(os.homedir(), ".local", "share", "github-router");
}
const PATHS = {
get APP_DIR() {
return appDir();
},
get GITHUB_TOKEN_PATH() {
return path.join(appDir(), "github_token");
},
get ERROR_LOG_PATH() {
return path.join(appDir(), "error.log");
},
get CODEX_HOME() {
return path.join(appDir(), "codex-isolated");
},
get CLAUDE_RUNTIME_DIR() {
return path.join(appDir(), "runtime");
},
get CLAUDE_CONFIG_DIR() {
return path.join(appDir(), "claude-config", claudeConfigDirSuffix());
}
};
/**
* Per-launch suffix for `PATHS.CLAUDE_CONFIG_DIR`. Lazily generated on
* first access and cached for the lifetime of the process so every
* caller (env-var injection in `getClaudeCodeEnvVars`,
* `ensureClaudeConfigMirror` provisioning, peer-agent `.md` writes
* under `<dir>/agents/`, the shutdown cleanup) resolves the same path.
*
* Shape: `<pid>-<8 hex>`. The PID prefix is what
* `sweepStaleClaudeConfigMirrors` keys off to drop orphans from
* crashed prior sessions; the 8-hex random suffix prevents collision
* if a future caller (tests, internal relaunch) ever clears the cache
* within a single PID lifetime.
*
* NOT exported — every consumer should go through `PATHS.CLAUDE_CONFIG_DIR`
* so the homedir-mock pattern used in the test suite keeps working.
*/
let _claudeConfigDirSuffix;
function claudeConfigDirSuffix() {
if (_claudeConfigDirSuffix === void 0) _claudeConfigDirSuffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
return _claudeConfigDirSuffix;
}
async function ensurePaths() {
await fs.mkdir(PATHS.APP_DIR, { recursive: true });
await fs.mkdir(PATHS.CODEX_HOME, { recursive: true });
await fs.mkdir(PATHS.CLAUDE_RUNTIME_DIR, { recursive: true });
await chmodIfPossible(PATHS.CLAUDE_RUNTIME_DIR, 448);
await ensureFile(PATHS.GITHUB_TOKEN_PATH);
await sweepStaleRuntimeFiles().catch((err) => {
consola.debug("Runtime sweep skipped:", err);
});
await sweepStaleClaudeConfigMirrors().catch((err) => {
consola.debug("Per-launch claude-config sweep skipped:", err);
});
await sweepStalePeerAgentMdFiles().catch((err) => {
consola.debug("Peer-agent .md sweep skipped:", err);
});
await (async () => {
await (await import("./lifecycle-DpnTmHCo.js")).sweepStaleWorktreesAtBoot();
})().catch((err) => {
consola.debug("Worker worktree boot sweep skipped:", err);
});
}
const CLAUDE_HOME_POLICY = new Map([
[".credentials.json", "ISOLATED"],
[".credentials.json.lock", "ISOLATED"],
[".oauth_refresh.lock", "ISOLATED"],
[".github-router-managed", "ISOLATED"],
["statsig", "ISOLATED"],
["cache", "ISOLATED"],
["logs", "ISOLATED"],
["paste-cache", "ISOLATED"],
["jobs", "ISOLATED"],
["daemon", "ISOLATED"],
["daemon.log", "ISOLATED"],
["projects", "SHARED"],
["sessions", "SHARED"],
["tasks", "SHARED"],
["todos", "SHARED"],
["transcripts", "SHARED"],
["shell-snapshots", "SHARED"],
["shell_snapshots", "SHARED"],
["plans", "SHARED"],
["file-history", "SHARED"],
["backups", "SHARED"]
]);
function policyFor(name) {
return CLAUDE_HOME_POLICY.get(name) ?? "MIRRORED";
}
/**
* Names with `SHARED` policy, materialized once for iteration in
* `ensureClaudeConfigMirror`'s post-copy phase.
*/
const SHARED_TOPLEVEL_NAMES = Array.from(CLAUDE_HOME_POLICY.entries()).filter(([, kind]) => kind === "SHARED").map(([name]) => name);
/**
* Marker file written into the router-owned CLAUDE_CONFIG_DIR so users
* (and our own future sweeps) can identify that the dir is managed by
* github-router. Content is informational only; no logic depends on
* its presence.
*/
const MANAGED_MARKER_FILENAME = ".github-router-managed";
/**
* Synthetic Console OAuth credential the router writes into its own
* `CLAUDE_CONFIG_DIR/.credentials.json` so spawned Claude Code (and
* any teammates it spawns) can authenticate without a real user
* `/login`.
*
* Schema verified verbatim from `claude` v2.1.140 binary, function
* `guH` (the credentials-save mutation). Fields:
* - `accessToken` — sent as `Authorization: Bearer ...` to the
* proxy. Proxy accepts any bearer (per CLAUDE.md "doesn't enforce
* auth").
* - `refreshToken` — only used by Claude Code's reactive refresh
* path (function `nH8`), which fires on 401 from upstream. The
* proxy maintains the no-401 invariant on the Anthropic-shape
* boundary, so this is never invoked. Synthetic value is fine.
* - `expiresAt` — far-future (2099-01-01 ms epoch). Sidesteps the
* proactive refresh path (`R8H(expiresAt)` returns false).
* - `scopes` — claude-ai-shaped so `tB(scopes)` returns true,
* making `Hq()` true (full feature surface, not "inference only").
* - `subscriptionType` — `"max"`. Pure client-side label
* (`e7()` / `Zc_()` / `CZ1()`); no server validation since
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` suppresses
* subscription-validation calls. Picks the most-permissive gating.
* - `rateLimitTier` — `"default_claude_max_20x"`. Paired with
* `subscriptionType:"max"` this is the real Max-20x tier, so the
* credential is internally consistent (vs the prior odd `max`+`null`).
* Verified live (claude v2.1.158) call-sites are cosmetic billing /
* upsell-suppression UI plus the `getPlanModeV2AgentCount` (`bGK`)
* `max && 20x → 3` branch — which `CLAUDE_CODE_PLAN_V2_AGENT_COUNT`
* (set to 7 in server-setup) already overrides, so this is
* belt-and-suspenders for the natural code path. No client-side quota
* enforcement keys off the tier (rate-limit UI reads server
* `x-ratelimit-*` headers; the proxy holds the no-429 invariant).
*/
const SYNTHETIC_CREDENTIAL = { claudeAiOauth: {
accessToken: "github-router-synthetic",
refreshToken: "github-router-synthetic",
expiresAt: 40709088e5,
scopes: ["user:inference", "user:profile"],
subscriptionType: "max",
rateLimitTier: "default_claude_max_20x",
clientId: "github-router"
} };
/**
* Snapshot-copy the user's `~/.claude/` into the router-owned
* CLAUDE_CONFIG_DIR (real files, not symlinks — symlinks don't isolate
* writes), classifying each top-level entry per `CLAUDE_HOME_POLICY`:
* ISOLATED entries are skipped, MIRRORED entries are copied, and
* SHARED entries become directory symlinks back to `~/.claude/<X>` so
* chat history (in `projects/<cwd-hash>/<session-uuid>.jsonl`) and
* other durable user state flow between proxy and plain-`claude`
* sessions. Then writes the synthetic `.credentials.json` so spawned
* Claude Code (and teammates that inherit `CLAUDE_CONFIG_DIR`)
* authenticate.
*
* Idempotent: only re-copies files whose source `mtime` is newer than
* target; SHARED-symlink creation no-ops when the symlink already
* points at the right target. Concurrent-safe: `mkdir({recursive:true})`
* is idempotent; symlinks are created via atomic temp+rename so two
* parallel github-router-claude startups can't race to EEXIST; the
* credentials write uses temp-file + atomic rename so Claude Code's
* `EZ1()` mtime watcher never sees a partial write.
*
* Walks with `lstat` (does NOT follow symlinks during traversal — a
* symlink-into-`/` would otherwise let the walk escape). Symlink leaves
* in the source tree are skipped during the MIRRORED copy walk (per the
* symlink-confused-deputy security finding); SHARED symlinks are
* created on the mirror side only, pointing at predetermined targets
* inside the user's real `~/.claude/`.
*
* Caller is expected to invoke this after `ensurePaths()` and before
* spawning Claude Code (`launchChild`). The mirror must exist before
* the child reads it. Currently called from the `claude` subcommand
* entry point only; `start` and `codex` subcommands don't need it.
*/
async function ensureClaudeConfigMirror(opts = {}) {
const realHome = opts.realHome ?? os.homedir();
const sourceDir = path.join(realHome, ".claude");
const targetDir = PATHS.CLAUDE_CONFIG_DIR;
await fs.mkdir(targetDir, {
recursive: true,
mode: 448
});
await chmodIfPossible(targetDir, 448);
let sourceExists = false;
try {
sourceExists = (await fs.stat(sourceDir)).isDirectory();
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot stat ${sourceDir}:`, err);
}
if (sourceExists) await mirrorDirRecursive(sourceDir, targetDir, "");
await fs.mkdir(path.join(targetDir, "agents"), { recursive: true });
for (const name of SHARED_TOPLEVEL_NAMES) await ensureSharedSymlink(name, sourceDir, targetDir).catch((err) => {
consola.debug(`ensureClaudeConfigMirror: SHARED symlink for ${name} skipped:`, err);
});
const credentialsPath = path.join(targetDir, ".credentials.json");
const desiredJson = JSON.stringify(SYNTHETIC_CREDENTIAL, null, 2);
let needsWrite = true;
try {
needsWrite = (await fs.readFile(credentialsPath, "utf8")).trim() !== desiredJson.trim();
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot read existing credentials:`, err);
}
if (needsWrite) {
const tempPath = `${credentialsPath}.${process.pid}.tmp`;
try {
await fs.writeFile(tempPath, desiredJson + "\n", {
mode: 384,
flag: "wx"
});
await fs.rename(tempPath, credentialsPath);
} catch (err) {
if (err.code === "EEXIST") consola.debug("ensureClaudeConfigMirror: concurrent credentials-write detected, skipping");
else {
await fs.unlink(tempPath).catch(() => {});
throw err;
}
}
}
await chmodIfPossible(credentialsPath, 384);
const markerPath = path.join(targetDir, MANAGED_MARKER_FILENAME);
let markerExists = false;
try {
const markerStat = await fs.lstat(markerPath);
if (markerStat.isFile()) markerExists = true;
else {
consola.warn(`ensureClaudeConfigMirror: ${markerPath} exists but is not a regular file (mode=${markerStat.mode.toString(8)}); refusing to overwrite. Inspect and remove manually if safe.`);
markerExists = true;
}
} catch (err) {
if (err.code !== "ENOENT") {
consola.debug(`ensureClaudeConfigMirror: cannot lstat marker:`, err);
markerExists = true;
}
}
if (!markerExists) {
const body = `Managed by github-router. Created ${(/* @__PURE__ */ new Date()).toISOString()}. Safe to delete (will be recreated).\n`;
await fs.writeFile(markerPath, body, {
mode: 384,
flag: "wx"
}).catch((err) => {
consola.debug(`ensureClaudeConfigMirror: marker write skipped:`, err);
});
}
}
/**
* Recursive snapshot-copy helper for `ensureClaudeConfigMirror`. Walks
* `sourceDir/relPath` and mirrors each entry into `targetDir/relPath`.
* - Top-level entries are dispatched on `policyFor(name)`:
* - `ISOLATED` → skipped entirely (no presence in mirror).
* - `SHARED` → skipped from the copy walk; handled by
* `ensureSharedSymlink` in the post-copy phase.
* - `MIRRORED` → copied as today.
* - Symlinks are skipped (not recreated) so the walk never follows out
* of `sourceDir` and we don't reintroduce a confused-deputy vector.
* - Files copy only if source mtime > target mtime (idempotent).
*/
async function mirrorDirRecursive(sourceDir, targetDir, relPath) {
const sourcePath = path.join(sourceDir, relPath);
let entries;
try {
entries = await fs.readdir(sourcePath);
} catch (err) {
if (err.code === "ENOENT") return;
consola.debug(`mirrorDirRecursive: cannot readdir ${sourcePath}:`, err);
return;
}
for (const name of entries) {
if (relPath === "") {
const policy = policyFor(name);
if (policy === "ISOLATED" || policy === "SHARED") continue;
}
const childRel = relPath === "" ? name : path.join(relPath, name);
const childSource = path.join(sourceDir, childRel);
const childTarget = path.join(targetDir, childRel);
let stats;
try {
stats = await fs.lstat(childSource);
} catch (err) {
consola.debug(`mirrorDirRecursive: cannot lstat ${childSource}:`, err);
continue;
}
if (stats.isSymbolicLink()) {
consola.debug(`mirrorDirRecursive: skipping symlink ${childSource} (security policy)`);
continue;
}
if (stats.isDirectory()) {
await fs.mkdir(childTarget, { recursive: true });
await mirrorDirRecursive(sourceDir, targetDir, childRel);
continue;
}
if (stats.isFile()) {
let needsCopy = true;
try {
const targetStat = await fs.lstat(childTarget);
if (targetStat.isFile() && targetStat.mtimeMs >= stats.mtimeMs) needsCopy = false;
} catch (err) {
if (err.code !== "ENOENT") consola.debug(`mirrorDirRecursive: lstat target ${childTarget}:`, err);
}
if (!needsCopy) continue;
try {
await fs.copyFile(childSource, childTarget, fs.constants.COPYFILE_FICLONE);
} catch (err) {
consola.debug(`mirrorDirRecursive: copy ${childSource} → ${childTarget}:`, err);
}
continue;
}
}
}
/**
* Create or refresh a directory symlink `<mirrorDir>/<name>` →
* `<sourceDir>/<name>` (i.e. `~/.local/share/github-router/claude-config/<X>`
* → `~/.claude/<X>`). Idempotent and concurrent-safe.
*
* Behavior depending on what's already at `<mirrorDir>/<name>`:
* - Symlink with the correct target → no-op.
* - Symlink with the wrong target → replace atomically.
* - Empty real directory (legacy mirror leftover with no proxy-session
* writes accumulated yet) → `rmdir` and replace with the symlink.
* Safe by definition: `fs.rmdir` only succeeds on empty dirs (POSIX),
* so there is nothing to lose. Smooths the upgrade path for users
* whose legacy mirror dirs were never written to.
* - Non-empty real directory or regular file → loud-warn and skip.
* Auto-deleting would destroy proxy-session writes from the prior
* version. The user is told the exact path and remediation.
* - ENOENT → create symlink atomically.
*
* Atomic-creation: symlinks are first written at a unique side-path
* (`<mirrorDir>/<name>.tmp.<pid>.<8 hex>`) and then `fs.rename()`d into
* place. POSIX `rename` is atomic and replaces an existing symlink in
* a single step, so two concurrent `github-router claude` startups can't
* race to `EEXIST` — the loser's rename just overwrites the winner's
* symlink with an identical one. Gemini-critic 3-lab-review finding.
*
* Pre-creates `~/.claude/<name>/` as a real directory if missing so
* Claude Code's writes through the symlink don't fail with ENOENT.
*/
async function ensureSharedSymlink(name, sourceDir, mirrorDir) {
const sourcePath = path.join(sourceDir, name);
const mirrorPath = path.join(mirrorDir, name);
try {
await fs.mkdir(sourcePath, { recursive: true });
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): cannot mkdir source ${sourcePath}:`, err);
return;
}
let existing = null;
try {
existing = await fs.lstat(mirrorPath);
} catch (err) {
if (err.code !== "ENOENT") {
consola.warn(`ensureSharedSymlink(${name}): cannot lstat ${mirrorPath}:`, err);
return;
}
}
if (existing?.isSymbolicLink()) {
const sourceReal = await fs.realpath(sourcePath).catch(() => null);
if (sourceReal === null) {
consola.warn(`ensureSharedSymlink(${name}): cannot resolve source ${sourcePath} — skipping junction creation to avoid silent every-startup churn. Inspect the source dir's permissions / OneDrive sync state and re-launch.`);
return;
}
const currentReal = await fs.realpath(mirrorPath).catch(() => null);
if (currentReal !== null && currentReal === sourceReal) return;
} else if (existing?.isDirectory()) try {
await fs.rmdir(mirrorPath);
} catch (err) {
consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory from an older github-router version; refusing to clobber. If you want chat-history continuity for "${name}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink (junction on Windows) on next launch. (rmdir error: ${err.code ?? "unknown"})`);
return;
}
else if (existing) {
consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a regular file at a SHARED symlink slot; refusing to clobber. Inspect and remove manually if safe; the mirror will create a symlink on next launch.`);
return;
}
const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
try {
await fs.symlink(sourcePath, tempPath, process.platform === "win32" ? "junction" : "dir");
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): symlink ${tempPath} failed:`, err);
return;
}
if (process.platform === "win32" && existing?.isSymbolicLink()) await fs.unlink(mirrorPath).catch(() => {});
try {
await fs.rename(tempPath, mirrorPath);
} catch (err) {
consola.warn(`ensureSharedSymlink(${name}): rename ${tempPath} → ${mirrorPath} failed:`, err);
await fs.unlink(tempPath).catch(() => {});
}
}
async function ensureFile(filePath) {
try {
await fs.access(filePath, fs.constants.W_OK);
} catch {
await fs.writeFile(filePath, "");
await fs.chmod(filePath, 384);
}
}
async function chmodIfPossible(target, mode) {
if (process.platform === "win32") return;
try {
await fs.chmod(target, mode);
} catch (err) {
consola.debug(`chmod ${target} ${mode.toString(8)} failed:`, err);
}
}
/**
* Write a runtime tempfile securely.
*
* - Mode `0o600` so other local users (multi-tenant boxes, shared
* dev containers) can't read the per-launch nonce or runtime URL.
* - `flag: "wx"` (O_CREAT | O_EXCL | O_WRONLY) refuses to overwrite
* an existing path. POSIX open(2) with O_EXCL also rejects
* pre-placed symlinks, killing the symlink-clobber attack vector.
* - The caller's responsibility to pick a path NOT yet in use.
* We intentionally do NOT pre-unlink: an `lstat` + `unlink` +
* `open(O_EXCL)` sequence still has a TOCTOU window where an
* attacker can drop a symlink between unlink and open. Letting
* `wx` fail is the safer behavior — surfaces the conflict
* instead of silently following.
*/
async function writeRuntimeFileSecure(filePath, content) {
await fs.writeFile(filePath, content, {
mode: 384,
flag: "wx"
});
}
/**
* Sweep stale runtime tempfiles. Removes files whose embedded PID is no
* longer a live process. A proxy crash (`kill -9`, OS reboot) leaves
* orphans that would otherwise accumulate forever — and worse, a stale
* config pointing at a now-recycled port could route MCP traffic to
* whatever process bound that port next.
*
* Naming convention: `peer-mcp-<pid>.json` and `peer-agents-<pid>.json`.
* Files not matching either pattern are left alone — this directory
* is shared with future runtime artifacts.
*
* We deliberately do NOT age-prune files whose PID is alive. A
* legitimately long-running proxy can have a tempfile older than any
* arbitrary threshold; deleting it out from under the live process
* breaks the spawned Claude Code child's MCP/agent wiring with no clean
* recovery. PID-wraparound risk is mitigated by (a) PID reuse on Linux
* being slow under typical loads, and (b) the file is only consulted by
* github-router itself — an unrelated process that inherits the PID
* never reads it.
*/
async function sweepStaleRuntimeFiles() {
const dir = PATHS.CLAUDE_RUNTIME_DIR;
let entries;
try {
entries = await fs.readdir(dir);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = /^peer-(?:mcp|agents)-(\d+)(?:-[0-9a-f]+)?\.json$/.exec(name);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
const filePath = path.join(dir, name);
if (isPidAlive(pid)) continue;
await fs.unlink(filePath).catch(() => {});
}
}
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err.code === "EPERM") return true;
return false;
}
}
/**
* Sweep stale peer-* subagent .md files from the router-owned
* `CLAUDE_CONFIG_DIR/agents/`. Phase 2.5 writes one .md per peer agent
* into Claude Code's agents directory (now our config dir's `agents/`
* subdir, since `getClaudeCodeEnvVars` points `CLAUDE_CONFIG_DIR` at
* `PATHS.CLAUDE_CONFIG_DIR`) so they appear in Claude Code's Task
* `subagent_type` enum. Files are named `peer-<pid>-<rand>-<agentName>.md`
* so this sweep can drop orphans from crashed prior proxy sessions
* without touching the user's own .md files (which were copied into
* the same dir during `ensureClaudeConfigMirror`).
*
* Same liveness rule as `sweepStaleRuntimeFiles`: only delete when the
* file's embedded PID is no longer alive. Live PIDs keep their files —
* a long-running proxy doesn't lose its agent registrations.
*
* Regex tightening (Phase 2.6, codex-critic + gemini-critic 2-lab finding):
* the original sweep regex `^peer-(\d+)(?:-[0-9a-f]+)?-.+\.md$` was too
* permissive — a user-authored `peer-12345-meeting-notes.md` matches
* (`12345` = "PID", `-meeting-notes` = trailing `.+`) and would be
* silently unlinked when 12345 happens to be a dead PID (overwhelmingly
* likely). Tightened to require BOTH the 8-hex-char random suffix AND
* an exact-match persona name suffix, eliminating the risk for any
* realistic user filename.
*/
async function sweepStalePeerAgentMdFiles() {
const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
let entries;
try {
entries = await fs.readdir(dir);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = PEER_AGENT_MD_FILENAME.exec(name);
if (!match) continue;
if (isPidAlive(Number.parseInt(match[1], 10))) continue;
await fs.unlink(path.join(dir, name)).catch(() => {});
}
}
/**
* Strict regex matching only files this proxy writes:
* peer-<pid>-<8 hex>-<exact persona/coordinator name>.md
* The persona-name allowlist is the load-bearing protection against
* deleting user files. Update this list whenever a new persona is added
* to `PERSONAS_READ` / `PERSONAS_WRITE` in `peer-mcp-personas.ts` or a
* new coordinator-style agent is added in `codex-mcp-config.ts`.
*/
const PEER_AGENT_MD_FILENAME = /^peer-(\d+)-[0-9a-f]{8}-(?:codex-critic|codex-reviewer|gemini-critic|codex-implementer|peer-review-coordinator)\.md$/;
/**
* Strict regex matching only per-launch claude-config mirror dirs this
* proxy creates: `<pid>-<8 hex>`. Anchored to the entire entry name so
* user-authored siblings under `<appDir>/claude-config/` (if any) are
* untouchable. The PID prefix is what `sweepStaleClaudeConfigMirrors`
* keys off; the 8-hex random suffix matches `randomBytes(4)` exactly
* (no `?` — files created by a different shape are not ours).
*/
const CLAUDE_CONFIG_MIRROR_DIR = /^(\d+)-[0-9a-f]{8}$/;
/**
* Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by
* crashed prior proxy sessions. Symmetric to `sweepStalePeerAgentMdFiles`
* — same liveness rule (only delete when the embedded PID is dead),
* same strict regex (the dir-name allowlist is the load-bearing
* protection against deleting user-authored siblings).
*
* Scans `<appDir>/claude-config/` (the parent of the per-launch dirs).
* Each entry whose name matches `<pid>-<8 hex>` AND whose PID is no
* longer alive is removed recursively. `fs.rm({recursive: true})`
* walks the tree calling `unlink` on symlinks/junctions rather than
* following them, so the SHARED junctions back to `~/.claude/<X>`
* are removed without touching their targets.
*
* Tolerates missing parent dir (first-ever launch, or user wiped it).
*/
async function sweepStaleClaudeConfigMirrors() {
const parent = path.join(appDir(), "claude-config");
let entries;
try {
entries = await fs.readdir(parent);
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
for (const name of entries) {
const match = CLAUDE_CONFIG_MIRROR_DIR.exec(name);
if (!match) continue;
if (isPidAlive(Number.parseInt(match[1], 10))) continue;
await fs.rm(path.join(parent, name), {
recursive: true,
force: true
}).catch((err) => {
consola.debug(`sweepStaleClaudeConfigMirrors: cannot rm ${name}:`, err);
});
}
}
/**
* Remove THIS launch's per-launch CLAUDE_CONFIG_DIR on shutdown.
* Best-effort: a failure here must not block process exit (the caller
* wraps this in a `.catch`-equivalent via `launchChild`'s onShutdown
* try/catch). Symmetric to `writePeerMcpRuntimeFiles`'s `cleanup()`:
* we own this dir for the lifetime of the proxy, so removing it on
* normal shutdown is correct; the boot-time sweep handles the
* abnormal-exit case.
*
* `fs.rm({recursive: true})` removes SHARED junctions via unlink
* (does NOT follow them into the user's real `~/.claude/<X>`).
*/
async function removeOwnClaudeConfigMirror() {
const dir = PATHS.CLAUDE_CONFIG_DIR;
await fs.rm(dir, {
recursive: true,
force: true
}).catch((err) => {
consola.debug(`removeOwnClaudeConfigMirror: rm ${dir} skipped:`, err);
});
}
//#endregion
export { sweepStaleClaudeConfigMirrors as a, writeRuntimeFileSecure as c, removeOwnClaudeConfigMirror as i, ensureClaudeConfigMirror as n, sweepStalePeerAgentMdFiles as o, ensurePaths as r, sweepStaleRuntimeFiles as s, PATHS as t };
//# sourceMappingURL=paths-cZle37Jp.js.map
{"version":3,"file":"paths-cZle37Jp.js","names":["_claudeConfigDirSuffix: string | undefined","CLAUDE_HOME_POLICY: ReadonlyMap<string, MirrorPolicy>","SHARED_TOPLEVEL_NAMES: ReadonlyArray<string>","entries: Array<string>","stats: Awaited<ReturnType<typeof fs.lstat>>","existing: Awaited<ReturnType<typeof fs.lstat>> | null"],"sources":["../src/lib/paths.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport os from \"node:os\"\nimport path from \"node:path\"\n\nimport consola from \"consola\"\n\nfunction appDir(): string {\n return path.join(os.homedir(), \".local\", \"share\", \"github-router\")\n}\n\nexport const PATHS = {\n get APP_DIR() {\n return appDir()\n },\n get GITHUB_TOKEN_PATH() {\n return path.join(appDir(), \"github_token\")\n },\n get ERROR_LOG_PATH() {\n return path.join(appDir(), \"error.log\")\n },\n /**\n * Isolated CODEX_HOME for the spawned Codex CLI. Masks any cached\n * ChatGPT subscription login (openai/codex#2733 — cached login can\n * override OPENAI_API_KEY) so the proxy's dummy key is authoritative.\n */\n get CODEX_HOME() {\n return path.join(appDir(), \"codex-isolated\")\n },\n /**\n * Runtime tempfiles for the per-launch peer-MCP wiring (the\n * `--mcp-config` JSON and `--agents` JSON written before spawning\n * Claude Code). Mode 0o700 to match the security review's mandate;\n * cleaned on shutdown via the per-launch `cleanup()`, plus a\n * boot-time sweep of stale files (dead PIDs, >24h old).\n */\n get CLAUDE_RUNTIME_DIR() {\n return path.join(appDir(), \"runtime\")\n },\n /**\n * Router-owned CLAUDE_CONFIG_DIR. The spawned Claude Code (and any\n * teammates it spawns via the agent-teams primitive) reads its\n * config — including `.credentials.json` — from this dir. We\n * snapshot-copy the user's `~/.claude/` here at startup (excluding\n * `.credentials.json` and volatile state), then write our own\n * synthetic Console OAuth credential. The teammate-spawn allowlist\n * propagates `CLAUDE_CONFIG_DIR` to children, so teammates find the\n * synthetic credential and authenticate instead of falling into the\n * \"Not logged in · Run /login\" gate that would otherwise leave\n * them mute. See `ensureClaudeConfigMirror` below.\n *\n * Per-launch dir: `<appDir>/claude-config/<pid>-<8 hex>`. Two\n * concurrent `github-router claude` launches each get their own\n * isolated mirror, so per-launch state (synthetic credential,\n * snapshot copy of `~/.claude/`, future per-launch `.claude.json`\n * mutation with the peer-MCP entry) cannot cross-talk. The\n * per-launch suffix is cached on first access (see\n * `claudeConfigDirSuffix()`) so all callers within a single proxy\n * lifetime see the same value. Boot-time `sweepStaleClaudeConfigMirrors`\n * reaps mirrors from crashed prior PIDs.\n */\n get CLAUDE_CONFIG_DIR() {\n return path.join(appDir(), \"claude-config\", claudeConfigDirSuffix())\n },\n}\n\n/**\n * Per-launch suffix for `PATHS.CLAUDE_CONFIG_DIR`. Lazily generated on\n * first access and cached for the lifetime of the process so every\n * caller (env-var injection in `getClaudeCodeEnvVars`,\n * `ensureClaudeConfigMirror` provisioning, peer-agent `.md` writes\n * under `<dir>/agents/`, the shutdown cleanup) resolves the same path.\n *\n * Shape: `<pid>-<8 hex>`. The PID prefix is what\n * `sweepStaleClaudeConfigMirrors` keys off to drop orphans from\n * crashed prior sessions; the 8-hex random suffix prevents collision\n * if a future caller (tests, internal relaunch) ever clears the cache\n * within a single PID lifetime.\n *\n * NOT exported — every consumer should go through `PATHS.CLAUDE_CONFIG_DIR`\n * so the homedir-mock pattern used in the test suite keeps working.\n */\nlet _claudeConfigDirSuffix: string | undefined\nfunction claudeConfigDirSuffix(): string {\n if (_claudeConfigDirSuffix === undefined) {\n _claudeConfigDirSuffix = `${process.pid}-${randomBytes(4).toString(\"hex\")}`\n }\n return _claudeConfigDirSuffix\n}\n\nexport async function ensurePaths(): Promise<void> {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n await fs.mkdir(PATHS.CODEX_HOME, { recursive: true })\n await fs.mkdir(PATHS.CLAUDE_RUNTIME_DIR, { recursive: true })\n // mkdir({recursive: true}) does NOT chmod an existing directory, so\n // explicitly tighten in case the dir was created by an older version.\n await chmodIfPossible(PATHS.CLAUDE_RUNTIME_DIR, 0o700)\n await ensureFile(PATHS.GITHUB_TOKEN_PATH)\n await sweepStaleRuntimeFiles().catch((err) => {\n consola.debug(\"Runtime sweep skipped:\", err)\n })\n // Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by\n // crashed prior proxy sessions BEFORE peer-agent .md sweep, since\n // the .md sweep is scoped to THIS launch's mirror and the per-launch\n // dir sweep is the parent cleanup for the same orphan class.\n await sweepStaleClaudeConfigMirrors().catch((err) => {\n consola.debug(\"Per-launch claude-config sweep skipped:\", err)\n })\n // Phase 2.5: also sweep stale peer-* subagent .md files from this\n // launch's CLAUDE_CONFIG_DIR/agents/ (defense-in-depth — should be\n // a no-op since the per-launch dir didn't exist before this PID\n // started; keeps the safety net in case a future change ever shares\n // an agents/ dir across launches).\n await sweepStalePeerAgentMdFiles().catch((err) => {\n consola.debug(\"Peer-agent .md sweep skipped:\", err)\n })\n // Worker-agent boot-time PID+instance safety net. Walks the\n // worker-repos.json ledger and removes any worktree dir whose\n // <pid> is dead OR whose <instance> UUID doesn't match this proxy.\n // Catches SIGKILL/OOM/host-crash escapees from prior sessions.\n // Lazy-imported so the worker-agent module doesn't get loaded by\n // every consumer of `paths.ts`.\n await (async () => {\n const mod = await import(\"./worker-agent/lifecycle\")\n await mod.sweepStaleWorktreesAtBoot()\n })().catch((err) => {\n consola.debug(\"Worker worktree boot sweep skipped:\", err)\n })\n}\n\n/**\n * Per-entry mirror policy. Every top-level entry under `~/.claude/` falls\n * into exactly one bucket; unlisted names default to `MIRRORED` so a future\n * Claude-Code-side addition flows through as a snapshot copy rather than\n * being silently lost.\n *\n * Three policies:\n *\n * - `ISOLATED` — not present in the mirror at all. The proxy owns its\n * own copy (synthetic `.credentials.json`, the `.github-router-managed`\n * marker) or the entry has no place in a proxy session\n * (`.credentials.json.lock`, `.oauth_refresh.lock` couple refresh loops\n * across sessions; `statsig/` is write-heavy and would constantly\n * re-copy; `cache/` and `logs/` are ephemeral; `paste-cache/` holds\n * sensitive clipboard extracts and shouldn't leak across sessions —\n * gemini-critic finding).\n *\n * - `SHARED` — symlink `<mirror>/<X>` → `~/.claude/<X>` so writes made\n * during the proxy session land in the user's real `~/.claude/` and\n * chat history is visible in both proxy and plain-`claude` sessions.\n * **Directories only.** Never use this for individual files: Claude\n * Code's atomic-write pattern (`fs.writeFile(temp); fs.rename(temp,\n * target)`) does NOT follow symlinks — a `rename` over the symlink\n * replaces it with a regular file, silently severing the connection\n * to `~/.claude/<X>`. Gemini-critic finding from the 3-lab review.\n *\n * - `MIRRORED` (default) — snapshot-copy with mtime skip. Use for static\n * or settings-shaped state where proxy-session writes should NOT flow\n * back to `~/.claude/` (e.g. `settings.json`, `.claude.json`,\n * `teams/`, `session-env/`) and for `agents/` — the proxy itself\n * writes per-launch `peer-<pid>-*.md` files into the mirror's `agents/`\n * and `sweepStalePeerAgentMdFiles` deletes them; a symlink would route\n * those writes/deletes into the user's real `~/.claude/agents/` and\n * destroy the user's own subagent files. **Hard regression test**:\n * `policyFor(\"agents\") === \"MIRRORED\"` is asserted in\n * `tests/lib-paths.test.ts` to prevent accidental reclassification.\n *\n * Sub-paths within MIRRORED dirs cascade recursively (existing behavior).\n */\ntype MirrorPolicy = \"ISOLATED\" | \"SHARED\" | \"MIRRORED\"\n\nconst CLAUDE_HOME_POLICY: ReadonlyMap<string, MirrorPolicy> = new Map<\n string,\n MirrorPolicy\n>([\n // ISOLATED\n [\".credentials.json\", \"ISOLATED\"],\n [\".credentials.json.lock\", \"ISOLATED\"],\n [\".oauth_refresh.lock\", \"ISOLATED\"],\n // Defense-in-depth: don't let a user-side file/symlink with the same\n // name as our marker collide with what we write. The marker write\n // logic also lstat-checks before writing (refuses if a non-regular\n // file exists at the path), but excluding it here removes the\n // attack vector entirely.\n [\".github-router-managed\", \"ISOLATED\"],\n [\"statsig\", \"ISOLATED\"],\n [\"cache\", \"ISOLATED\"],\n [\"logs\", \"ISOLATED\"],\n [\"paste-cache\", \"ISOLATED\"],\n [\"jobs\", \"ISOLATED\"],\n [\"daemon\", \"ISOLATED\"],\n [\"daemon.log\", \"ISOLATED\"],\n // SHARED — directories only (see policy doc above)\n [\"projects\", \"SHARED\"],\n [\"sessions\", \"SHARED\"],\n [\"tasks\", \"SHARED\"],\n [\"todos\", \"SHARED\"],\n [\"transcripts\", \"SHARED\"],\n [\"shell-snapshots\", \"SHARED\"],\n // The underscored variant is the historical exclude-list name; some\n // Claude Code versions may still use it. Classify SHARED so either\n // spelling resolves correctly.\n [\"shell_snapshots\", \"SHARED\"],\n [\"plans\", \"SHARED\"],\n [\"file-history\", \"SHARED\"],\n [\"backups\", \"SHARED\"],\n])\n\nfunction policyFor(name: string): MirrorPolicy {\n return CLAUDE_HOME_POLICY.get(name) ?? \"MIRRORED\"\n}\n\n/**\n * Test-only export: lets the test suite assert hard regression guards\n * such as `policyFor(\"agents\") === \"MIRRORED\"` (preventing accidental\n * reclassification that would let `sweepStalePeerAgentMdFiles` delete\n * files in the user's real `~/.claude/agents/`).\n */\nexport const __testing = { policyFor, ensureSharedSymlink }\n\n/**\n * Names with `SHARED` policy, materialized once for iteration in\n * `ensureClaudeConfigMirror`'s post-copy phase.\n */\nconst SHARED_TOPLEVEL_NAMES: ReadonlyArray<string> = Array.from(\n CLAUDE_HOME_POLICY.entries(),\n)\n .filter(([, kind]) => kind === \"SHARED\")\n .map(([name]) => name)\n\n/**\n * Marker file written into the router-owned CLAUDE_CONFIG_DIR so users\n * (and our own future sweeps) can identify that the dir is managed by\n * github-router. Content is informational only; no logic depends on\n * its presence.\n */\nconst MANAGED_MARKER_FILENAME = \".github-router-managed\"\n\n/**\n * Synthetic Console OAuth credential the router writes into its own\n * `CLAUDE_CONFIG_DIR/.credentials.json` so spawned Claude Code (and\n * any teammates it spawns) can authenticate without a real user\n * `/login`.\n *\n * Schema verified verbatim from `claude` v2.1.140 binary, function\n * `guH` (the credentials-save mutation). Fields:\n * - `accessToken` — sent as `Authorization: Bearer ...` to the\n * proxy. Proxy accepts any bearer (per CLAUDE.md \"doesn't enforce\n * auth\").\n * - `refreshToken` — only used by Claude Code's reactive refresh\n * path (function `nH8`), which fires on 401 from upstream. The\n * proxy maintains the no-401 invariant on the Anthropic-shape\n * boundary, so this is never invoked. Synthetic value is fine.\n * - `expiresAt` — far-future (2099-01-01 ms epoch). Sidesteps the\n * proactive refresh path (`R8H(expiresAt)` returns false).\n * - `scopes` — claude-ai-shaped so `tB(scopes)` returns true,\n * making `Hq()` true (full feature surface, not \"inference only\").\n * - `subscriptionType` — `\"max\"`. Pure client-side label\n * (`e7()` / `Zc_()` / `CZ1()`); no server validation since\n * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` suppresses\n * subscription-validation calls. Picks the most-permissive gating.\n * - `rateLimitTier` — `\"default_claude_max_20x\"`. Paired with\n * `subscriptionType:\"max\"` this is the real Max-20x tier, so the\n * credential is internally consistent (vs the prior odd `max`+`null`).\n * Verified live (claude v2.1.158) call-sites are cosmetic billing /\n * upsell-suppression UI plus the `getPlanModeV2AgentCount` (`bGK`)\n * `max && 20x → 3` branch — which `CLAUDE_CODE_PLAN_V2_AGENT_COUNT`\n * (set to 7 in server-setup) already overrides, so this is\n * belt-and-suspenders for the natural code path. No client-side quota\n * enforcement keys off the tier (rate-limit UI reads server\n * `x-ratelimit-*` headers; the proxy holds the no-429 invariant).\n */\nconst SYNTHETIC_CREDENTIAL = {\n claudeAiOauth: {\n accessToken: \"github-router-synthetic\",\n refreshToken: \"github-router-synthetic\",\n expiresAt: 4_070_908_800_000,\n scopes: [\"user:inference\", \"user:profile\"],\n subscriptionType: \"max\",\n rateLimitTier: \"default_claude_max_20x\",\n clientId: \"github-router\",\n },\n} as const\n\n/**\n * Snapshot-copy the user's `~/.claude/` into the router-owned\n * CLAUDE_CONFIG_DIR (real files, not symlinks — symlinks don't isolate\n * writes), classifying each top-level entry per `CLAUDE_HOME_POLICY`:\n * ISOLATED entries are skipped, MIRRORED entries are copied, and\n * SHARED entries become directory symlinks back to `~/.claude/<X>` so\n * chat history (in `projects/<cwd-hash>/<session-uuid>.jsonl`) and\n * other durable user state flow between proxy and plain-`claude`\n * sessions. Then writes the synthetic `.credentials.json` so spawned\n * Claude Code (and teammates that inherit `CLAUDE_CONFIG_DIR`)\n * authenticate.\n *\n * Idempotent: only re-copies files whose source `mtime` is newer than\n * target; SHARED-symlink creation no-ops when the symlink already\n * points at the right target. Concurrent-safe: `mkdir({recursive:true})`\n * is idempotent; symlinks are created via atomic temp+rename so two\n * parallel github-router-claude startups can't race to EEXIST; the\n * credentials write uses temp-file + atomic rename so Claude Code's\n * `EZ1()` mtime watcher never sees a partial write.\n *\n * Walks with `lstat` (does NOT follow symlinks during traversal — a\n * symlink-into-`/` would otherwise let the walk escape). Symlink leaves\n * in the source tree are skipped during the MIRRORED copy walk (per the\n * symlink-confused-deputy security finding); SHARED symlinks are\n * created on the mirror side only, pointing at predetermined targets\n * inside the user's real `~/.claude/`.\n *\n * Caller is expected to invoke this after `ensurePaths()` and before\n * spawning Claude Code (`launchChild`). The mirror must exist before\n * the child reads it. Currently called from the `claude` subcommand\n * entry point only; `start` and `codex` subcommands don't need it.\n */\nexport async function ensureClaudeConfigMirror(opts: {\n realHome?: string\n} = {}): Promise<void> {\n const realHome = opts.realHome ?? os.homedir()\n const sourceDir = path.join(realHome, \".claude\")\n const targetDir = PATHS.CLAUDE_CONFIG_DIR\n\n // 1. Create our config dir (idempotent, mode 0o700)\n await fs.mkdir(targetDir, { recursive: true, mode: 0o700 })\n await chmodIfPossible(targetDir, 0o700)\n\n // 2. Snapshot-copy from ~/.claude if it exists. Only MIRRORED entries\n // flow through this walk; ISOLATED and SHARED entries are filtered\n // in `mirrorDirRecursive` and handled separately.\n let sourceExists = false\n try {\n const sourceStat = await fs.stat(sourceDir)\n sourceExists = sourceStat.isDirectory()\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n consola.debug(`ensureClaudeConfigMirror: cannot stat ${sourceDir}:`, err)\n }\n }\n if (sourceExists) {\n await mirrorDirRecursive(sourceDir, targetDir, \"\")\n }\n\n // 3. Always ensure agents/ exists (even if user has none) so the\n // peer-agent .md emission has a place to write. Empty dir is fine.\n // agents/ is MIRRORED, not SHARED — the proxy writes per-launch\n // `peer-<pid>-*.md` files here and `sweepStalePeerAgentMdFiles`\n // deletes them; routing those operations into the user's real\n // `~/.claude/agents/` would destroy their custom subagent files.\n await fs.mkdir(path.join(targetDir, \"agents\"), { recursive: true })\n\n // 4. Create symlinks for SHARED entries so chat history (and other\n // durable user state) is visible in both proxy and plain-`claude`.\n for (const name of SHARED_TOPLEVEL_NAMES) {\n await ensureSharedSymlink(name, sourceDir, targetDir).catch((err) => {\n consola.debug(\n `ensureClaudeConfigMirror: SHARED symlink for ${name} skipped:`,\n err,\n )\n })\n }\n\n // 5. Write synthetic .credentials.json (only if content differs)\n const credentialsPath = path.join(targetDir, \".credentials.json\")\n const desiredJson = JSON.stringify(SYNTHETIC_CREDENTIAL, null, 2)\n let needsWrite = true\n try {\n const existing = await fs.readFile(credentialsPath, \"utf8\")\n needsWrite = existing.trim() !== desiredJson.trim()\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n consola.debug(`ensureClaudeConfigMirror: cannot read existing credentials:`, err)\n }\n }\n if (needsWrite) {\n // Atomic temp-file + rename so EZ1()'s mtime watcher doesn't see\n // a partial write. wx flag ensures we don't clobber a concurrent\n // writer's tempfile.\n const tempPath = `${credentialsPath}.${process.pid}.tmp`\n try {\n await fs.writeFile(tempPath, desiredJson + \"\\n\", { mode: 0o600, flag: \"wx\" })\n await fs.rename(tempPath, credentialsPath)\n } catch (err) {\n // EEXIST on the tempfile means another concurrent startup is\n // mid-write. Best-effort: skip — the other writer will produce\n // identical content (deterministic constant blob).\n if ((err as NodeJS.ErrnoException).code === \"EEXIST\") {\n consola.debug(\n \"ensureClaudeConfigMirror: concurrent credentials-write detected, skipping\",\n )\n } else {\n await fs.unlink(tempPath).catch(() => {})\n throw err\n }\n }\n }\n await chmodIfPossible(credentialsPath, 0o600)\n\n // 6. Write/refresh marker file. Use lstat (not access) to detect\n // symlinks at the marker path — a previously-mirrored or\n // user-placed symlink could otherwise let our `fs.writeFile`\n // follow through to an arbitrary target. With the symlink-skip\n // policy in `mirrorDirRecursive` this is defense-in-depth, but\n // cheap and definitive.\n const markerPath = path.join(targetDir, MANAGED_MARKER_FILENAME)\n let markerExists = false\n try {\n const markerStat = await fs.lstat(markerPath)\n if (markerStat.isFile()) {\n markerExists = true\n } else {\n // Anything non-regular (symlink, dir, special file) is a red flag —\n // refuse to overwrite, log loudly. The user can investigate.\n consola.warn(\n `ensureClaudeConfigMirror: ${markerPath} exists but is not a regular file (mode=${markerStat.mode.toString(8)}); refusing to overwrite. Inspect and remove manually if safe.`,\n )\n markerExists = true\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n consola.debug(`ensureClaudeConfigMirror: cannot lstat marker:`, err)\n markerExists = true\n }\n }\n if (!markerExists) {\n const body = `Managed by github-router. Created ${new Date().toISOString()}. Safe to delete (will be recreated).\\n`\n // wx flag (O_CREAT | O_EXCL) refuses to clobber an existing\n // file or symlink (POSIX O_EXCL behavior) — additional protection\n // against the marker-symlink confused-deputy vector.\n await fs\n .writeFile(markerPath, body, { mode: 0o600, flag: \"wx\" })\n .catch((err) => {\n consola.debug(`ensureClaudeConfigMirror: marker write skipped:`, err)\n })\n }\n}\n\n/**\n * Recursive snapshot-copy helper for `ensureClaudeConfigMirror`. Walks\n * `sourceDir/relPath` and mirrors each entry into `targetDir/relPath`.\n * - Top-level entries are dispatched on `policyFor(name)`:\n * - `ISOLATED` → skipped entirely (no presence in mirror).\n * - `SHARED` → skipped from the copy walk; handled by\n * `ensureSharedSymlink` in the post-copy phase.\n * - `MIRRORED` → copied as today.\n * - Symlinks are skipped (not recreated) so the walk never follows out\n * of `sourceDir` and we don't reintroduce a confused-deputy vector.\n * - Files copy only if source mtime > target mtime (idempotent).\n */\nasync function mirrorDirRecursive(\n sourceDir: string,\n targetDir: string,\n relPath: string,\n): Promise<void> {\n const sourcePath = path.join(sourceDir, relPath)\n let entries: Array<string>\n try {\n entries = await fs.readdir(sourcePath)\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return\n consola.debug(`mirrorDirRecursive: cannot readdir ${sourcePath}:`, err)\n return\n }\n for (const name of entries) {\n // Policy dispatch at top-level only. Sub-paths within MIRRORED\n // dirs always cascade as MIRRORED.\n if (relPath === \"\") {\n const policy = policyFor(name)\n if (policy === \"ISOLATED\" || policy === \"SHARED\") continue\n }\n const childRel = relPath === \"\" ? name : path.join(relPath, name)\n const childSource = path.join(sourceDir, childRel)\n const childTarget = path.join(targetDir, childRel)\n let stats: Awaited<ReturnType<typeof fs.lstat>>\n try {\n stats = await fs.lstat(childSource)\n } catch (err) {\n consola.debug(`mirrorDirRecursive: cannot lstat ${childSource}:`, err)\n continue\n }\n if (stats.isSymbolicLink()) {\n // Skip symlinks during mirror copy. gemini-critic security finding:\n // recreating user symlinks in our mirror creates a confused-deputy\n // vector — a previously prompt-injected process could place\n // `~/.claude/<X>` → `/some/sensitive/file`, our walker would mirror\n // it, and any subsequent write to `<mirror>/<X>` (by us or by\n // Claude Code) would follow the symlink and overwrite the target.\n // Snapshot-copy semantics make symlink preservation moot anyway:\n // a snapshot is a point-in-time content copy, and a symlink\n // recreated in the mirror points at exactly the same target as\n // the original would have — the user-side symlink is sufficient.\n // If a user has a legitimate need for a symlink to be visible\n // through the proxy session, they can create the equivalent\n // symlink in their `~/.claude/` directly and it'll be reachable\n // — they just won't see it in our mirror dir.\n consola.debug(`mirrorDirRecursive: skipping symlink ${childSource} (security policy)`)\n continue\n }\n if (stats.isDirectory()) {\n await fs.mkdir(childTarget, { recursive: true })\n await mirrorDirRecursive(sourceDir, targetDir, childRel)\n continue\n }\n if (stats.isFile()) {\n // mtime-based skip — only copy if source is newer than target.\n let needsCopy = true\n try {\n const targetStat = await fs.lstat(childTarget)\n if (targetStat.isFile() && targetStat.mtimeMs >= stats.mtimeMs) {\n needsCopy = false\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n consola.debug(`mirrorDirRecursive: lstat target ${childTarget}:`, err)\n }\n }\n if (!needsCopy) continue\n try {\n await fs.copyFile(childSource, childTarget, fs.constants.COPYFILE_FICLONE)\n } catch (err) {\n consola.debug(`mirrorDirRecursive: copy ${childSource} → ${childTarget}:`, err)\n }\n continue\n }\n // Skip other inode types (sockets, devices, fifos) silently.\n }\n}\n\n/**\n * Create or refresh a directory symlink `<mirrorDir>/<name>` →\n * `<sourceDir>/<name>` (i.e. `~/.local/share/github-router/claude-config/<X>`\n * → `~/.claude/<X>`). Idempotent and concurrent-safe.\n *\n * Behavior depending on what's already at `<mirrorDir>/<name>`:\n * - Symlink with the correct target → no-op.\n * - Symlink with the wrong target → replace atomically.\n * - Empty real directory (legacy mirror leftover with no proxy-session\n * writes accumulated yet) → `rmdir` and replace with the symlink.\n * Safe by definition: `fs.rmdir` only succeeds on empty dirs (POSIX),\n * so there is nothing to lose. Smooths the upgrade path for users\n * whose legacy mirror dirs were never written to.\n * - Non-empty real directory or regular file → loud-warn and skip.\n * Auto-deleting would destroy proxy-session writes from the prior\n * version. The user is told the exact path and remediation.\n * - ENOENT → create symlink atomically.\n *\n * Atomic-creation: symlinks are first written at a unique side-path\n * (`<mirrorDir>/<name>.tmp.<pid>.<8 hex>`) and then `fs.rename()`d into\n * place. POSIX `rename` is atomic and replaces an existing symlink in\n * a single step, so two concurrent `github-router claude` startups can't\n * race to `EEXIST` — the loser's rename just overwrites the winner's\n * symlink with an identical one. Gemini-critic 3-lab-review finding.\n *\n * Pre-creates `~/.claude/<name>/` as a real directory if missing so\n * Claude Code's writes through the symlink don't fail with ENOENT.\n */\nasync function ensureSharedSymlink(\n name: string,\n sourceDir: string,\n mirrorDir: string,\n): Promise<void> {\n const sourcePath = path.join(sourceDir, name)\n const mirrorPath = path.join(mirrorDir, name)\n\n // 1. Ensure the source directory exists. Without this, Claude Code's\n // writes through the symlink (e.g. `projects/<hash>/foo.jsonl`)\n // fail with ENOENT on the parent dir.\n try {\n await fs.mkdir(sourcePath, { recursive: true })\n } catch (err) {\n // Escalated from debug → warn per the CLAUDE.md \"smoking gun\"\n // rule (consistent with the symlink and rename catches below):\n // if the source dir cannot be created (e.g. a stray regular file\n // sitting at `~/.claude/projects`, perms blocking mkdir on a\n // corp-managed Windows box, OneDrive cloud-only reparse point),\n // ensureSharedSymlink returns without creating a junction. The\n // spawned Claude Code child then writes to the REAL `~/.claude`\n // while the proxy reads from the mirror — exactly the split-brain\n // pattern this whole function exists to prevent. Silent debug-log\n // hid this from us once already; warn so the user sees the cause.\n consola.warn(\n `ensureSharedSymlink(${name}): cannot mkdir source ${sourcePath}:`,\n err,\n )\n return\n }\n\n // 2. Inspect the mirror-side slot.\n let existing: Awaited<ReturnType<typeof fs.lstat>> | null = null\n try {\n existing = await fs.lstat(mirrorPath)\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n // Escalated from debug → warn per the CLAUDE.md \"smoking gun\"\n // rule (consistent with the other fs catches in this function):\n // ENOENT is the only expected-and-benign failure mode here\n // (slot doesn't exist yet — falls through to create). Any other\n // lstat failure (EACCES, ELOOP, EIO from a sketchy reparse\n // point) means we bail without creating the junction, which\n // silently leaves the proxy and child diverged. A visible warn\n // surfaces the root cause instead of a mysteriously missing\n // junction.\n consola.warn(\n `ensureSharedSymlink(${name}): cannot lstat ${mirrorPath}:`,\n err,\n )\n return\n }\n }\n\n if (existing?.isSymbolicLink()) {\n // Resolve both sides to their canonical absolute paths and compare.\n // We use `fs.realpath` rather than the raw `fs.readlink()` output\n // because Windows junctions resolve via readlink to `\\\\?\\`-prefixed\n // device-namespace paths (e.g. `\\\\?\\C:\\Users\\foo\\.claude\\projects`)\n // while we wrote the plain absolute `sourcePath` (e.g.\n // `C:\\Users\\foo\\.claude\\projects`) with `fs.symlink`. A literal\n // `===` on the raw readlink output never matched on Windows, so\n // the fast path silently failed and every startup tore down +\n // recreated all 9 SHARED junctions — masked locally because NTFS\n // File System Tunneling forges the creation timestamp for a name\n // deleted and recreated within 15 s (the per-startup churn was\n // real, the ctime-stable assertion was a false negative). The\n // realpath comparison canonicalizes both forms to the same string\n // on POSIX and Windows alike, and as a bonus handles drive-letter\n // casing / trailing-slash differences too. The extra two syscalls\n // per slot are negligible at proxy startup (runs once per launch).\n //\n // CRITICAL: sourceReal and currentReal are NOT treated symmetrically.\n // If `sourceReal` is null (we just mkdir'd it above, but realpath\n // failed — OneDrive cloud-only reparse point, EACCES on parent,\n // EXDEV mount oddity), we WARN AND RETURN rather than fall through.\n // Falling through would do unlink+symlink+rename with the same\n // failing realpath next launch — silent every-startup churn, the\n // exact bug class round-3 G2 fixed in a different code path.\n // `currentReal === null` is benign (broken/wrong slot — replace).\n const sourceReal = await fs.realpath(sourcePath).catch(() => null)\n if (sourceReal === null) {\n consola.warn(\n `ensureSharedSymlink(${name}): cannot resolve source ${sourcePath} ` +\n `— skipping junction creation to avoid silent every-startup churn. ` +\n `Inspect the source dir's permissions / OneDrive sync state and re-launch.`,\n )\n return\n }\n const currentReal = await fs.realpath(mirrorPath).catch(() => null)\n if (currentReal !== null && currentReal === sourceReal) {\n return\n }\n // Wrong target (or unresolvable mirror) — fall through to the\n // atomic-rename replace path.\n } else if (existing?.isDirectory()) {\n // Legacy real directory at the slot. Try `fs.rmdir` — on POSIX it\n // succeeds ONLY if the directory is empty, so there's nothing to\n // lose. If it's non-empty (ENOTEMPTY) or any other failure occurs,\n // fall back to the warn-and-skip path so we never auto-clobber\n // user data.\n try {\n await fs.rmdir(mirrorPath)\n // Empty dir reaped — fall through to the atomic-rename create path.\n } catch (err) {\n consola.warn(\n `ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory ` +\n `from an older github-router version; refusing to clobber. ` +\n `If you want chat-history continuity for \"${name}\", move its ` +\n `contents into ${sourcePath}/ then delete ${mirrorPath}; the ` +\n `mirror will create a symlink (junction on Windows) on next launch. ` +\n `(rmdir error: ${(err as NodeJS.ErrnoException).code ?? \"unknown\"})`,\n )\n return\n }\n } else if (existing) {\n // Regular file (or special inode like a socket) — never auto-clobber.\n consola.warn(\n `ensureClaudeConfigMirror: ${mirrorPath} is a regular file at a ` +\n `SHARED symlink slot; refusing to clobber. Inspect and remove ` +\n `manually if safe; the mirror will create a symlink on next launch.`,\n )\n return\n }\n\n // 3. Atomic-rename creation: symlink to a unique temp path, then\n // rename over the slot. `fs.rename` replaces existing symlinks\n // atomically on POSIX and is safe against concurrent racers.\n // On Windows, MoveFileEx with MOVEFILE_REPLACE_EXISTING does NOT\n // replace an existing directory or junction destination\n // (npm/cli#9021), so when the slot already holds a wrong-target\n // junction we must explicitly unlink it first. The sub-millisecond\n // window of no-link is acceptable: ensureClaudeConfigMirror is\n // idempotent under concurrency and only runs at proxy startup,\n // before any spawned Claude Code child has been launched.\n const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString(\"hex\")}`\n try {\n await fs.symlink(\n sourcePath,\n tempPath,\n process.platform === \"win32\" ? \"junction\" : \"dir\",\n )\n } catch (err) {\n // Escalated from debug → warn per the CLAUDE.md \"smoking gun\" rule:\n // the rule applies to ALL fs catches in this function, not just the\n // rename one. The temp path is per-pid + 8-hex random so EEXIST is\n // essentially impossible — any failure here (EPERM on Windows\n // without DevMode, EXDEV cross-volume, ENOSPC, …) is a real\n // operational problem the user needs to see.\n consola.warn(\n `ensureSharedSymlink(${name}): symlink ${tempPath} failed:`,\n err,\n )\n return\n }\n if (process.platform === \"win32\" && existing?.isSymbolicLink()) {\n // Windows-only: clear the wrong-target junction so the rename\n // below can land. Best-effort — if a concurrent racer already\n // unlinked it, the rename succeeds as a CREATE; if a concurrent\n // racer already replaced it with a fresh junction, the rename\n // hits the catch below and we surface a warn.\n await fs.unlink(mirrorPath).catch(() => {})\n }\n try {\n await fs.rename(tempPath, mirrorPath)\n } catch (err) {\n // Escalated from debug → warn per the CLAUDE.md \"smoking gun\"\n // rule (consistent with the fs.symlink catch above): a silent\n // debug log here previously hid the Windows rename-replace bug\n // (junction-over-junction MoveFileEx EPERM). Post-fix, rename\n // failures should be rare and visible.\n consola.warn(\n `ensureSharedSymlink(${name}): rename ${tempPath} → ${mirrorPath} failed:`,\n err,\n )\n await fs.unlink(tempPath).catch(() => {})\n }\n}\n\nasync function ensureFile(filePath: string): Promise<void> {\n try {\n await fs.access(filePath, fs.constants.W_OK)\n } catch {\n await fs.writeFile(filePath, \"\")\n await fs.chmod(filePath, 0o600)\n }\n}\n\nasync function chmodIfPossible(target: string, mode: number): Promise<void> {\n if (process.platform === \"win32\") return // Windows chmod is no-op-ish\n try {\n await fs.chmod(target, mode)\n } catch (err) {\n consola.debug(`chmod ${target} ${mode.toString(8)} failed:`, err)\n }\n}\n\n/**\n * Write a runtime tempfile securely.\n *\n * - Mode `0o600` so other local users (multi-tenant boxes, shared\n * dev containers) can't read the per-launch nonce or runtime URL.\n * - `flag: \"wx\"` (O_CREAT | O_EXCL | O_WRONLY) refuses to overwrite\n * an existing path. POSIX open(2) with O_EXCL also rejects\n * pre-placed symlinks, killing the symlink-clobber attack vector.\n * - The caller's responsibility to pick a path NOT yet in use.\n * We intentionally do NOT pre-unlink: an `lstat` + `unlink` +\n * `open(O_EXCL)` sequence still has a TOCTOU window where an\n * attacker can drop a symlink between unlink and open. Letting\n * `wx` fail is the safer behavior — surfaces the conflict\n * instead of silently following.\n */\nexport async function writeRuntimeFileSecure(\n filePath: string,\n content: string,\n): Promise<void> {\n await fs.writeFile(filePath, content, { mode: 0o600, flag: \"wx\" })\n}\n\n/**\n * Sweep stale runtime tempfiles. Removes files whose embedded PID is no\n * longer a live process. A proxy crash (`kill -9`, OS reboot) leaves\n * orphans that would otherwise accumulate forever — and worse, a stale\n * config pointing at a now-recycled port could route MCP traffic to\n * whatever process bound that port next.\n *\n * Naming convention: `peer-mcp-<pid>.json` and `peer-agents-<pid>.json`.\n * Files not matching either pattern are left alone — this directory\n * is shared with future runtime artifacts.\n *\n * We deliberately do NOT age-prune files whose PID is alive. A\n * legitimately long-running proxy can have a tempfile older than any\n * arbitrary threshold; deleting it out from under the live process\n * breaks the spawned Claude Code child's MCP/agent wiring with no clean\n * recovery. PID-wraparound risk is mitigated by (a) PID reuse on Linux\n * being slow under typical loads, and (b) the file is only consulted by\n * github-router itself — an unrelated process that inherits the PID\n * never reads it.\n */\nexport async function sweepStaleRuntimeFiles(): Promise<void> {\n const dir = PATHS.CLAUDE_RUNTIME_DIR\n let entries: Array<string>\n try {\n entries = await fs.readdir(dir)\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return\n throw err\n }\n\n for (const name of entries) {\n // Match both legacy `peer-mcp-<pid>.json` and current\n // `peer-mcp-<pid>-<rand>.json` filenames so we can clean up either.\n const match = /^peer-(?:mcp|agents)-(\\d+)(?:-[0-9a-f]+)?\\.json$/.exec(name)\n if (!match) continue\n const pid = Number.parseInt(match[1], 10)\n const filePath = path.join(dir, name)\n\n if (isPidAlive(pid)) continue\n\n await fs.unlink(filePath).catch(() => {\n // already gone or unreadable, fine\n })\n }\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n // signal 0 = check existence without delivering a signal. EPERM\n // means the process exists but we can't signal it (which is still\n // \"alive\" for our purposes); ESRCH means it's gone.\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Sweep stale peer-* subagent .md files from the router-owned\n * `CLAUDE_CONFIG_DIR/agents/`. Phase 2.5 writes one .md per peer agent\n * into Claude Code's agents directory (now our config dir's `agents/`\n * subdir, since `getClaudeCodeEnvVars` points `CLAUDE_CONFIG_DIR` at\n * `PATHS.CLAUDE_CONFIG_DIR`) so they appear in Claude Code's Task\n * `subagent_type` enum. Files are named `peer-<pid>-<rand>-<agentName>.md`\n * so this sweep can drop orphans from crashed prior proxy sessions\n * without touching the user's own .md files (which were copied into\n * the same dir during `ensureClaudeConfigMirror`).\n *\n * Same liveness rule as `sweepStaleRuntimeFiles`: only delete when the\n * file's embedded PID is no longer alive. Live PIDs keep their files —\n * a long-running proxy doesn't lose its agent registrations.\n *\n * Regex tightening (Phase 2.6, codex-critic + gemini-critic 2-lab finding):\n * the original sweep regex `^peer-(\\d+)(?:-[0-9a-f]+)?-.+\\.md$` was too\n * permissive — a user-authored `peer-12345-meeting-notes.md` matches\n * (`12345` = \"PID\", `-meeting-notes` = trailing `.+`) and would be\n * silently unlinked when 12345 happens to be a dead PID (overwhelmingly\n * likely). Tightened to require BOTH the 8-hex-char random suffix AND\n * an exact-match persona name suffix, eliminating the risk for any\n * realistic user filename.\n */\nexport async function sweepStalePeerAgentMdFiles(): Promise<void> {\n const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, \"agents\")\n let entries: Array<string>\n try {\n entries = await fs.readdir(dir)\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return\n throw err\n }\n for (const name of entries) {\n const match = PEER_AGENT_MD_FILENAME.exec(name)\n if (!match) continue\n const pid = Number.parseInt(match[1], 10)\n if (isPidAlive(pid)) continue\n await fs.unlink(path.join(dir, name)).catch(() => {\n // already gone or unreadable, fine\n })\n }\n}\n\n/**\n * Strict regex matching only files this proxy writes:\n * peer-<pid>-<8 hex>-<exact persona/coordinator name>.md\n * The persona-name allowlist is the load-bearing protection against\n * deleting user files. Update this list whenever a new persona is added\n * to `PERSONAS_READ` / `PERSONAS_WRITE` in `peer-mcp-personas.ts` or a\n * new coordinator-style agent is added in `codex-mcp-config.ts`.\n */\nconst PEER_AGENT_MD_FILENAME =\n /^peer-(\\d+)-[0-9a-f]{8}-(?:codex-critic|codex-reviewer|gemini-critic|codex-implementer|peer-review-coordinator)\\.md$/\n\n/**\n * Strict regex matching only per-launch claude-config mirror dirs this\n * proxy creates: `<pid>-<8 hex>`. Anchored to the entire entry name so\n * user-authored siblings under `<appDir>/claude-config/` (if any) are\n * untouchable. The PID prefix is what `sweepStaleClaudeConfigMirrors`\n * keys off; the 8-hex random suffix matches `randomBytes(4)` exactly\n * (no `?` — files created by a different shape are not ours).\n */\nconst CLAUDE_CONFIG_MIRROR_DIR = /^(\\d+)-[0-9a-f]{8}$/\n\n/**\n * Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by\n * crashed prior proxy sessions. Symmetric to `sweepStalePeerAgentMdFiles`\n * — same liveness rule (only delete when the embedded PID is dead),\n * same strict regex (the dir-name allowlist is the load-bearing\n * protection against deleting user-authored siblings).\n *\n * Scans `<appDir>/claude-config/` (the parent of the per-launch dirs).\n * Each entry whose name matches `<pid>-<8 hex>` AND whose PID is no\n * longer alive is removed recursively. `fs.rm({recursive: true})`\n * walks the tree calling `unlink` on symlinks/junctions rather than\n * following them, so the SHARED junctions back to `~/.claude/<X>`\n * are removed without touching their targets.\n *\n * Tolerates missing parent dir (first-ever launch, or user wiped it).\n */\nexport async function sweepStaleClaudeConfigMirrors(): Promise<void> {\n const parent = path.join(appDir(), \"claude-config\")\n let entries: Array<string>\n try {\n entries = await fs.readdir(parent)\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return\n throw err\n }\n for (const name of entries) {\n const match = CLAUDE_CONFIG_MIRROR_DIR.exec(name)\n if (!match) continue\n const pid = Number.parseInt(match[1], 10)\n if (isPidAlive(pid)) continue\n await fs\n .rm(path.join(parent, name), { recursive: true, force: true })\n .catch((err) => {\n // Best-effort: stale-dir cleanup must never block startup.\n // Common failure modes (worth surviving silently): an EBUSY/EPERM\n // on Windows if a leftover handle is still open, or a stray\n // root-owned file inside the dir from a previous run with\n // different permissions.\n consola.debug(\n `sweepStaleClaudeConfigMirrors: cannot rm ${name}:`,\n err,\n )\n })\n }\n}\n\n/**\n * Remove THIS launch's per-launch CLAUDE_CONFIG_DIR on shutdown.\n * Best-effort: a failure here must not block process exit (the caller\n * wraps this in a `.catch`-equivalent via `launchChild`'s onShutdown\n * try/catch). Symmetric to `writePeerMcpRuntimeFiles`'s `cleanup()`:\n * we own this dir for the lifetime of the proxy, so removing it on\n * normal shutdown is correct; the boot-time sweep handles the\n * abnormal-exit case.\n *\n * `fs.rm({recursive: true})` removes SHARED junctions via unlink\n * (does NOT follow them into the user's real `~/.claude/<X>`).\n */\nexport async function removeOwnClaudeConfigMirror(): Promise<void> {\n const dir = PATHS.CLAUDE_CONFIG_DIR\n await fs.rm(dir, { recursive: true, force: true }).catch((err) => {\n consola.debug(`removeOwnClaudeConfigMirror: rm ${dir} skipped:`, err)\n })\n}\n"],"mappings":"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"}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display