github-router
Advanced tools
| 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
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1965311
3.39%19642
2.3%33
3.13%