github-router 0.3.250 → 0.3.255
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attribution-settings-CegtE0TY.js +2347 -0
- package/dist/attribution-settings-CegtE0TY.js.map +1 -0
- package/dist/auth-CP4q2CjO.js +44 -0
- package/dist/auth-CP4q2CjO.js.map +1 -0
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/check-usage-6PB5IUFb.js +43 -0
- package/dist/check-usage-6PB5IUFb.js.map +1 -0
- package/dist/claude-BiZxoNWq.js +593 -0
- package/dist/claude-BiZxoNWq.js.map +1 -0
- package/dist/client-CQMfroGV.js +413 -0
- package/dist/client-CQMfroGV.js.map +1 -0
- package/dist/codex-ST21X3WF.js +85 -0
- package/dist/codex-ST21X3WF.js.map +1 -0
- package/dist/debug-CjmE9sgG.js +81 -0
- package/dist/debug-CjmE9sgG.js.map +1 -0
- package/dist/engine-DNCX0Hfd.js +2 -0
- package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
- package/dist/exec-DTO2oin0.js.map +1 -0
- package/dist/file-log-reporter-IFQcL5IK.js +158 -0
- package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
- package/dist/gate-discovery-CepmbZnc.js +635 -0
- package/dist/gate-discovery-CepmbZnc.js.map +1 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
- package/dist/hook-mcp-client-DBvm9608.js +136 -0
- package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
- package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
- package/dist/internal-plan-review-ipnT3bhj.js +114 -0
- package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
- package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
- package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
- package/dist/internal-session-bind-DwK2iGW0.js +133 -0
- package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
- package/dist/internal-stop-hook-CNPGJm63.js +212 -0
- package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
- package/dist/internal-stop-review-AUcFssrW.js +122 -0
- package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
- package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
- package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
- package/dist/lifecycle-Byw5s1G8.js +134 -0
- package/dist/lifecycle-Byw5s1G8.js.map +1 -0
- package/dist/lifecycle-D80CaWie.js +2 -0
- package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
- package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
- package/dist/lifecycle-DzqCqDlM.js +2 -0
- package/dist/main.js +43 -12904
- package/dist/main.js.map +1 -1
- package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
- package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
- package/dist/models-B6UnGyRo.js +158 -0
- package/dist/models-B6UnGyRo.js.map +1 -0
- package/dist/orchestration-hRU4TdWr.js +39 -0
- package/dist/orchestration-hRU4TdWr.js.map +1 -0
- package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
- package/dist/paths-C1cLZ7Ga.js.map +1 -0
- package/dist/paths-CwKwc2pi.js +2 -0
- package/dist/{peer-mcp-personas-BvommiSI.js → peer-mcp-personas-BDet588n.js} +163 -2392
- package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
- package/dist/plan-review-hook-C8x0uKPf.js +159 -0
- package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
- package/dist/provision-DSkPa5hY.js +656 -0
- package/dist/provision-DSkPa5hY.js.map +1 -0
- package/dist/serve-sa_7c-yD.js +1511 -0
- package/dist/serve-sa_7c-yD.js.map +1 -0
- package/dist/server-setup-DcO9qvN3.js +4776 -0
- package/dist/server-setup-DcO9qvN3.js.map +1 -0
- package/dist/start-siGxuZhO.js +129 -0
- package/dist/start-siGxuZhO.js.map +1 -0
- package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
- package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
- package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
- package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
- package/dist/token-R2ocFC3M.js +1054 -0
- package/dist/token-R2ocFC3M.js.map +1 -0
- package/dist/version-_Q1WpsQp.js +32 -0
- package/dist/version-_Q1WpsQp.js.map +1 -0
- package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
- package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
- package/package.json +1 -1
- package/dist/engine-dOYoMQis.js +0 -2
- package/dist/lifecycle-0rXrwjWk.js +0 -2
- package/dist/lifecycle-DHZsVN8j.js.map +0 -1
- package/dist/lifecycle-hzPwXc3I.js +0 -2
- package/dist/paths-BjTMI_xK.js.map +0 -1
- package/dist/paths-D3XgmTcQ.js +0 -2
- package/dist/peer-mcp-personas-BvommiSI.js.map +0 -1
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { D as TOOLBELT_TOOLS, E as vscodeRipgrepPath, O as assetFor, Pt as withInstallLock, T as toolbeltSkipSet, vt as extractTarGzMember, w as toolbeltEnabled, wt as collapsePathKeys, yt as extractZipMember } from "./peer-mcp-personas-BDet588n.js";
|
|
2
|
+
import { t as PATHS } from "./paths-C1cLZ7Ga.js";
|
|
3
|
+
import { a as resolveExecutable, r as parseBoolEnv, t as killChildProcessTree } from "./exec-DTO2oin0.js";
|
|
4
|
+
import { a as sweepRegistry } from "./lifecycle-DD88ka3f.js";
|
|
5
|
+
import consola from "consola";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { chmod, copyFile, link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
12
|
+
//#region src/lib/process-guard/index.ts
|
|
13
|
+
/**
|
|
14
|
+
* Crash-safe process guard (POSIX only).
|
|
15
|
+
*
|
|
16
|
+
* If the proxy dies WITHOUT running its own shutdown handlers (hard crash,
|
|
17
|
+
* SIGKILL, OOM), nothing in `cleanup()` runs to reap the launched CLI's
|
|
18
|
+
* process tree. This guard is the last-resort net for that case.
|
|
19
|
+
*
|
|
20
|
+
* Platform split:
|
|
21
|
+
* - **Windows: no guard needed.** Node assigns each child to a Job
|
|
22
|
+
* Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so when the proxy
|
|
23
|
+
* dies (any cause) the OS tears down the whole descendant tree —
|
|
24
|
+
* verified: force-killing a Node parent kills child AND grandchild.
|
|
25
|
+
* `startProcessGuard` is a no-op on win32.
|
|
26
|
+
* - **POSIX: a detached `node -e` reaper.** There is no job-object
|
|
27
|
+
* equivalent (a killed parent's children reparent to init), so we
|
|
28
|
+
* spawn a detached watchdog that learns of proxy death via an inherited
|
|
29
|
+
* stdin PIPE EOF (the proxy holds the write end; the OS closes it on
|
|
30
|
+
* proxy death) and reaps the CLI's process group.
|
|
31
|
+
*
|
|
32
|
+
* Never kill the WRONG process: if the child's PID/PGID was recycled after
|
|
33
|
+
* the child exited, a blind `kill(-pid)` could hit an innocent group. The
|
|
34
|
+
* reaper snapshots the child's process start-time at startup and, on proxy
|
|
35
|
+
* death, kills ONLY if the live PID's start-time still matches. A mismatch
|
|
36
|
+
* / unreadable probe → skip (fail-safe). We never trade an orphan-leak for
|
|
37
|
+
* killing the user's work.
|
|
38
|
+
*
|
|
39
|
+
* The reaper is detached + unref'd so it survives the proxy's SIGKILL, and
|
|
40
|
+
* self-exits once it has acted (or after a 24h cap) so it can never become
|
|
41
|
+
* the orphan it exists to prevent. Opt out with
|
|
42
|
+
* `GH_ROUTER_DISABLE_PROCESS_GUARD=1`.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Live reaper children, held so the GC can't collect the `ChildProcess`
|
|
46
|
+
* (and with it the stdin write-end that is the proxy-death signal) while
|
|
47
|
+
* the proxy runs. Entries self-remove on the reaper's exit. We do NOT
|
|
48
|
+
* proactively kill these on graceful shutdown: when the proxy exits the OS
|
|
49
|
+
* closes the stdin pipe, the reaper hits EOF, re-verifies the child's
|
|
50
|
+
* identity, and reaps any survivor that ignored the graceful SIGTERM —
|
|
51
|
+
* exactly the backstop we want.
|
|
52
|
+
*/
|
|
53
|
+
const _activeReapers = /* @__PURE__ */ new Set();
|
|
54
|
+
/** Guard is on by default; `GH_ROUTER_DISABLE_PROCESS_GUARD=1` opts out. */
|
|
55
|
+
function processGuardEnabled() {
|
|
56
|
+
return parseBoolEnv(process.env.GH_ROUTER_DISABLE_PROCESS_GUARD) !== true;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the start-time-verified node reaper script (POSIX). PURE — `pid`
|
|
60
|
+
* is our own integer. `detachedGroup` selects `kill(-pid)` (process group,
|
|
61
|
+
* the detached CLI) vs `kill(pid)` on POSIX.
|
|
62
|
+
*
|
|
63
|
+
* Kept dependency-free (require'd builtins only) so it runs under `node -e`
|
|
64
|
+
* with no module resolution against the dist bundle.
|
|
65
|
+
*/
|
|
66
|
+
function buildNodeReaperScript(pid, detachedGroup) {
|
|
67
|
+
const target = detachedGroup ? "-PID" : "PID";
|
|
68
|
+
return `
|
|
69
|
+
const cp = require("node:child_process");
|
|
70
|
+
const fs = require("node:fs");
|
|
71
|
+
const PID = ${pid >>> 0};
|
|
72
|
+
function startTime() {
|
|
73
|
+
try {
|
|
74
|
+
if (process.platform === "linux") {
|
|
75
|
+
const stat = fs.readFileSync("/proc/" + PID + "/stat", "utf8");
|
|
76
|
+
const post = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\\s+/);
|
|
77
|
+
return post[19] || null; // field 22 (starttime) = index 19 after comm
|
|
78
|
+
}
|
|
79
|
+
const out = cp.execFileSync("ps", ["-o","lstart=","-p",String(PID)],
|
|
80
|
+
{ stdio: ["ignore","pipe","ignore"], timeout: 2000 }).toString().trim();
|
|
81
|
+
return out || null;
|
|
82
|
+
} catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
function alive() { try { process.kill(PID, 0); return true; } catch { return false; } }
|
|
85
|
+
function treeKill() {
|
|
86
|
+
try { process.kill(${target}, "SIGTERM"); } catch {}
|
|
87
|
+
// NOT unref'd: this must keep the loop alive to deliver the escalated
|
|
88
|
+
// SIGKILL (the onDeath exit timer below stays alive past 500ms too).
|
|
89
|
+
setTimeout(() => { try { process.kill(${target}, "SIGKILL"); } catch {} }, 500);
|
|
90
|
+
}
|
|
91
|
+
const snap = (() => {
|
|
92
|
+
// The child is definitely ours and alive at startup, so a null here is a
|
|
93
|
+
// transient probe failure (e.g. a momentary 'ps' hiccup), NOT a real
|
|
94
|
+
// identity loss. Retry a few times so a one-off failure can't silently
|
|
95
|
+
// disable the guard for the rest of the run.
|
|
96
|
+
for (let i = 0; i < 3; i++) { const s = startTime(); if (s !== null) return s; }
|
|
97
|
+
return null;
|
|
98
|
+
})();
|
|
99
|
+
let done = false;
|
|
100
|
+
function onDeath() {
|
|
101
|
+
if (done) return; done = true;
|
|
102
|
+
// Re-verify identity: kill ONLY if the live PID is still our child.
|
|
103
|
+
if (snap !== null && alive() && startTime() === snap) treeKill();
|
|
104
|
+
// REF'd (NOT unref'd): stdin has closed, so an unref'd timer would let
|
|
105
|
+
// the loop empty and the process exit immediately — dropping the 500ms
|
|
106
|
+
// SIGKILL escalation. Keep the loop alive to deliver it, then exit.
|
|
107
|
+
setTimeout(() => process.exit(0), 1500);
|
|
108
|
+
}
|
|
109
|
+
process.stdin.resume();
|
|
110
|
+
process.stdin.on("end", onDeath);
|
|
111
|
+
process.stdin.on("close", onDeath);
|
|
112
|
+
process.stdin.on("error", onDeath);
|
|
113
|
+
const cap = setTimeout(() => process.exit(0), 24*3600*1000); if (cap.unref) cap.unref();
|
|
114
|
+
`.trim();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Spawn the detached node reaper holding the stdin death-pipe. Always
|
|
118
|
+
* `detached` so it outlives a force-kill of the proxy, and `unref`'d so it
|
|
119
|
+
* never holds the proxy's event loop open. Returns null on spawn failure.
|
|
120
|
+
*/
|
|
121
|
+
function spawnNodeReaper(pid, detachedGroup) {
|
|
122
|
+
try {
|
|
123
|
+
const child = spawn(process.execPath, ["-e", buildNodeReaperScript(pid, detachedGroup)], {
|
|
124
|
+
stdio: [
|
|
125
|
+
"pipe",
|
|
126
|
+
"ignore",
|
|
127
|
+
"ignore"
|
|
128
|
+
],
|
|
129
|
+
detached: true,
|
|
130
|
+
windowsHide: true,
|
|
131
|
+
shell: false
|
|
132
|
+
});
|
|
133
|
+
child.on("error", () => {});
|
|
134
|
+
child.stdin?.on("error", () => {});
|
|
135
|
+
_activeReapers.add(child);
|
|
136
|
+
child.once("exit", () => _activeReapers.delete(child));
|
|
137
|
+
child.unref();
|
|
138
|
+
return child;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Start the crash-safe guard for a launched CLI child. No-op on Windows
|
|
145
|
+
* (Node's Job Object already reaps the tree on proxy death) and when
|
|
146
|
+
* disabled / unspawnable. Fire-and-forget; never throws.
|
|
147
|
+
*/
|
|
148
|
+
function startProcessGuard(child) {
|
|
149
|
+
if (!processGuardEnabled()) return;
|
|
150
|
+
const pid = child.pid;
|
|
151
|
+
if (!pid) return;
|
|
152
|
+
if (process.platform === "win32") return;
|
|
153
|
+
spawnNodeReaper(pid, true);
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/lib/stripped-env-keys.ts
|
|
157
|
+
/**
|
|
158
|
+
* Auth / routing / remote / wire-fingerprint environment keys that must never
|
|
159
|
+
* reach a github-router-spawned Claude or Codex child — through ANY channel.
|
|
160
|
+
*
|
|
161
|
+
* Two channels carry these keys to a spawned agent, and both must be defended:
|
|
162
|
+
* 1. The PARENT PROCESS ENV — a user's shell exports. Stripped by
|
|
163
|
+
* `sanitizeParentEnv` (`src/lib/launch.ts`) before the proxy's overrides.
|
|
164
|
+
* 2. The serve mirror `settings.json` `env` BLOCK — a user's own
|
|
165
|
+
* `~/.claude/settings.json`, snapshot-mirrored and applied to the
|
|
166
|
+
* SDK-spawned claude via `settingSources`. Stripped by
|
|
167
|
+
* `sanitizeServeSettingsEnv` (`src/lib/mcp-permissions-settings.ts`).
|
|
168
|
+
*
|
|
169
|
+
* If any of these survived, a Bedrock/Vertex/gateway user's config would
|
|
170
|
+
* re-route the session OFF the github-router proxy, inject real auth that wins
|
|
171
|
+
* over the synthetic `.credentials.json`, or activate Claude Code's
|
|
172
|
+
* unimplemented Bridge/remote code path. `CLAUDE_CODE_COORDINATOR_MODE` (a
|
|
173
|
+
* toolset-stripper) is NOT here — it's serve-specific, added to the serve list
|
|
174
|
+
* directly. `ANTHROPIC_SMALL_FAST_MODEL` is deliberately NOT stripped (users
|
|
175
|
+
* legitimately rely on it; `resolveModel` translates unrecognized values).
|
|
176
|
+
*/
|
|
177
|
+
const STRIPPED_AUTH_ROUTING_ENV_KEYS = [
|
|
178
|
+
"ANTHROPIC_API_KEY",
|
|
179
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
180
|
+
"ANTHROPIC_BASE_URL",
|
|
181
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
182
|
+
"ANTHROPIC_MODEL",
|
|
183
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
184
|
+
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
|
185
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
186
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
187
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
188
|
+
"CLAUDE_CONFIG_DIR",
|
|
189
|
+
"CLAUDE_BRIDGE_OAUTH_TOKEN",
|
|
190
|
+
"CLAUDE_BRIDGE_BASE_URL",
|
|
191
|
+
"CLAUDE_BRIDGE_SESSION_INGRESS_URL",
|
|
192
|
+
"SESSION_INGRESS_URL",
|
|
193
|
+
"CLAUDE_CODE_REMOTE",
|
|
194
|
+
"CLAUDE_CODE_CONTAINER_ID",
|
|
195
|
+
"CLAUDE_CODE_REMOTE_SESSION_ID",
|
|
196
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
197
|
+
"CLAUDE_CODE_ADDITIONAL_PROTECTION",
|
|
198
|
+
"OPENAI_API_KEY",
|
|
199
|
+
"OPENAI_BASE_URL",
|
|
200
|
+
"CODEX_HOME",
|
|
201
|
+
"AIORDIE_CLAUDE_BIND",
|
|
202
|
+
"AIORDIE_TOKEN",
|
|
203
|
+
"AIORDIE_INSECURE_TLS"
|
|
204
|
+
];
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/lib/launch.ts
|
|
207
|
+
/**
|
|
208
|
+
* Auth-related env keys we strip from the parent before spawning the child CLI.
|
|
209
|
+
* The proxy provides its own values for everything we care about, so the child
|
|
210
|
+
* behaves as if the user had no parent-env auth at all. The canonical list lives
|
|
211
|
+
* in `./stripped-env-keys` (shared with serve's settings.json `env` strip).
|
|
212
|
+
*
|
|
213
|
+
* Why strip rather than override-with-empty-string: Claude Code emits "Auth
|
|
214
|
+
* conflict" warnings whenever both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
|
|
215
|
+
* are present (any value); stripping suppresses it AND prevents an inherited
|
|
216
|
+
* real key from leaking. Cloud-provider toggles + OAuth tokens are simpler
|
|
217
|
+
* dropped than overridden — a missing var is unambiguously absent everywhere.
|
|
218
|
+
*/
|
|
219
|
+
const STRIPPED_PARENT_ENV_KEYS = STRIPPED_AUTH_ROUTING_ENV_KEYS;
|
|
220
|
+
/**
|
|
221
|
+
* Strip auth-related keys from a parent-process env object. The result
|
|
222
|
+
* is suitable to spread into a spawned child's env BEFORE the proxy's
|
|
223
|
+
* explicit overrides, so the proxy is the only source of truth for
|
|
224
|
+
* auth — and stale shell exports can't leak through.
|
|
225
|
+
*/
|
|
226
|
+
function sanitizeParentEnv(parent) {
|
|
227
|
+
const sanitized = { ...parent };
|
|
228
|
+
for (const key of STRIPPED_PARENT_ENV_KEYS) delete sanitized[key];
|
|
229
|
+
return sanitized;
|
|
230
|
+
}
|
|
231
|
+
function commandExists(name) {
|
|
232
|
+
try {
|
|
233
|
+
execFileSync(process.platform === "win32" ? "where.exe" : "which", [name], { stdio: "ignore" });
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Whether the launcher can execute `executable`.
|
|
241
|
+
*
|
|
242
|
+
* `buildLaunchCommand` resolves the CLI to an ABSOLUTE path (anti-shadow).
|
|
243
|
+
* `where.exe` (and POSIX `which`) reject a full path argument — `where`
|
|
244
|
+
* returns "Could not find files for the given pattern(s)" for an absolute
|
|
245
|
+
* path even when the file exists — so the where/which probe is only valid
|
|
246
|
+
* for bare command names. For an absolute path (already resolved against
|
|
247
|
+
* PATH and existence-checked by `resolveExecutable`), check the
|
|
248
|
+
* filesystem directly. Without this split, every launch where the CLI is
|
|
249
|
+
* installed fails with a spurious "not found on PATH".
|
|
250
|
+
*/
|
|
251
|
+
function isExecutableAvailable(executable) {
|
|
252
|
+
if (path.isAbsolute(executable)) return existsSync(executable);
|
|
253
|
+
return commandExists(executable);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Provider-config flags (`-c model_providers.github_router=...`) that
|
|
257
|
+
* point Codex at our proxy. Extracted from `buildCodexCmd` so the new
|
|
258
|
+
* `codex mcp-server` MCP-config builder can reuse the exact same
|
|
259
|
+
* provider definition — drift between the two paths would silently
|
|
260
|
+
* break the MCP wiring.
|
|
261
|
+
*/
|
|
262
|
+
function buildCodexProviderConfigFlags(serverUrl) {
|
|
263
|
+
return [
|
|
264
|
+
"-c",
|
|
265
|
+
`model_providers.github_router={name="github-router",base_url="${serverUrl}/v1",wire_api="responses",env_key="OPENAI_API_KEY"}`,
|
|
266
|
+
"-c",
|
|
267
|
+
"model_provider=github_router"
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Inspect the installed `codex` binary. Used by the codex-MCP wiring
|
|
272
|
+
* in `claude.ts` to gate `--codex-cli`. Codex 0.129.0 introduced the
|
|
273
|
+
* `mcp-server` subcommand; older versions don't expose it, so we
|
|
274
|
+
* downgrade to the HTTP backend with a warning.
|
|
275
|
+
*/
|
|
276
|
+
function getCodexVersion() {
|
|
277
|
+
if (!commandExists("codex")) return { ok: false };
|
|
278
|
+
let raw;
|
|
279
|
+
try {
|
|
280
|
+
raw = execFileSync("codex", ["--version"], {
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
stdio: [
|
|
283
|
+
"ignore",
|
|
284
|
+
"pipe",
|
|
285
|
+
"ignore"
|
|
286
|
+
]
|
|
287
|
+
}).trim();
|
|
288
|
+
} catch {
|
|
289
|
+
return { ok: false };
|
|
290
|
+
}
|
|
291
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw);
|
|
292
|
+
if (!m) return {
|
|
293
|
+
ok: false,
|
|
294
|
+
version: raw
|
|
295
|
+
};
|
|
296
|
+
const major = Number.parseInt(m[1], 10);
|
|
297
|
+
const minor = Number.parseInt(m[2], 10);
|
|
298
|
+
const version = `${m[1]}.${m[2]}.${m[3]}`;
|
|
299
|
+
return {
|
|
300
|
+
ok: major > 0 || major === 0 && minor >= 129,
|
|
301
|
+
version
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Codex 0.129.0 broke two things the launcher had been relying on:
|
|
306
|
+
* (1) `--full-auto` was removed in favor of `--sandbox` + `--ask-for-approval`;
|
|
307
|
+
* passing it now exits the child immediately with
|
|
308
|
+
* `error: unexpected argument '--full-auto' found`.
|
|
309
|
+
* (2) `OPENAI_BASE_URL` is silently ignored — Codex hardcodes
|
|
310
|
+
* `https://api.openai.com/v1/responses` and 401s out without an
|
|
311
|
+
* explicit `-c model_providers.<name>.base_url` override.
|
|
312
|
+
*
|
|
313
|
+
* `buildCodexCmd` builds the launch argv that works on Codex 0.129+ while
|
|
314
|
+
* still being compatible with older versions that accept the same flags.
|
|
315
|
+
*/
|
|
316
|
+
function buildCodexCmd(target) {
|
|
317
|
+
const cmd = ["codex"];
|
|
318
|
+
if (target.serverUrl) cmd.push(...buildCodexProviderConfigFlags(target.serverUrl));
|
|
319
|
+
cmd.push("--sandbox", "workspace-write", "--ask-for-approval", "on-request", "-m", target.model ?? "gpt-5.6-sol", ...target.extraArgs);
|
|
320
|
+
return cmd;
|
|
321
|
+
}
|
|
322
|
+
function buildLaunchCommand(target) {
|
|
323
|
+
const wantsPermissionMode = target.kind === "claude-code" && target.extraArgs.some((arg) => arg === "--permission-mode" || arg.startsWith("--permission-mode="));
|
|
324
|
+
const cmd = target.kind === "claude-code" ? wantsPermissionMode ? ["claude", ...target.extraArgs] : [
|
|
325
|
+
"claude",
|
|
326
|
+
"--dangerously-skip-permissions",
|
|
327
|
+
...target.extraArgs
|
|
328
|
+
] : buildCodexCmd(target);
|
|
329
|
+
const resolved = resolveExecutable(cmd[0], { env: process.env });
|
|
330
|
+
if (resolved) cmd[0] = resolved;
|
|
331
|
+
return {
|
|
332
|
+
cmd,
|
|
333
|
+
env: collapsePathKeys({
|
|
334
|
+
...sanitizeParentEnv(process.env),
|
|
335
|
+
...target.envVars
|
|
336
|
+
})
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Whether a resolved Windows executable must be launched through cmd.exe
|
|
341
|
+
* (`shell:true`). Only batch shims (`.cmd`/`.bat`) need it — and even then
|
|
342
|
+
* cmd.exe stays alive as the CLI's parent, so `taskkill /T` reaps the
|
|
343
|
+
* whole tree. A real `.exe` (e.g. the native-installer `claude.exe`) is
|
|
344
|
+
* spawned DIRECTLY so the CLI is the direct child, with no cmd.exe
|
|
345
|
+
* intermediary to orphan its node/MCP grandchildren on a kill.
|
|
346
|
+
*/
|
|
347
|
+
function windowsLaunchNeedsShell(executable) {
|
|
348
|
+
const ext = path.extname(executable).toLowerCase();
|
|
349
|
+
return ext === ".cmd" || ext === ".bat";
|
|
350
|
+
}
|
|
351
|
+
function launchChild(target, server, options = {}) {
|
|
352
|
+
const { cmd, env } = buildLaunchCommand(target);
|
|
353
|
+
const executable = cmd[0];
|
|
354
|
+
if (!isExecutableAvailable(executable)) {
|
|
355
|
+
const msg = `"${executable}" not found on PATH. Install it first, then try again.`;
|
|
356
|
+
consola.error(msg);
|
|
357
|
+
process.stderr.write(msg + "\n");
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
let child;
|
|
361
|
+
try {
|
|
362
|
+
if (process.platform === "win32") if (windowsLaunchNeedsShell(cmd[0])) {
|
|
363
|
+
const quoted = cmd.map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
|
|
364
|
+
child = spawn(quoted, [], {
|
|
365
|
+
env,
|
|
366
|
+
stdio: "inherit",
|
|
367
|
+
shell: true
|
|
368
|
+
});
|
|
369
|
+
} else child = spawn(cmd[0], cmd.slice(1), {
|
|
370
|
+
env,
|
|
371
|
+
stdio: "inherit"
|
|
372
|
+
});
|
|
373
|
+
else child = spawn(cmd[0], cmd.slice(1), {
|
|
374
|
+
env,
|
|
375
|
+
stdio: "inherit",
|
|
376
|
+
detached: true
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const msg = `Failed to launch ${executable}: ${error instanceof Error ? error.message : String(error)}`;
|
|
380
|
+
consola.error(msg);
|
|
381
|
+
process.stderr.write(msg + "\n");
|
|
382
|
+
server.close(true).catch(() => {});
|
|
383
|
+
if (options.onShutdown) Promise.resolve(options.onShutdown()).catch(() => {});
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
startProcessGuard(child);
|
|
387
|
+
let cleaned = false;
|
|
388
|
+
let exiting = false;
|
|
389
|
+
async function cleanup() {
|
|
390
|
+
if (cleaned) return;
|
|
391
|
+
cleaned = true;
|
|
392
|
+
const timeout = setTimeout(() => process.exit(1), 5e3);
|
|
393
|
+
try {
|
|
394
|
+
killChildProcessTree(child, { detachedGroup: process.platform !== "win32" });
|
|
395
|
+
} catch {}
|
|
396
|
+
try {
|
|
397
|
+
await server.close(true);
|
|
398
|
+
} catch {}
|
|
399
|
+
if (options.onShutdown) try {
|
|
400
|
+
await options.onShutdown();
|
|
401
|
+
} catch {}
|
|
402
|
+
clearTimeout(timeout);
|
|
403
|
+
}
|
|
404
|
+
function exit(code) {
|
|
405
|
+
if (exiting) return;
|
|
406
|
+
exiting = true;
|
|
407
|
+
process.exit(code);
|
|
408
|
+
}
|
|
409
|
+
let forwardGrace = null;
|
|
410
|
+
const lastForwardAt = {
|
|
411
|
+
SIGINT: 0,
|
|
412
|
+
SIGTERM: 0
|
|
413
|
+
};
|
|
414
|
+
const onSignal = (sig) => {
|
|
415
|
+
if (process.platform !== "win32" && child.pid && !cleaned) {
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
if (now - lastForwardAt[sig] > 250) {
|
|
418
|
+
lastForwardAt[sig] = now;
|
|
419
|
+
try {
|
|
420
|
+
process.kill(-child.pid, sig);
|
|
421
|
+
} catch {}
|
|
422
|
+
}
|
|
423
|
+
const graceMs = sig === "SIGINT" ? 1e4 : 3e3;
|
|
424
|
+
if (!forwardGrace) {
|
|
425
|
+
forwardGrace = setTimeout(() => {
|
|
426
|
+
cleanup().then(() => exit(130)).catch(() => exit(1));
|
|
427
|
+
}, graceMs);
|
|
428
|
+
forwardGrace.unref?.();
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
cleanup().then(() => exit(130)).catch(() => exit(1));
|
|
433
|
+
};
|
|
434
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
435
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
436
|
+
child.on("exit", (exitCode, signal) => {
|
|
437
|
+
try {
|
|
438
|
+
sweepRegistry();
|
|
439
|
+
} catch {}
|
|
440
|
+
const code = exitCode ?? (signal ? 128 : 1);
|
|
441
|
+
cleanup().then(() => exit(code)).catch(() => exit(1));
|
|
442
|
+
});
|
|
443
|
+
child.on("error", () => {
|
|
444
|
+
cleanup().then(() => exit(1)).catch(() => exit(1));
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/lib/toolbelt/provision.ts
|
|
449
|
+
/**
|
|
450
|
+
* Toolbelt provisioner: materialize curated CLI tools into the durable
|
|
451
|
+
* router `bin/` dir so the spawned agent can call them on PATH.
|
|
452
|
+
*
|
|
453
|
+
* Properties (all load-bearing):
|
|
454
|
+
* - **Best-effort.** Every tool is isolated in try/catch; a failure
|
|
455
|
+
* skips that one tool and never throws to the launcher.
|
|
456
|
+
* - **Gap-fill.** A tool already on the user's PATH is NOT
|
|
457
|
+
* materialized (and a stale copy is removed) so we never shadow the
|
|
458
|
+
* user's own `jq`/`yq`/etc.
|
|
459
|
+
* - **Verified.** The downloaded archive's SHA256 must match the
|
|
460
|
+
* hardcoded manifest digest BEFORE extraction.
|
|
461
|
+
* - **Safe extraction.** Only the expected regular-file binary is
|
|
462
|
+
* pulled out; symlink/hardlink/device entries are rejected.
|
|
463
|
+
* - **Concurrency-safe.** A cross-process lock serializes writers;
|
|
464
|
+
* unique temp names + atomic rename avoid half-written binaries.
|
|
465
|
+
* - **Integrity-pruned.** Unexpected files in `bin/` are removed each
|
|
466
|
+
* launch so a prompt-injected agent can't plant a `git.cmd` that
|
|
467
|
+
* shadows real tools in future sessions.
|
|
468
|
+
*/
|
|
469
|
+
/** Per-download cap (bytes) — these binaries are a few MB at most. */
|
|
470
|
+
const MAX_DOWNLOAD_BYTES = 67108864;
|
|
471
|
+
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
472
|
+
const EXE_EXT = process.platform === "win32" ? ".exe" : "";
|
|
473
|
+
/**
|
|
474
|
+
* Materialize the toolbelt. Returns the list of command names exposed
|
|
475
|
+
* in `bin/` after provisioning. Best-effort; never throws.
|
|
476
|
+
*/
|
|
477
|
+
async function provisionToolbelt() {
|
|
478
|
+
if (!toolbeltEnabled()) return [];
|
|
479
|
+
const binDir = PATHS.TOOLBELT_BIN_DIR;
|
|
480
|
+
try {
|
|
481
|
+
await mkdir(binDir, { recursive: true });
|
|
482
|
+
} catch (err) {
|
|
483
|
+
consola.debug("toolbelt: could not create bin dir:", err);
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
const skip = toolbeltSkipSet();
|
|
487
|
+
await withInstallLock("toolbelt.lock", async () => {
|
|
488
|
+
await pruneUnexpected(binDir);
|
|
489
|
+
await provisionRg(binDir, skip).catch((err) => consola.debug("toolbelt: rg skipped:", err));
|
|
490
|
+
await Promise.all(TOOLBELT_TOOLS.map((spec) => provisionTool(spec, binDir, skip).catch((err) => consola.debug(`toolbelt: ${spec.command} skipped:`, err))));
|
|
491
|
+
});
|
|
492
|
+
return exposedCommands(binDir);
|
|
493
|
+
}
|
|
494
|
+
/** Names allowed to live in `bin/` (managed binaries + their sidecars). */
|
|
495
|
+
function expectedFileNames() {
|
|
496
|
+
const names = /* @__PURE__ */ new Set();
|
|
497
|
+
const add = (base) => {
|
|
498
|
+
names.add(base + EXE_EXT);
|
|
499
|
+
names.add(`${base}${EXE_EXT}.sha256`);
|
|
500
|
+
};
|
|
501
|
+
add("rg");
|
|
502
|
+
for (const spec of TOOLBELT_TOOLS) {
|
|
503
|
+
add(spec.binBasename);
|
|
504
|
+
for (const a of spec.aliases ?? []) add(a);
|
|
505
|
+
}
|
|
506
|
+
return names;
|
|
507
|
+
}
|
|
508
|
+
/** Remove any file in `bin/` that isn't a managed binary or sidecar. */
|
|
509
|
+
async function pruneUnexpected(binDir) {
|
|
510
|
+
const expected = expectedFileNames();
|
|
511
|
+
let entries;
|
|
512
|
+
try {
|
|
513
|
+
entries = await readdir(binDir);
|
|
514
|
+
} catch {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
for (const name of entries) {
|
|
518
|
+
if (name.endsWith(".tmp")) continue;
|
|
519
|
+
if (!expected.has(name)) await rm(path.join(binDir, name), { force: true }).catch(() => {});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function provisionRg(binDir, skip) {
|
|
523
|
+
const dest = path.join(binDir, "rg" + EXE_EXT);
|
|
524
|
+
if (skip.has("rg") || resolveExecutable("rg")) {
|
|
525
|
+
await removeBin(dest);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (existsSync(dest)) return;
|
|
529
|
+
const src = vscodeRipgrepPath();
|
|
530
|
+
if (!src) return;
|
|
531
|
+
const tmp = tempName(dest);
|
|
532
|
+
try {
|
|
533
|
+
await link(src, tmp);
|
|
534
|
+
} catch {
|
|
535
|
+
await copyFile(src, tmp);
|
|
536
|
+
}
|
|
537
|
+
if (process.platform !== "win32") await chmod(tmp, 493).catch(() => {});
|
|
538
|
+
await commit(tmp, dest);
|
|
539
|
+
}
|
|
540
|
+
async function provisionTool(spec, binDir, skip) {
|
|
541
|
+
const dest = path.join(binDir, spec.binBasename + EXE_EXT);
|
|
542
|
+
const sidecar = `${dest}.sha256`;
|
|
543
|
+
const asset = assetFor(spec);
|
|
544
|
+
if (skip.has(spec.command) || !asset) {
|
|
545
|
+
await removeTool(spec, binDir);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (resolveExecutable(spec.command)) {
|
|
549
|
+
await removeTool(spec, binDir);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (existsSync(dest) && await sidecarMatches(sidecar, asset.sha256)) {
|
|
553
|
+
await ensureAliases(spec, binDir, dest);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await atomicInstall(dest, await downloadAndExtract(spec, asset));
|
|
557
|
+
await writeFile(sidecar, asset.sha256).catch(() => {});
|
|
558
|
+
await ensureAliases(spec, binDir, dest);
|
|
559
|
+
}
|
|
560
|
+
async function downloadAndExtract(spec, asset) {
|
|
561
|
+
const data = await download(asset.url);
|
|
562
|
+
const digest = createHash("sha256").update(data).digest("hex");
|
|
563
|
+
if (digest !== asset.sha256) throw new Error(`checksum mismatch for ${spec.command} (${asset.url}): expected ${asset.sha256}, got ${digest}`);
|
|
564
|
+
if (asset.archive === "raw") return data;
|
|
565
|
+
const member = asset.archive === "zip" ? extractZipMember(data, spec.binBasename) : extractTarGzMember(data, spec.binBasename);
|
|
566
|
+
if (!member) throw new Error(`binary "${spec.binBasename}" not found in ${asset.url}`);
|
|
567
|
+
return member;
|
|
568
|
+
}
|
|
569
|
+
async function download(url) {
|
|
570
|
+
const controller = new AbortController();
|
|
571
|
+
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
572
|
+
try {
|
|
573
|
+
const res = await fetch(url, {
|
|
574
|
+
signal: controller.signal,
|
|
575
|
+
redirect: "follow",
|
|
576
|
+
headers: { "User-Agent": "github-router-toolbelt" }
|
|
577
|
+
});
|
|
578
|
+
if (!res.ok) throw new Error(`download ${url}: HTTP ${res.status}`);
|
|
579
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
580
|
+
if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`download ${url}: exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
|
|
581
|
+
return buf;
|
|
582
|
+
} finally {
|
|
583
|
+
clearTimeout(timer);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Write to a unique temp then atomically rename into place. */
|
|
587
|
+
async function atomicInstall(dest, bytes) {
|
|
588
|
+
const tmp = tempName(dest);
|
|
589
|
+
await writeFile(tmp, bytes);
|
|
590
|
+
if (process.platform !== "win32") await chmod(tmp, 493).catch(() => {});
|
|
591
|
+
await commit(tmp, dest);
|
|
592
|
+
}
|
|
593
|
+
/** Rename tmp→dest, handling Windows replace-existing / in-use locks. */
|
|
594
|
+
async function commit(tmp, dest) {
|
|
595
|
+
try {
|
|
596
|
+
await rename(tmp, dest);
|
|
597
|
+
} catch {
|
|
598
|
+
try {
|
|
599
|
+
await rm(dest, { force: true });
|
|
600
|
+
await rename(tmp, dest);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function ensureAliases(spec, binDir, dest) {
|
|
608
|
+
for (const alias of spec.aliases ?? []) {
|
|
609
|
+
const ap = path.join(binDir, alias + EXE_EXT);
|
|
610
|
+
if (existsSync(ap)) continue;
|
|
611
|
+
const tmp = tempName(ap);
|
|
612
|
+
try {
|
|
613
|
+
await copyFile(dest, tmp);
|
|
614
|
+
if (process.platform !== "win32") await chmod(tmp, 493).catch(() => {});
|
|
615
|
+
await commit(tmp, ap);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
consola.debug(`toolbelt: alias ${alias} skipped:`, err);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async function removeTool(spec, binDir) {
|
|
622
|
+
await removeBin(path.join(binDir, spec.binBasename + EXE_EXT));
|
|
623
|
+
for (const alias of spec.aliases ?? []) await removeBin(path.join(binDir, alias + EXE_EXT));
|
|
624
|
+
}
|
|
625
|
+
async function removeBin(dest) {
|
|
626
|
+
await rm(dest, { force: true }).catch(() => {});
|
|
627
|
+
await rm(`${dest}.sha256`, { force: true }).catch(() => {});
|
|
628
|
+
}
|
|
629
|
+
async function sidecarMatches(sidecar, sha256) {
|
|
630
|
+
try {
|
|
631
|
+
return (await readFile(sidecar, "utf8")).trim() === sha256;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function tempName(dest) {
|
|
637
|
+
return `${dest}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
638
|
+
}
|
|
639
|
+
/** The command names currently exposed in `bin/`. */
|
|
640
|
+
async function exposedCommands(binDir) {
|
|
641
|
+
let files;
|
|
642
|
+
try {
|
|
643
|
+
files = new Set(await readdir(binDir));
|
|
644
|
+
} catch {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
const present = (base) => files.has(base + EXE_EXT);
|
|
648
|
+
const out = [];
|
|
649
|
+
if (present("rg")) out.push("rg");
|
|
650
|
+
for (const spec of TOOLBELT_TOOLS) if (present(spec.binBasename)) out.push(spec.command);
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
export { STRIPPED_AUTH_ROUTING_ENV_KEYS as a, launchChild as i, buildCodexProviderConfigFlags as n, getCodexVersion as r, provisionToolbelt as t };
|
|
655
|
+
|
|
656
|
+
//# sourceMappingURL=provision-DSkPa5hY.js.map
|