github-router 0.3.20 → 0.3.22

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/main.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
5
  import fs from "node:fs/promises";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import process$1 from "node:process";
9
9
  import { execFile, execFileSync, spawn } from "node:child_process";
10
10
  import { promisify } from "node:util";
@@ -38,6 +38,9 @@ const PATHS = {
38
38
  },
39
39
  get CLAUDE_RUNTIME_DIR() {
40
40
  return path.join(appDir(), "runtime");
41
+ },
42
+ get CLAUDE_CONFIG_DIR() {
43
+ return path.join(appDir(), "claude-config");
41
44
  }
42
45
  };
43
46
  async function ensurePaths() {
@@ -53,6 +56,318 @@ async function ensurePaths() {
53
56
  consola.debug("Peer-agent .md sweep skipped:", err);
54
57
  });
55
58
  }
59
+ const CLAUDE_HOME_POLICY = new Map([
60
+ [".credentials.json", "ISOLATED"],
61
+ [".credentials.json.lock", "ISOLATED"],
62
+ [".oauth_refresh.lock", "ISOLATED"],
63
+ [".github-router-managed", "ISOLATED"],
64
+ ["statsig", "ISOLATED"],
65
+ ["cache", "ISOLATED"],
66
+ ["logs", "ISOLATED"],
67
+ ["paste-cache", "ISOLATED"],
68
+ ["projects", "SHARED"],
69
+ ["sessions", "SHARED"],
70
+ ["tasks", "SHARED"],
71
+ ["todos", "SHARED"],
72
+ ["transcripts", "SHARED"],
73
+ ["shell-snapshots", "SHARED"],
74
+ ["shell_snapshots", "SHARED"],
75
+ ["plans", "SHARED"],
76
+ ["file-history", "SHARED"],
77
+ ["backups", "SHARED"]
78
+ ]);
79
+ function policyFor(name$1) {
80
+ return CLAUDE_HOME_POLICY.get(name$1) ?? "MIRRORED";
81
+ }
82
+ /**
83
+ * Names with `SHARED` policy, materialized once for iteration in
84
+ * `ensureClaudeConfigMirror`'s post-copy phase.
85
+ */
86
+ const SHARED_TOPLEVEL_NAMES = Array.from(CLAUDE_HOME_POLICY.entries()).filter(([, kind]) => kind === "SHARED").map(([name$1]) => name$1);
87
+ /**
88
+ * Marker file written into the router-owned CLAUDE_CONFIG_DIR so users
89
+ * (and our own future sweeps) can identify that the dir is managed by
90
+ * github-router. Content is informational only; no logic depends on
91
+ * its presence.
92
+ */
93
+ const MANAGED_MARKER_FILENAME = ".github-router-managed";
94
+ /**
95
+ * Synthetic Console OAuth credential the router writes into its own
96
+ * `CLAUDE_CONFIG_DIR/.credentials.json` so spawned Claude Code (and
97
+ * any teammates it spawns) can authenticate without a real user
98
+ * `/login`.
99
+ *
100
+ * Schema verified verbatim from `claude` v2.1.140 binary, function
101
+ * `guH` (the credentials-save mutation). Fields:
102
+ * - `accessToken` — sent as `Authorization: Bearer ...` to the
103
+ * proxy. Proxy accepts any bearer (per CLAUDE.md "doesn't enforce
104
+ * auth").
105
+ * - `refreshToken` — only used by Claude Code's reactive refresh
106
+ * path (function `nH8`), which fires on 401 from upstream. The
107
+ * proxy maintains the no-401 invariant on the Anthropic-shape
108
+ * boundary, so this is never invoked. Synthetic value is fine.
109
+ * - `expiresAt` — far-future (2099-01-01 ms epoch). Sidesteps the
110
+ * proactive refresh path (`R8H(expiresAt)` returns false).
111
+ * - `scopes` — claude-ai-shaped so `tB(scopes)` returns true,
112
+ * making `Hq()` true (full feature surface, not "inference only").
113
+ * - `subscriptionType` — `"max"`. Pure client-side label
114
+ * (`e7()` / `Zc_()` / `CZ1()`); no server validation since
115
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` suppresses
116
+ * subscription-validation calls. Picks the most-permissive gating.
117
+ */
118
+ const SYNTHETIC_CREDENTIAL = { claudeAiOauth: {
119
+ accessToken: "github-router-synthetic",
120
+ refreshToken: "github-router-synthetic",
121
+ expiresAt: 40709088e5,
122
+ scopes: ["user:inference", "user:profile"],
123
+ subscriptionType: "max",
124
+ rateLimitTier: null,
125
+ clientId: "github-router"
126
+ } };
127
+ /**
128
+ * Snapshot-copy the user's `~/.claude/` into the router-owned
129
+ * CLAUDE_CONFIG_DIR (real files, not symlinks — symlinks don't isolate
130
+ * writes), classifying each top-level entry per `CLAUDE_HOME_POLICY`:
131
+ * ISOLATED entries are skipped, MIRRORED entries are copied, and
132
+ * SHARED entries become directory symlinks back to `~/.claude/<X>` so
133
+ * chat history (in `projects/<cwd-hash>/<session-uuid>.jsonl`) and
134
+ * other durable user state flow between proxy and plain-`claude`
135
+ * sessions. Then writes the synthetic `.credentials.json` so spawned
136
+ * Claude Code (and teammates that inherit `CLAUDE_CONFIG_DIR`)
137
+ * authenticate.
138
+ *
139
+ * Idempotent: only re-copies files whose source `mtime` is newer than
140
+ * target; SHARED-symlink creation no-ops when the symlink already
141
+ * points at the right target. Concurrent-safe: `mkdir({recursive:true})`
142
+ * is idempotent; symlinks are created via atomic temp+rename so two
143
+ * parallel github-router-claude startups can't race to EEXIST; the
144
+ * credentials write uses temp-file + atomic rename so Claude Code's
145
+ * `EZ1()` mtime watcher never sees a partial write.
146
+ *
147
+ * Walks with `lstat` (does NOT follow symlinks during traversal — a
148
+ * symlink-into-`/` would otherwise let the walk escape). Symlink leaves
149
+ * in the source tree are skipped during the MIRRORED copy walk (per the
150
+ * symlink-confused-deputy security finding); SHARED symlinks are
151
+ * created on the mirror side only, pointing at predetermined targets
152
+ * inside the user's real `~/.claude/`.
153
+ *
154
+ * Caller is expected to invoke this after `ensurePaths()` and before
155
+ * spawning Claude Code (`launchChild`). The mirror must exist before
156
+ * the child reads it. Currently called from the `claude` subcommand
157
+ * entry point only; `start` and `codex` subcommands don't need it.
158
+ */
159
+ async function ensureClaudeConfigMirror(opts = {}) {
160
+ const realHome = opts.realHome ?? os.homedir();
161
+ const sourceDir = path.join(realHome, ".claude");
162
+ const targetDir = PATHS.CLAUDE_CONFIG_DIR;
163
+ await fs.mkdir(targetDir, {
164
+ recursive: true,
165
+ mode: 448
166
+ });
167
+ await chmodIfPossible(targetDir, 448);
168
+ let sourceExists = false;
169
+ try {
170
+ sourceExists = (await fs.stat(sourceDir)).isDirectory();
171
+ } catch (err) {
172
+ if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot stat ${sourceDir}:`, err);
173
+ }
174
+ if (sourceExists) await mirrorDirRecursive(sourceDir, targetDir, "");
175
+ await fs.mkdir(path.join(targetDir, "agents"), { recursive: true });
176
+ for (const name$1 of SHARED_TOPLEVEL_NAMES) await ensureSharedSymlink(name$1, sourceDir, targetDir).catch((err) => {
177
+ consola.debug(`ensureClaudeConfigMirror: SHARED symlink for ${name$1} skipped:`, err);
178
+ });
179
+ const credentialsPath = path.join(targetDir, ".credentials.json");
180
+ const desiredJson = JSON.stringify(SYNTHETIC_CREDENTIAL, null, 2);
181
+ let needsWrite = true;
182
+ try {
183
+ needsWrite = (await fs.readFile(credentialsPath, "utf8")).trim() !== desiredJson.trim();
184
+ } catch (err) {
185
+ if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot read existing credentials:`, err);
186
+ }
187
+ if (needsWrite) {
188
+ const tempPath = `${credentialsPath}.${process.pid}.tmp`;
189
+ try {
190
+ await fs.writeFile(tempPath, desiredJson + "\n", {
191
+ mode: 384,
192
+ flag: "wx"
193
+ });
194
+ await fs.rename(tempPath, credentialsPath);
195
+ } catch (err) {
196
+ if (err.code === "EEXIST") consola.debug("ensureClaudeConfigMirror: concurrent credentials-write detected, skipping");
197
+ else {
198
+ await fs.unlink(tempPath).catch(() => {});
199
+ throw err;
200
+ }
201
+ }
202
+ }
203
+ await chmodIfPossible(credentialsPath, 384);
204
+ const markerPath = path.join(targetDir, MANAGED_MARKER_FILENAME);
205
+ let markerExists = false;
206
+ try {
207
+ const markerStat = await fs.lstat(markerPath);
208
+ if (markerStat.isFile()) markerExists = true;
209
+ else {
210
+ 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.`);
211
+ markerExists = true;
212
+ }
213
+ } catch (err) {
214
+ if (err.code !== "ENOENT") {
215
+ consola.debug(`ensureClaudeConfigMirror: cannot lstat marker:`, err);
216
+ markerExists = true;
217
+ }
218
+ }
219
+ if (!markerExists) {
220
+ const body = `Managed by github-router. Created ${(/* @__PURE__ */ new Date()).toISOString()}. Safe to delete (will be recreated).\n`;
221
+ await fs.writeFile(markerPath, body, {
222
+ mode: 384,
223
+ flag: "wx"
224
+ }).catch((err) => {
225
+ consola.debug(`ensureClaudeConfigMirror: marker write skipped:`, err);
226
+ });
227
+ }
228
+ }
229
+ /**
230
+ * Recursive snapshot-copy helper for `ensureClaudeConfigMirror`. Walks
231
+ * `sourceDir/relPath` and mirrors each entry into `targetDir/relPath`.
232
+ * - Top-level entries are dispatched on `policyFor(name)`:
233
+ * - `ISOLATED` → skipped entirely (no presence in mirror).
234
+ * - `SHARED` → skipped from the copy walk; handled by
235
+ * `ensureSharedSymlink` in the post-copy phase.
236
+ * - `MIRRORED` → copied as today.
237
+ * - Symlinks are skipped (not recreated) so the walk never follows out
238
+ * of `sourceDir` and we don't reintroduce a confused-deputy vector.
239
+ * - Files copy only if source mtime > target mtime (idempotent).
240
+ */
241
+ async function mirrorDirRecursive(sourceDir, targetDir, relPath) {
242
+ const sourcePath = path.join(sourceDir, relPath);
243
+ let entries;
244
+ try {
245
+ entries = await fs.readdir(sourcePath);
246
+ } catch (err) {
247
+ if (err.code === "ENOENT") return;
248
+ consola.debug(`mirrorDirRecursive: cannot readdir ${sourcePath}:`, err);
249
+ return;
250
+ }
251
+ for (const name$1 of entries) {
252
+ if (relPath === "") {
253
+ const policy = policyFor(name$1);
254
+ if (policy === "ISOLATED" || policy === "SHARED") continue;
255
+ }
256
+ const childRel = relPath === "" ? name$1 : path.join(relPath, name$1);
257
+ const childSource = path.join(sourceDir, childRel);
258
+ const childTarget = path.join(targetDir, childRel);
259
+ let stats;
260
+ try {
261
+ stats = await fs.lstat(childSource);
262
+ } catch (err) {
263
+ consola.debug(`mirrorDirRecursive: cannot lstat ${childSource}:`, err);
264
+ continue;
265
+ }
266
+ if (stats.isSymbolicLink()) {
267
+ consola.debug(`mirrorDirRecursive: skipping symlink ${childSource} (security policy)`);
268
+ continue;
269
+ }
270
+ if (stats.isDirectory()) {
271
+ await fs.mkdir(childTarget, { recursive: true });
272
+ await mirrorDirRecursive(sourceDir, targetDir, childRel);
273
+ continue;
274
+ }
275
+ if (stats.isFile()) {
276
+ let needsCopy = true;
277
+ try {
278
+ const targetStat = await fs.lstat(childTarget);
279
+ if (targetStat.isFile() && targetStat.mtimeMs >= stats.mtimeMs) needsCopy = false;
280
+ } catch (err) {
281
+ if (err.code !== "ENOENT") consola.debug(`mirrorDirRecursive: lstat target ${childTarget}:`, err);
282
+ }
283
+ if (!needsCopy) continue;
284
+ try {
285
+ await fs.copyFile(childSource, childTarget, fs.constants.COPYFILE_FICLONE);
286
+ } catch (err) {
287
+ consola.debug(`mirrorDirRecursive: copy ${childSource} → ${childTarget}:`, err);
288
+ }
289
+ continue;
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Create or refresh a directory symlink `<mirrorDir>/<name>` →
295
+ * `<sourceDir>/<name>` (i.e. `~/.local/share/github-router/claude-config/<X>`
296
+ * → `~/.claude/<X>`). Idempotent and concurrent-safe.
297
+ *
298
+ * Behavior depending on what's already at `<mirrorDir>/<name>`:
299
+ * - Symlink with the correct target → no-op.
300
+ * - Symlink with the wrong target → replace atomically.
301
+ * - Empty real directory (legacy mirror leftover with no proxy-session
302
+ * writes accumulated yet) → `rmdir` and replace with the symlink.
303
+ * Safe by definition: `fs.rmdir` only succeeds on empty dirs (POSIX),
304
+ * so there is nothing to lose. Smooths the upgrade path for users
305
+ * whose legacy mirror dirs were never written to.
306
+ * - Non-empty real directory or regular file → loud-warn and skip.
307
+ * Auto-deleting would destroy proxy-session writes from the prior
308
+ * version. The user is told the exact path and remediation.
309
+ * - ENOENT → create symlink atomically.
310
+ *
311
+ * Atomic-creation: symlinks are first written at a unique side-path
312
+ * (`<mirrorDir>/<name>.tmp.<pid>.<8 hex>`) and then `fs.rename()`d into
313
+ * place. POSIX `rename` is atomic and replaces an existing symlink in
314
+ * a single step, so two concurrent `github-router claude` startups can't
315
+ * race to `EEXIST` — the loser's rename just overwrites the winner's
316
+ * symlink with an identical one. Gemini-critic 3-lab-review finding.
317
+ *
318
+ * Pre-creates `~/.claude/<name>/` as a real directory if missing so
319
+ * Claude Code's writes through the symlink don't fail with ENOENT.
320
+ */
321
+ async function ensureSharedSymlink(name$1, sourceDir, mirrorDir) {
322
+ const sourcePath = path.join(sourceDir, name$1);
323
+ const mirrorPath = path.join(mirrorDir, name$1);
324
+ try {
325
+ await fs.mkdir(sourcePath, { recursive: true });
326
+ } catch (err) {
327
+ consola.debug(`ensureSharedSymlink(${name$1}): cannot mkdir source ${sourcePath}:`, err);
328
+ return;
329
+ }
330
+ let existing = null;
331
+ try {
332
+ existing = await fs.lstat(mirrorPath);
333
+ } catch (err) {
334
+ if (err.code !== "ENOENT") {
335
+ consola.debug(`ensureSharedSymlink(${name$1}): cannot lstat ${mirrorPath}:`, err);
336
+ return;
337
+ }
338
+ }
339
+ if (existing?.isSymbolicLink()) {
340
+ let currentTarget = null;
341
+ try {
342
+ currentTarget = await fs.readlink(mirrorPath);
343
+ } catch (err) {
344
+ consola.debug(`ensureSharedSymlink(${name$1}): cannot readlink ${mirrorPath}:`, err);
345
+ }
346
+ if (currentTarget === sourcePath) return;
347
+ } else if (existing?.isDirectory()) try {
348
+ await fs.rmdir(mirrorPath);
349
+ } catch (err) {
350
+ 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$1}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink on next launch. (rmdir error: ${err.code ?? "unknown"})`);
351
+ return;
352
+ }
353
+ else if (existing) {
354
+ 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.`);
355
+ return;
356
+ }
357
+ const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
358
+ try {
359
+ await fs.symlink(sourcePath, tempPath);
360
+ } catch (err) {
361
+ consola.debug(`ensureSharedSymlink(${name$1}): symlink ${tempPath} failed:`, err);
362
+ return;
363
+ }
364
+ try {
365
+ await fs.rename(tempPath, mirrorPath);
366
+ } catch (err) {
367
+ consola.debug(`ensureSharedSymlink(${name$1}): rename ${tempPath} → ${mirrorPath} failed:`, err);
368
+ await fs.unlink(tempPath).catch(() => {});
369
+ }
370
+ }
56
371
  async function ensureFile(filePath) {
57
372
  try {
58
373
  await fs.access(filePath, fs.constants.W_OK);
@@ -139,12 +454,15 @@ function isPidAlive(pid) {
139
454
  }
140
455
  }
141
456
  /**
142
- * Sweep stale peer-* subagent .md files from `~/.claude/agents/`. Phase
143
- * 2.5 writes one .md per peer agent into the canonical agents directory
144
- * so they appear in Claude Code's Task `subagent_type` enum. Files are
145
- * named `peer-<pid>-<rand>-<agentName>.md` so this sweep can drop
146
- * orphans from crashed prior proxy sessions without touching the user's
147
- * own .md files.
457
+ * Sweep stale peer-* subagent .md files from the router-owned
458
+ * `CLAUDE_CONFIG_DIR/agents/`. Phase 2.5 writes one .md per peer agent
459
+ * into Claude Code's agents directory (now our config dir's `agents/`
460
+ * subdir, since `getClaudeCodeEnvVars` points `CLAUDE_CONFIG_DIR` at
461
+ * `PATHS.CLAUDE_CONFIG_DIR`) so they appear in Claude Code's Task
462
+ * `subagent_type` enum. Files are named `peer-<pid>-<rand>-<agentName>.md`
463
+ * so this sweep can drop orphans from crashed prior proxy sessions
464
+ * without touching the user's own .md files (which were copied into
465
+ * the same dir during `ensureClaudeConfigMirror`).
148
466
  *
149
467
  * Same liveness rule as `sweepStaleRuntimeFiles`: only delete when the
150
468
  * file's embedded PID is no longer alive. Live PIDs keep their files —
@@ -160,7 +478,7 @@ function isPidAlive(pid) {
160
478
  * realistic user filename.
161
479
  */
162
480
  async function sweepStalePeerAgentMdFiles() {
163
- const dir = path.join(os.homedir(), ".claude", "agents");
481
+ const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
164
482
  let entries;
165
483
  try {
166
484
  entries = await fs.readdir(dir);
@@ -273,19 +591,20 @@ async function forwardError(c, error) {
273
591
  }
274
592
  }, 400);
275
593
  }
594
+ const responseStatus = error.response.status === 401 ? 503 : error.response.status;
276
595
  if (isAnthropicError(errorJson)) {
277
596
  consola.error("HTTP error:", errorJson);
278
- return c.json(errorJson, error.response.status);
597
+ return c.json(errorJson, responseStatus);
279
598
  }
280
599
  const message = resolveErrorMessage(errorJson, errorText);
281
600
  consola.error("HTTP error:", errorJson ?? errorText);
282
601
  return c.json({
283
602
  type: "error",
284
603
  error: {
285
- type: resolveErrorType(error.response.status),
604
+ type: resolveErrorType(responseStatus),
286
605
  message
287
606
  }
288
- }, error.response.status);
607
+ }, responseStatus);
289
608
  }
290
609
  return c.json({
291
610
  type: "error",
@@ -342,6 +661,12 @@ function isContextOverflow(status, errorJson, errorText) {
342
661
  }
343
662
  /**
344
663
  * Map HTTP status to Anthropic error type.
664
+ *
665
+ * Note: a 401 from upstream is remapped to 503 in `forwardError` BEFORE
666
+ * this function is called (no-401 invariant — see comment there). The
667
+ * 401 → "authentication_error" mapping below is preserved for
668
+ * defensive coverage in case any code path calls `resolveErrorType`
669
+ * directly with an unsanitized status.
345
670
  */
346
671
  function resolveErrorType(status) {
347
672
  if (status === 400) return "invalid_request_error";
@@ -349,6 +674,7 @@ function resolveErrorType(status) {
349
674
  if (status === 403) return "permission_error";
350
675
  if (status === 404) return "not_found_error";
351
676
  if (status === 429) return "rate_limit_error";
677
+ if (status === 503) return "overloaded_error";
352
678
  if (status === 529) return "overloaded_error";
353
679
  return "api_error";
354
680
  }
@@ -1136,6 +1462,7 @@ const STRIPPED_PARENT_ENV_KEYS = [
1136
1462
  "ANTHROPIC_CUSTOM_HEADERS",
1137
1463
  "ANTHROPIC_MODEL",
1138
1464
  "CLAUDE_CODE_OAUTH_TOKEN",
1465
+ "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
1139
1466
  "CLAUDE_CODE_USE_BEDROCK",
1140
1467
  "CLAUDE_CODE_USE_VERTEX",
1141
1468
  "CLAUDE_CODE_USE_FOUNDRY",
@@ -1671,12 +1998,16 @@ function buildPeerAgentDefinitions(opts) {
1671
1998
  * Default location Claude Code reads subagent .md files from at session
1672
1999
  * startup. Files placed here populate the Task `subagent_type` enum.
1673
2000
  *
1674
- * We pin to the user's `~/.claude/agents/` because `getClaudeCodeEnvVars`
1675
- * sets `CLAUDE_CONFIG_DIR=$HOME/.claude` (the Spawned-CLI auth isolation
1676
- * trick) the spawned child reads from this exact path.
2001
+ * We point at the router-owned `PATHS.CLAUDE_CONFIG_DIR/agents/` because
2002
+ * `getClaudeCodeEnvVars` sets `CLAUDE_CONFIG_DIR=PATHS.CLAUDE_CONFIG_DIR`
2003
+ * (the snapshot-mirror substrate fix that gives spawned teammates an
2004
+ * authenticatable on-disk credential). The user's own custom-agent .md
2005
+ * files were copied into this same dir by `ensureClaudeConfigMirror`,
2006
+ * so writing peer-* files here doesn't conflict — and the boot-time
2007
+ * sweep is scoped to peer-* names only via the persona-name allowlist.
1677
2008
  */
1678
2009
  function defaultAgentsDir() {
1679
- return path.join(os.homedir(), ".claude", "agents");
2010
+ return path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
1680
2011
  }
1681
2012
  /**
1682
2013
  * YAML frontmatter string-escape — sufficient for our use case where
@@ -2013,7 +2344,7 @@ function initProxyFromEnv() {
2013
2344
  //#endregion
2014
2345
  //#region package.json
2015
2346
  var name = "github-router";
2016
- var version = "0.3.20";
2347
+ var version = "0.3.22";
2017
2348
 
2018
2349
  //#endregion
2019
2350
  //#region src/lib/approval.ts
@@ -2124,6 +2455,50 @@ function detectCapabilityMismatch(info, model) {
2124
2455
  const err = info.errorBody.toLowerCase();
2125
2456
  return err.includes("token") || err.includes("context") || err.includes("too long") || err.includes("max_tokens") || err.includes("prompt is too long");
2126
2457
  }
2458
+ /**
2459
+ * Opt-in instrumentation for the discovery loop (Phase 0.5 of the
2460
+ * long-horizon plan). When `GH_ROUTER_LOG_FIELDS=1` is set in the
2461
+ * environment, emits a single structured `[fields]` log line per request
2462
+ * recording the top-level body keys, per-tool field keys, and
2463
+ * anthropic-beta header values seen.
2464
+ *
2465
+ * Default-off (zero overhead). The companion
2466
+ * `scripts/discover-new-fields.sh` greps these lines, aggregates unique
2467
+ * field names per request shape, and diffs against the known-fields
2468
+ * list in `docs/copilot-compat-matrix.md` — surfacing anything new
2469
+ * that should get a probe row added.
2470
+ *
2471
+ * Format (single line, deterministic-ish key order):
2472
+ * [fields] path=<P> body_keys=<csv> tool_field_keys=<csv> beta_values=<csv>
2473
+ *
2474
+ * Where:
2475
+ * - `body_keys` is the alphabetical union of top-level keys in the
2476
+ * request body
2477
+ * - `tool_field_keys` is the alphabetical union of all keys appearing
2478
+ * across every entry of `body.tools[]` (or empty)
2479
+ * - `beta_values` is the comma-split anthropic-beta header value as
2480
+ * received (NOT filtered) — captures what the client sends, not
2481
+ * what we forward
2482
+ */
2483
+ function logRequestFields(opts) {
2484
+ if (process.env.GH_ROUTER_LOG_FIELDS !== "1") return;
2485
+ const bodyKeys = collectTopLevelKeys(opts.body);
2486
+ const toolFieldKeys = collectToolFieldKeys(opts.body);
2487
+ const betaValues = (opts.betaHeader ?? "").split(",").map((v) => v.trim()).filter(Boolean);
2488
+ consola.info(`[fields] path=${opts.path} body_keys=${bodyKeys.join(",")} tool_field_keys=${toolFieldKeys.join(",")} beta_values=${betaValues.join(",")}`);
2489
+ }
2490
+ function collectTopLevelKeys(body) {
2491
+ if (!body || typeof body !== "object" || Array.isArray(body)) return [];
2492
+ return Object.keys(body).sort();
2493
+ }
2494
+ function collectToolFieldKeys(body) {
2495
+ if (!body || typeof body !== "object") return [];
2496
+ const tools = body.tools;
2497
+ if (!Array.isArray(tools)) return [];
2498
+ const seen = /* @__PURE__ */ new Set();
2499
+ for (const tool of tools) if (tool && typeof tool === "object" && !Array.isArray(tool)) for (const k of Object.keys(tool)) seen.add(k);
2500
+ return [...seen].sort();
2501
+ }
2127
2502
 
2128
2503
  //#endregion
2129
2504
  //#region src/lib/stream-relay.ts
@@ -4416,7 +4791,7 @@ function resolveModelInBody$1(rawBody) {
4416
4791
  }
4417
4792
  }
4418
4793
  if (rawBody.includes("\"scope\"") && sanitizeCacheControl$1(parsed)) modified = true;
4419
- if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"")) && stripAnthropicOnlyFields$1(parsed)) modified = true;
4794
+ if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"") || rawBody.includes("\"eager_input_streaming\"")) && stripAnthropicOnlyFields$1(parsed)) modified = true;
4420
4795
  const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
4421
4796
  return {
4422
4797
  body: modified ? JSON.stringify(parsed) : rawBody,
@@ -4478,6 +4853,20 @@ function stripAnthropicOnlyFields$1(body) {
4478
4853
  delete body.betas;
4479
4854
  stripped = true;
4480
4855
  }
4856
+ if (Array.isArray(body.tools)) {
4857
+ let warnedFGTS = false;
4858
+ for (const tool of body.tools) if (typeof tool === "object" && tool !== null) {
4859
+ const t = tool;
4860
+ if (t.eager_input_streaming !== void 0) {
4861
+ delete t.eager_input_streaming;
4862
+ stripped = true;
4863
+ if (!warnedFGTS) {
4864
+ consola.warn("[count_tokens] Stripping per-tool `eager_input_streaming` (Copilot 400s on `tools.*.custom.eager_input_streaming`)");
4865
+ warnedFGTS = true;
4866
+ }
4867
+ }
4868
+ }
4869
+ }
4481
4870
  return stripped;
4482
4871
  }
4483
4872
 
@@ -4588,6 +4977,17 @@ async function handleCompletion(c) {
4588
4977
  const rawBody = await c.req.text();
4589
4978
  const debugEnabled = consola.level >= 4;
4590
4979
  if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
4980
+ if (process.env.GH_ROUTER_LOG_FIELDS === "1") {
4981
+ let parsedForLog = void 0;
4982
+ try {
4983
+ parsedForLog = JSON.parse(rawBody);
4984
+ } catch {}
4985
+ logRequestFields({
4986
+ path: c.req.path,
4987
+ body: parsedForLog,
4988
+ betaHeader: c.req.header("anthropic-beta")
4989
+ });
4990
+ }
4591
4991
  if (state.manualApprove) await awaitApproval();
4592
4992
  const betaHeaders = extractBetaHeaders(c);
4593
4993
  const advisorEnabled = isAdvisorRequested(c.req.header("anthropic-beta"));
@@ -4730,7 +5130,7 @@ function resolveModelInBody(rawBody) {
4730
5130
  const selectedModel = resolvedModel ? state.models?.data.find((m) => m.id === resolvedModel) : void 0;
4731
5131
  if (translateThinking(parsed, selectedModel)) modified = true;
4732
5132
  if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
4733
- if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"")) && stripAnthropicOnlyFields(parsed)) modified = true;
5133
+ if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"") || rawBody.includes("\"eager_input_streaming\"")) && stripAnthropicOnlyFields(parsed)) modified = true;
4734
5134
  return {
4735
5135
  body: modified ? JSON.stringify(parsed) : rawBody,
4736
5136
  originalModel,
@@ -4899,6 +5299,20 @@ function stripAnthropicOnlyFields(body) {
4899
5299
  delete body.betas;
4900
5300
  stripped = true;
4901
5301
  }
5302
+ if (Array.isArray(body.tools)) {
5303
+ let warnedFGTS = false;
5304
+ for (const tool of body.tools) if (typeof tool === "object" && tool !== null) {
5305
+ const t = tool;
5306
+ if (t.eager_input_streaming !== void 0) {
5307
+ delete t.eager_input_streaming;
5308
+ stripped = true;
5309
+ if (!warnedFGTS) {
5310
+ consola.warn("Stripping per-tool `eager_input_streaming` field (Copilot 400s on `tools.*.custom.eager_input_streaming`; FGTS chunk-size optimization disabled, but streaming correctness is unaffected — `input_json_delta` events still flow normally)");
5311
+ warnedFGTS = true;
5312
+ }
5313
+ }
5314
+ }
5315
+ }
4902
5316
  return stripped;
4903
5317
  }
4904
5318
  /**
@@ -5519,48 +5933,50 @@ function parseSharedArgs(args) {
5519
5933
  * (see `src/lib/launch.ts`) BEFORE these overrides are merged in, so we
5520
5934
  * only need to provide the positive values.
5521
5935
  *
5522
- * Auth precedence in Claude Code (https://code.claude.com/docs/en/iam):
5936
+ * Auth precedence in Claude Code (https://code.claude.com/docs/en/iam),
5937
+ * after the github-router substrate fix:
5523
5938
  * 1. Cloud provider (CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY) — stripped at parent.
5524
- * 2. ANTHROPIC_AUTH_TOKEN — set here to "dummy"; wins over #4–#6.
5525
- * 3. ANTHROPIC_API_KEY stripped at parent, intentionally NOT re-set
5526
- * (Claude Code emits an Auth conflict warning when both AUTH_TOKEN
5527
- * and API_KEY are present, even with dummy values).
5528
- * 4. apiKeyHelper in settings.json beaten by #2.
5939
+ * 2. ANTHROPIC_AUTH_TOKEN — NOT set by the proxy. Stripped at parent
5940
+ * (no env-source auth in the spawned child at all).
5941
+ * 3. ANTHROPIC_API_KEY stripped at parent.
5942
+ * 4. apiKeyHelper in settings.json copied into our config dir as
5943
+ * part of the mirror; if the user defined one, it still fires
5944
+ * and may mint an `x-api-key` header. Copilot ignores `x-api-key`,
5945
+ * so behavior is unchanged from before this fix.
5529
5946
  * 5. CLAUDE_CODE_OAUTH_TOKEN — stripped at parent.
5530
- * 6. Subscription OAuth (Keychain / ~/.claude/.credentials.json)
5531
- * INVISIBLE to the spawned child via the CLAUDE_CONFIG_DIR trick
5532
- * below. The credential file is left in place so `claude /logout`
5533
- * still works outside the proxy.
5947
+ * 6. Subscription OAuth (Keychain / `<CLAUDE_CONFIG_DIR>/.credentials.json`)
5948
+ * the credentials file is OURS (synthetic blob, written by
5949
+ * `ensureClaudeConfigMirror`). Claude Code reads accessToken from
5950
+ * it and sends as `Authorization: Bearer <accessToken>`. The
5951
+ * teammate-spawn allowlist propagates `CLAUDE_CONFIG_DIR` to
5952
+ * children, so spawned teammates find the same synthetic credential
5953
+ * and authenticate (the bug this whole fix addresses).
5534
5954
  *
5535
5955
  * `CLAUDE_CONFIG_DIR` activates Claude Code's per-config-dir keychain
5536
- * isolation. Per binary-grep of Claude Code 2.1.126's `iN()` function:
5956
+ * isolation (per binary-grep of v2.1.126's `iN()` function: when set,
5957
+ * the keychain service name becomes `Claude Code-<sha256(path)[0..8]>`,
5958
+ * missing the user's real `Claude Code` entry). Pointing it at our
5959
+ * snapshot-copied `PATHS.CLAUDE_CONFIG_DIR` preserves user customization
5960
+ * (mirrored settings.json, skills, MCP, hooks, CLAUDE.md, custom
5961
+ * agents) while giving teammates a credential they can find on disk.
5537
5962
  *
5538
- * function iN(H = "") {
5539
- * let _ = B6(), // resolved config-dir path
5540
- * K = !process.env.CLAUDE_CONFIG_DIR ? "" : `-${sha256(_).slice(0, 8)}`;
5541
- * return `Claude Code${OAUTH_FILE_SUFFIX}${H}${K}`
5542
- * }
5543
- *
5544
- * The conditional is on PRESENCE, not value. When CLAUDE_CONFIG_DIR is
5545
- * unset (the user's normal `claude` usage), the keychain service name is
5546
- * "Claude Code" and their `/login` credential is found there. When set
5547
- * (the proxy session), the service name becomes "Claude Code-<hash>" —
5548
- * the user's credential is invisible, `iCH()` returns null, and all
5549
- * three auth-conflict warnings fire `false`. The path resolves to the
5550
- * default config-dir, so settings.json/skills/MCP/plugins/hooks/CLAUDE.md
5551
- * still load from `~/.claude` as normal.
5963
+ * No-401 invariant: Claude Code's reactive refresh path (`SZ1` →
5964
+ * `D3(0,true,...)`) fires on any 401 from upstream. The synthetic
5965
+ * refreshToken would fail any real refresh attempt, so the proxy
5966
+ * MUST NOT return 401 on the Anthropic-shape boundary even when
5967
+ * upstream Copilot returns 401. See `src/routes/messages/handler.ts`.
5552
5968
  */
5553
5969
  function getClaudeCodeEnvVars(serverUrl, model) {
5554
5970
  const vars = {
5555
5971
  ANTHROPIC_BASE_URL: serverUrl,
5556
- ANTHROPIC_AUTH_TOKEN: "dummy",
5557
- CLAUDE_CONFIG_DIR: path.join(os.homedir(), ".claude"),
5972
+ CLAUDE_CONFIG_DIR: PATHS.CLAUDE_CONFIG_DIR,
5558
5973
  MCP_TIMEOUT: "600000",
5559
5974
  DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
5560
5975
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
5561
5976
  DISABLE_TELEMETRY: "1"
5562
5977
  };
5563
5978
  if (model) vars.ANTHROPIC_MODEL = model;
5979
+ if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-haiku-4-5";
5564
5980
  for (const key of [
5565
5981
  "CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL",
5566
5982
  "CLAUDE_CODE_FORK_SUBAGENT",
@@ -5673,6 +6089,12 @@ const claude = defineCommand({
5673
6089
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
5674
6090
  process$1.exit(1);
5675
6091
  }
6092
+ try {
6093
+ await ensureClaudeConfigMirror();
6094
+ } catch (err) {
6095
+ consola.error(`Failed to provision CLAUDE_CONFIG_DIR mirror: ${err instanceof Error ? err.message : String(err)}. Spawned Claude Code would not be able to authenticate.`);
6096
+ process$1.exit(1);
6097
+ }
5676
6098
  enableFileLogging();
5677
6099
  const usingDefault = !args.model;
5678
6100
  let chosenSlug = args.model ?? DEFAULT_CLAUDE_MODEL;