github-router 0.3.20 → 0.3.21
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 +382 -43
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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
|
|
143
|
-
* 2.5 writes one .md per peer agent
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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(
|
|
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,
|
|
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(
|
|
604
|
+
type: resolveErrorType(responseStatus),
|
|
286
605
|
message
|
|
287
606
|
}
|
|
288
|
-
},
|
|
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
|
|
1675
|
-
* sets `CLAUDE_CONFIG_DIR
|
|
1676
|
-
*
|
|
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(
|
|
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.
|
|
2347
|
+
var version = "0.3.21";
|
|
2017
2348
|
|
|
2018
2349
|
//#endregion
|
|
2019
2350
|
//#region src/lib/approval.ts
|
|
@@ -5519,48 +5850,50 @@ function parseSharedArgs(args) {
|
|
|
5519
5850
|
* (see `src/lib/launch.ts`) BEFORE these overrides are merged in, so we
|
|
5520
5851
|
* only need to provide the positive values.
|
|
5521
5852
|
*
|
|
5522
|
-
* Auth precedence in Claude Code (https://code.claude.com/docs/en/iam)
|
|
5853
|
+
* Auth precedence in Claude Code (https://code.claude.com/docs/en/iam),
|
|
5854
|
+
* after the github-router substrate fix:
|
|
5523
5855
|
* 1. Cloud provider (CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY) — stripped at parent.
|
|
5524
|
-
* 2. ANTHROPIC_AUTH_TOKEN — set
|
|
5525
|
-
*
|
|
5526
|
-
*
|
|
5527
|
-
*
|
|
5528
|
-
*
|
|
5856
|
+
* 2. ANTHROPIC_AUTH_TOKEN — NOT set by the proxy. Stripped at parent
|
|
5857
|
+
* (no env-source auth in the spawned child at all).
|
|
5858
|
+
* 3. ANTHROPIC_API_KEY — stripped at parent.
|
|
5859
|
+
* 4. apiKeyHelper in settings.json — copied into our config dir as
|
|
5860
|
+
* part of the mirror; if the user defined one, it still fires
|
|
5861
|
+
* and may mint an `x-api-key` header. Copilot ignores `x-api-key`,
|
|
5862
|
+
* so behavior is unchanged from before this fix.
|
|
5529
5863
|
* 5. CLAUDE_CODE_OAUTH_TOKEN — stripped at parent.
|
|
5530
|
-
* 6. Subscription OAuth (Keychain /
|
|
5531
|
-
*
|
|
5532
|
-
*
|
|
5533
|
-
*
|
|
5864
|
+
* 6. Subscription OAuth (Keychain / `<CLAUDE_CONFIG_DIR>/.credentials.json`)
|
|
5865
|
+
* — the credentials file is OURS (synthetic blob, written by
|
|
5866
|
+
* `ensureClaudeConfigMirror`). Claude Code reads accessToken from
|
|
5867
|
+
* it and sends as `Authorization: Bearer <accessToken>`. The
|
|
5868
|
+
* teammate-spawn allowlist propagates `CLAUDE_CONFIG_DIR` to
|
|
5869
|
+
* children, so spawned teammates find the same synthetic credential
|
|
5870
|
+
* and authenticate (the bug this whole fix addresses).
|
|
5534
5871
|
*
|
|
5535
5872
|
* `CLAUDE_CONFIG_DIR` activates Claude Code's per-config-dir keychain
|
|
5536
|
-
* isolation
|
|
5537
|
-
*
|
|
5538
|
-
*
|
|
5539
|
-
*
|
|
5540
|
-
*
|
|
5541
|
-
*
|
|
5542
|
-
* }
|
|
5873
|
+
* isolation (per binary-grep of v2.1.126's `iN()` function: when set,
|
|
5874
|
+
* the keychain service name becomes `Claude Code-<sha256(path)[0..8]>`,
|
|
5875
|
+
* missing the user's real `Claude Code` entry). Pointing it at our
|
|
5876
|
+
* snapshot-copied `PATHS.CLAUDE_CONFIG_DIR` preserves user customization
|
|
5877
|
+
* (mirrored settings.json, skills, MCP, hooks, CLAUDE.md, custom
|
|
5878
|
+
* agents) while giving teammates a credential they can find on disk.
|
|
5543
5879
|
*
|
|
5544
|
-
*
|
|
5545
|
-
*
|
|
5546
|
-
*
|
|
5547
|
-
*
|
|
5548
|
-
*
|
|
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.
|
|
5880
|
+
* No-401 invariant: Claude Code's reactive refresh path (`SZ1` →
|
|
5881
|
+
* `D3(0,true,...)`) fires on any 401 from upstream. The synthetic
|
|
5882
|
+
* refreshToken would fail any real refresh attempt, so the proxy
|
|
5883
|
+
* MUST NOT return 401 on the Anthropic-shape boundary even when
|
|
5884
|
+
* upstream Copilot returns 401. See `src/routes/messages/handler.ts`.
|
|
5552
5885
|
*/
|
|
5553
5886
|
function getClaudeCodeEnvVars(serverUrl, model) {
|
|
5554
5887
|
const vars = {
|
|
5555
5888
|
ANTHROPIC_BASE_URL: serverUrl,
|
|
5556
|
-
|
|
5557
|
-
CLAUDE_CONFIG_DIR: path.join(os.homedir(), ".claude"),
|
|
5889
|
+
CLAUDE_CONFIG_DIR: PATHS.CLAUDE_CONFIG_DIR,
|
|
5558
5890
|
MCP_TIMEOUT: "600000",
|
|
5559
5891
|
DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
|
|
5560
5892
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
5561
5893
|
DISABLE_TELEMETRY: "1"
|
|
5562
5894
|
};
|
|
5563
5895
|
if (model) vars.ANTHROPIC_MODEL = model;
|
|
5896
|
+
if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-haiku-4-5";
|
|
5564
5897
|
for (const key of [
|
|
5565
5898
|
"CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL",
|
|
5566
5899
|
"CLAUDE_CODE_FORK_SUBAGENT",
|
|
@@ -5673,6 +6006,12 @@ const claude = defineCommand({
|
|
|
5673
6006
|
consola.error("Failed to start server:", error instanceof Error ? error.message : error);
|
|
5674
6007
|
process$1.exit(1);
|
|
5675
6008
|
}
|
|
6009
|
+
try {
|
|
6010
|
+
await ensureClaudeConfigMirror();
|
|
6011
|
+
} catch (err) {
|
|
6012
|
+
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.`);
|
|
6013
|
+
process$1.exit(1);
|
|
6014
|
+
}
|
|
5676
6015
|
enableFileLogging();
|
|
5677
6016
|
const usingDefault = !args.model;
|
|
5678
6017
|
let chosenSlug = args.model ?? DEFAULT_CLAUDE_MODEL;
|