github-router 0.3.271 → 0.3.274
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-DLRQEKpU.js → attribution-settings-Cqh_A9yx.js} +3 -3
- package/dist/{attribution-settings-DLRQEKpU.js.map → attribution-settings-Cqh_A9yx.js.map} +1 -1
- package/dist/{auth-DIFbHOu0.js → auth-Q1tfmfCT.js} +2 -2
- package/dist/{auth-DIFbHOu0.js.map → auth-Q1tfmfCT.js.map} +1 -1
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/{check-usage-DfnrR-d_.js → check-usage-CHLz4tU_.js} +3 -3
- package/dist/{check-usage-DfnrR-d_.js.map → check-usage-CHLz4tU_.js.map} +1 -1
- package/dist/{claude-BpQVQE0T.js → claude-CqFkkQGo.js} +7 -7
- package/dist/{claude-BpQVQE0T.js.map → claude-CqFkkQGo.js.map} +1 -1
- package/dist/{codex-CGMiGJy1.js → codex-gGkDJHZJ.js} +5 -5
- package/dist/{codex-CGMiGJy1.js.map → codex-gGkDJHZJ.js.map} +1 -1
- package/dist/engine-BM2mIXsc.js +2 -0
- package/dist/{gate-discovery-BBA7cV1D.js → gate-discovery-e114xla0.js} +2 -2
- package/dist/{gate-discovery-BBA7cV1D.js.map → gate-discovery-e114xla0.js.map} +1 -1
- package/dist/{get-copilot-usage-Ds09xSmH.js → get-copilot-usage-BRN18uMJ.js} +2 -2
- package/dist/{get-copilot-usage-Ds09xSmH.js.map → get-copilot-usage-BRN18uMJ.js.map} +1 -1
- package/dist/{internal-stop-hook-CO2NDmGL.js → internal-stop-hook-BLPxEImO.js} +2 -2
- package/dist/{internal-stop-hook-CO2NDmGL.js.map → internal-stop-hook-BLPxEImO.js.map} +1 -1
- package/dist/main.js +8 -8
- package/dist/{models-BFQUp0ov.js → models-d0gkuYGy.js} +2 -2
- package/dist/{models-BFQUp0ov.js.map → models-d0gkuYGy.js.map} +1 -1
- package/dist/{peer-mcp-personas-CN8xaXcV.js → peer-mcp-personas-pzk1zcDa.js} +111 -8
- package/dist/peer-mcp-personas-pzk1zcDa.js.map +1 -0
- package/dist/{provision-CiPjSx7J.js → provision-BIUvGrMS.js} +2 -2
- package/dist/{provision-CiPjSx7J.js.map → provision-BIUvGrMS.js.map} +1 -1
- package/dist/{serve-CpTMr5D9.js → serve-BYbEyMoF.js} +6 -6
- package/dist/{serve-CpTMr5D9.js.map → serve-BYbEyMoF.js.map} +1 -1
- package/dist/{server-setup-B02Myghc.js → server-setup-CNKsKERG.js} +566 -8
- package/dist/server-setup-CNKsKERG.js.map +1 -0
- package/dist/{start-CIV9eXVb.js → start-Cy_fEtEw.js} +3 -3
- package/dist/{start-CIV9eXVb.js.map → start-Cy_fEtEw.js.map} +1 -1
- package/dist/{token-iaSGp76k.js → token-Bz6HoH2H.js} +438 -51
- package/dist/token-Bz6HoH2H.js.map +1 -0
- package/package.json +2 -1
- package/dist/engine-DUJyXWZn.js +0 -2
- package/dist/peer-mcp-personas-CN8xaXcV.js.map +0 -1
- package/dist/server-setup-B02Myghc.js.map +0 -1
- package/dist/token-iaSGp76k.js.map +0 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { t as PATHS } from "./paths-Bg1_DWhi.js";
|
|
2
2
|
import consola from "consola";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import process$1 from "node:process";
|
|
7
7
|
//#region src/lib/state.ts
|
|
8
8
|
const state = {
|
|
9
9
|
accountType: "enterprise",
|
|
10
|
+
copilotTokenGeneration: 0,
|
|
10
11
|
manualApprove: false,
|
|
11
12
|
rateLimitWait: false,
|
|
12
13
|
showToken: false,
|
|
@@ -125,8 +126,60 @@ const TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
|
125
126
|
"EAI_AGAIN",
|
|
126
127
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
127
128
|
"UND_ERR_HEADERS_TIMEOUT",
|
|
128
|
-
"UND_ERR_SOCKET"
|
|
129
|
+
"UND_ERR_SOCKET",
|
|
130
|
+
"UND_ERR_CLOSED",
|
|
131
|
+
"ERR_HTTP2_INVALID_SESSION",
|
|
132
|
+
"ERR_HTTP2_GOAWAY_SESSION",
|
|
133
|
+
"ERR_HTTP2_STREAM_CANCEL"
|
|
129
134
|
]);
|
|
135
|
+
/**
|
|
136
|
+
* TLS faults that are genuinely permanent: a protocol/version/cipher mismatch,
|
|
137
|
+
* or a certificate this client cannot validate. Retrying any of these buys
|
|
138
|
+
* three times the latency before the identical failure.
|
|
139
|
+
*
|
|
140
|
+
* Deliberately NOT the blanket `"ssl routines"` match this replaces. Every
|
|
141
|
+
* OpenSSL error carries that string, so the catch-all also swallowed alert 20
|
|
142
|
+
* (`bad record mac`) — an integrity fault a fresh connection routinely clears.
|
|
143
|
+
* See TRANSIENT_TLS_MESSAGES.
|
|
144
|
+
*
|
|
145
|
+
* Each entry must stay disjoint from TRANSIENT_TLS_MESSAGES. The bucket order
|
|
146
|
+
* in `classifyTransportError` checks the narrower transient alert first, so an
|
|
147
|
+
* overlap would resolve as transient; `tests/upstream-retry.test.ts` pins that
|
|
148
|
+
* no error matches two buckets.
|
|
149
|
+
*/
|
|
150
|
+
const DETERMINISTIC_TLS_MESSAGES = [
|
|
151
|
+
"self signed certificate",
|
|
152
|
+
"certificate has expired",
|
|
153
|
+
"unable to verify the first certificate",
|
|
154
|
+
"unable to get local issuer certificate",
|
|
155
|
+
"hostname/ip does not match certificate",
|
|
156
|
+
"certificate verify failed",
|
|
157
|
+
"unknown ca",
|
|
158
|
+
"wrong version number",
|
|
159
|
+
"unsupported protocol",
|
|
160
|
+
"no protocols available",
|
|
161
|
+
"alert handshake failure",
|
|
162
|
+
"alert protocol version",
|
|
163
|
+
"alert insufficient security",
|
|
164
|
+
"tls handshake"
|
|
165
|
+
];
|
|
166
|
+
/**
|
|
167
|
+
* TLS alert 20 (`bad_record_mac`) ONLY — a record failed AEAD authentication
|
|
168
|
+
* somewhere on the path. The connection is unusable, but the next one is
|
|
169
|
+
* typically fine, which is exactly what a bounded retry is for.
|
|
170
|
+
*
|
|
171
|
+
* This also matches OpenSSL's local-detection wording ("decryption failed or
|
|
172
|
+
* bad record mac", reason 281) as well as the received-alert wording ("ssl/tls
|
|
173
|
+
* alert bad record mac", reason 1020). Both are integrity faults and both
|
|
174
|
+
* warrant the same one-bounded-replay policy.
|
|
175
|
+
*
|
|
176
|
+
* An earlier revision promoted four alert phrases; three were wrong and stay
|
|
177
|
+
* deterministic on purpose. Alert 10 (`unexpected message`) is protocol state,
|
|
178
|
+
* alert 21 (`decryption failed`) is legacy-server incompatibility, and alert 22
|
|
179
|
+
* (`record overflow`) is a malformed or desynchronised stream. Promoting those
|
|
180
|
+
* converts fail-fast configuration errors into 3x latency.
|
|
181
|
+
*/
|
|
182
|
+
const TRANSIENT_TLS_MESSAGES = ["bad record mac"];
|
|
130
183
|
const DETERMINISTIC_CODES = /* @__PURE__ */ new Set([
|
|
131
184
|
"ENOTFOUND",
|
|
132
185
|
"ECONNREFUSED",
|
|
@@ -201,6 +254,15 @@ function sanitizeTransportText(value) {
|
|
|
201
254
|
* Classify errors thrown before the first response byte. Deterministic
|
|
202
255
|
* connectivity/configuration signals win over generic runtime wording such as
|
|
203
256
|
* TypeError("fetch failed"), because the nested cause is more specific.
|
|
257
|
+
*
|
|
258
|
+
* Buckets are evaluated in a stated total order, narrowest first, and the first
|
|
259
|
+
* match wins — so no error can land in two of them:
|
|
260
|
+
*
|
|
261
|
+
* 1. caller cancel
|
|
262
|
+
* 2. TLS alert 20 (`bad record mac`) — integrity, retryable
|
|
263
|
+
* 3. deterministic cert/config codes and messages, plus refused/DNS-miss
|
|
264
|
+
* 4. transient codes (incl. HTTP/2 session death) and generic transport text
|
|
265
|
+
* 5. everything else — `non_transport`, rethrown raw
|
|
204
266
|
*/
|
|
205
267
|
function classifyTransportError(error, opts = {}) {
|
|
206
268
|
const name = readStringProperty(error, "name") ?? "Error";
|
|
@@ -228,7 +290,14 @@ function classifyTransportError(error, opts = {}) {
|
|
|
228
290
|
code,
|
|
229
291
|
causeCode
|
|
230
292
|
};
|
|
231
|
-
if (
|
|
293
|
+
if (TRANSIENT_TLS_MESSAGES.some((phrase) => messages.includes(phrase))) return {
|
|
294
|
+
classification: "transient",
|
|
295
|
+
name,
|
|
296
|
+
message,
|
|
297
|
+
code,
|
|
298
|
+
causeCode
|
|
299
|
+
};
|
|
300
|
+
if (codes.some((value) => DETERMINISTIC_CODES.has(value)) || DETERMINISTIC_TLS_MESSAGES.some((phrase) => messages.includes(phrase)) || messages.includes("ssl routines") || messages.includes("econnrefused") || messages.includes("enotfound")) return {
|
|
232
301
|
classification: "deterministic",
|
|
233
302
|
name,
|
|
234
303
|
message,
|
|
@@ -410,10 +479,10 @@ async function forwardError(c, error) {
|
|
|
410
479
|
if (error.classification === "transient") return c.json({
|
|
411
480
|
type: "error",
|
|
412
481
|
error: {
|
|
413
|
-
type: "
|
|
414
|
-
message: `Upstream transport was interrupted at ${target} after ${error.attempts} attempts
|
|
482
|
+
type: "api_error",
|
|
483
|
+
message: `Upstream transport was interrupted at ${target} after ${error.attempts} attempts. ${diagnostic}`
|
|
415
484
|
}
|
|
416
|
-
},
|
|
485
|
+
}, 502);
|
|
417
486
|
return c.json({
|
|
418
487
|
type: "error",
|
|
419
488
|
error: {
|
|
@@ -563,9 +632,113 @@ function isAllowedCopilotHost(rawUrl) {
|
|
|
563
632
|
if (parsed.protocol !== "https:") return false;
|
|
564
633
|
return COPILOT_HOST_ALLOWLIST.includes(parsed.hostname);
|
|
565
634
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
635
|
+
/**
|
|
636
|
+
* Short, non-reversible identifier for a credential, safe to log.
|
|
637
|
+
*
|
|
638
|
+
* During the 2026-08-08 incident the logs could not answer the one question
|
|
639
|
+
* that mattered: was the process holding a stale credential, or was the
|
|
640
|
+
* credential on disk itself dead? The operator re-authenticated at 13:53 and
|
|
641
|
+
* the proxy kept failing, and nothing recorded whether those were the same
|
|
642
|
+
* secret. A truncated SHA-256 makes two credentials comparable across log
|
|
643
|
+
* lines without ever printing one. 8 hex chars is ample to distinguish the
|
|
644
|
+
* handful of credentials a machine sees, and far too short to attack.
|
|
645
|
+
*/
|
|
646
|
+
function credentialFingerprint(token) {
|
|
647
|
+
if (!token) return "none";
|
|
648
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 8);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* A failed `/copilot_internal/v2/token` exchange, carrying enough to diagnose
|
|
652
|
+
* it after the fact.
|
|
653
|
+
*
|
|
654
|
+
* The predecessor threw `HTTPError("Failed to get Copilot token", response)`
|
|
655
|
+
* and every catch logged only `.message`, so status and body were dropped —
|
|
656
|
+
* which is why the incident is unattributable.
|
|
657
|
+
*/
|
|
658
|
+
var CopilotTokenExchangeError = class extends HTTPError {
|
|
659
|
+
kind;
|
|
660
|
+
status;
|
|
661
|
+
/** Fingerprint of the credential that was rejected, never the credential. */
|
|
662
|
+
credential;
|
|
663
|
+
constructor(opts) {
|
|
664
|
+
super(`Failed to get Copilot token: HTTP ${opts.status} (${opts.kind}, credential ${opts.credential})${opts.detail ? ` — ${opts.detail}` : ""}`, opts.response);
|
|
665
|
+
this.kind = opts.kind;
|
|
666
|
+
this.status = opts.status;
|
|
667
|
+
this.credential = opts.credential;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
/** Upper bound on how much of an error body we quote into a log line. */
|
|
671
|
+
const ERROR_BODY_SNIPPET_CHARS = 300;
|
|
672
|
+
/**
|
|
673
|
+
* `notification_id` values that mean "the credential is valid but the
|
|
674
|
+
* entitlement is not". These need a different remedy than a bad credential:
|
|
675
|
+
* telling someone whose subscription lapsed to re-authenticate sends them
|
|
676
|
+
* around a loop that cannot help.
|
|
677
|
+
*/
|
|
678
|
+
const ENTITLEMENT_NOTIFICATIONS = /* @__PURE__ */ new Set([
|
|
679
|
+
"access_revoked",
|
|
680
|
+
"subscription_ended",
|
|
681
|
+
"no_copilot_access"
|
|
682
|
+
]);
|
|
683
|
+
/**
|
|
684
|
+
* Classify a non-OK exchange response.
|
|
685
|
+
*
|
|
686
|
+
* Deliberately asymmetric: only a 401, or a 403 that NAMES an entitlement
|
|
687
|
+
* problem, is treated as terminal. A bare 403 stays transient because GitHub
|
|
688
|
+
* returns 403 for primary rate limits (`x-ratelimit-remaining: 0`) and
|
|
689
|
+
* secondary abuse limits — telling a user with a perfectly good credential to
|
|
690
|
+
* re-authenticate because they were briefly rate-limited would be a worse
|
|
691
|
+
* failure than the one being fixed. The costs are not symmetric: calling a
|
|
692
|
+
* terminal failure transient wastes some retries, while calling a transient
|
|
693
|
+
* failure terminal kills a working session.
|
|
694
|
+
*/
|
|
695
|
+
function classifyExchangeFailure(status, body) {
|
|
696
|
+
if (status === 401) return "credential_rejected";
|
|
697
|
+
if (status === 403) {
|
|
698
|
+
let notificationId;
|
|
699
|
+
try {
|
|
700
|
+
const parsed = JSON.parse(body);
|
|
701
|
+
notificationId = parsed.notification_id ?? parsed.error?.notification_id;
|
|
702
|
+
} catch {}
|
|
703
|
+
if (typeof notificationId === "string" && ENTITLEMENT_NOTIFICATIONS.has(notificationId)) return "entitlement_lapsed";
|
|
704
|
+
}
|
|
705
|
+
return "transient";
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Exchange the GitHub credential for a short-lived Copilot token.
|
|
709
|
+
*
|
|
710
|
+
* `credentialOverride` lets a caller try a candidate credential WITHOUT
|
|
711
|
+
* publishing it to the process-global `state` first. That matters because
|
|
712
|
+
* `state` is shared by every concurrent request: installing an unvalidated
|
|
713
|
+
* credential globally, even briefly, would make unrelated in-flight requests
|
|
714
|
+
* send it and 401 while a perfectly good credential was still in hand.
|
|
715
|
+
*/
|
|
716
|
+
const getCopilotToken = async (credentialOverride) => {
|
|
717
|
+
const credential = credentialFingerprint(credentialOverride ?? state.githubToken);
|
|
718
|
+
const authState = credentialOverride === void 0 ? state : {
|
|
719
|
+
...state,
|
|
720
|
+
githubToken: credentialOverride
|
|
721
|
+
};
|
|
722
|
+
const response = await fetchWithTransientRetry(() => fetch(`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, { headers: githubHeaders(authState) }), { label: "/copilot_internal/v2/token" });
|
|
723
|
+
if (!response.ok) {
|
|
724
|
+
let body;
|
|
725
|
+
try {
|
|
726
|
+
body = await response.text();
|
|
727
|
+
} catch {
|
|
728
|
+
body = "(could not read error body)";
|
|
729
|
+
}
|
|
730
|
+
throw new CopilotTokenExchangeError({
|
|
731
|
+
kind: classifyExchangeFailure(response.status, body),
|
|
732
|
+
status: response.status,
|
|
733
|
+
credential,
|
|
734
|
+
detail: body.slice(0, ERROR_BODY_SNIPPET_CHARS).replaceAll(/\s+/g, " ").trim(),
|
|
735
|
+
response: new Response(body, {
|
|
736
|
+
status: response.status,
|
|
737
|
+
statusText: response.statusText,
|
|
738
|
+
headers: response.headers
|
|
739
|
+
})
|
|
740
|
+
});
|
|
741
|
+
}
|
|
569
742
|
const data = await response.json();
|
|
570
743
|
if (data.endpoints?.api) if (isAllowedCopilotHost(data.endpoints.api)) state.copilotApiUrl = data.endpoints.api;
|
|
571
744
|
else consola.warn(`Refusing to honor Copilot API endpoint "${data.endpoints.api}" from the token-exchange response — not in allowlist (${COPILOT_HOST_ALLOWLIST.join(", ")}). ` + (state.copilotApiUrl ? `Keeping existing override "${state.copilotApiUrl}".` : `Falling back to the default api.githubcopilot.com.`));
|
|
@@ -1096,6 +1269,43 @@ async function pollAccessToken(deviceCode, clientId = GITHUB_CLIENT_ID) {
|
|
|
1096
1269
|
//#region src/lib/token.ts
|
|
1097
1270
|
const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8");
|
|
1098
1271
|
/**
|
|
1272
|
+
* Transient filesystem errors from a read that raced the temp+rename write.
|
|
1273
|
+
*
|
|
1274
|
+
* On Windows a read landing in that window fails with one of these even
|
|
1275
|
+
* though a perfectly good file exists microseconds later. Treating one as
|
|
1276
|
+
* fatal at startup would cost a launch for no reason.
|
|
1277
|
+
*/
|
|
1278
|
+
const TRANSIENT_READ_CODES = /* @__PURE__ */ new Set([
|
|
1279
|
+
"EPERM",
|
|
1280
|
+
"EBUSY",
|
|
1281
|
+
"EACCES",
|
|
1282
|
+
"ENOENT"
|
|
1283
|
+
]);
|
|
1284
|
+
function isTransientReadError(err) {
|
|
1285
|
+
const code = err?.code;
|
|
1286
|
+
return typeof code === "string" && TRANSIENT_READ_CODES.has(code);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Read the credential file, retrying briefly through a racing rename.
|
|
1290
|
+
*
|
|
1291
|
+
* Same delays and the same reasoning as {@link RENAME_RETRY_DELAYS_MS} on the
|
|
1292
|
+
* write side: the contending handle closes almost immediately, so a short
|
|
1293
|
+
* bounded retry converts a spurious launch failure into a non-event. A
|
|
1294
|
+
* persistent failure still propagates — the caller must not believe a
|
|
1295
|
+
* credential was read when it was not.
|
|
1296
|
+
*/
|
|
1297
|
+
async function readGithubTokenWithRetry() {
|
|
1298
|
+
let lastErr;
|
|
1299
|
+
for (let attempt = 0; attempt <= RENAME_RETRY_DELAYS_MS.length; attempt++) try {
|
|
1300
|
+
return await readGithubToken();
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
lastErr = err;
|
|
1303
|
+
if (!isTransientReadError(err)) throw err;
|
|
1304
|
+
if (attempt < RENAME_RETRY_DELAYS_MS.length) await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_DELAYS_MS[attempt]));
|
|
1305
|
+
}
|
|
1306
|
+
throw lastErr;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1099
1309
|
* Backoff for a transient Windows rename failure. On Windows `fs.rename` over
|
|
1100
1310
|
* an existing destination transiently fails with EPERM / EBUSY / EACCES when
|
|
1101
1311
|
* anything else holds the target open for a moment (antivirus, the search
|
|
@@ -1158,10 +1368,60 @@ const writeGithubToken = (token) => writeTokenFileAtomic(PATHS.GITHUB_TOKEN_PATH
|
|
|
1158
1368
|
const readGithubAgentToken = () => fs.readFile(PATHS.GITHUB_AGENT_TOKEN_PATH, "utf8");
|
|
1159
1369
|
const writeGithubAgentToken = (token) => writeTokenFileAtomic(PATHS.GITHUB_AGENT_TOKEN_PATH, token);
|
|
1160
1370
|
/**
|
|
1371
|
+
* Safety margin subtracted from the derived refresh deadline, so a request
|
|
1372
|
+
* arriving just before expiry refreshes rather than racing it.
|
|
1373
|
+
*/
|
|
1374
|
+
const REFRESH_SKEW_MS = 12e4;
|
|
1375
|
+
/**
|
|
1376
|
+
* Bounds on a `refresh_in` we are willing to turn into a timer delay. An
|
|
1377
|
+
* absurd or missing upstream value must not produce a zero-length timer (a
|
|
1378
|
+
* hot loop hammering GitHub) or an effectively infinite one (a token that is
|
|
1379
|
+
* never refreshed). 60s floor, 6h ceiling, 1500s default — the value GitHub
|
|
1380
|
+
* actually returns today.
|
|
1381
|
+
*/
|
|
1382
|
+
const MIN_REFRESH_IN_S = 60;
|
|
1383
|
+
const MAX_REFRESH_IN_S = 21600;
|
|
1384
|
+
const DEFAULT_REFRESH_IN_S = 1500;
|
|
1385
|
+
/** Backoff schedule after a failed refresh, then hold at the last value. */
|
|
1386
|
+
const REFRESH_BACKOFF_MS = [
|
|
1387
|
+
5e3,
|
|
1388
|
+
15e3,
|
|
1389
|
+
6e4,
|
|
1390
|
+
3e5
|
|
1391
|
+
];
|
|
1392
|
+
function clampRefreshIn(refreshIn) {
|
|
1393
|
+
if (typeof refreshIn !== "number" || !Number.isFinite(refreshIn)) return DEFAULT_REFRESH_IN_S;
|
|
1394
|
+
return Math.min(Math.max(refreshIn, MIN_REFRESH_IN_S), MAX_REFRESH_IN_S);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Adopt an exchange result into `state`.
|
|
1398
|
+
*
|
|
1399
|
+
* The generation bump is what lets an in-flight request know a newer token
|
|
1400
|
+
* exists (see `State.copilotTokenGeneration`), so it happens on EVERY success
|
|
1401
|
+
* — including one that returns a byte-identical token, which upstream
|
|
1402
|
+
* demonstrably does.
|
|
1403
|
+
*/
|
|
1404
|
+
/**
|
|
1405
|
+
* Floor on how far ahead the derived refresh deadline may be placed.
|
|
1406
|
+
*
|
|
1407
|
+
* The deadline is `now + refresh_in - skew`, and the skew (120s) is larger
|
|
1408
|
+
* than the `refresh_in` floor (60s) — so a short upstream `refresh_in` would
|
|
1409
|
+
* put the deadline in the PAST, the scheduler would floor its delay to 1s,
|
|
1410
|
+
* and the proxy would hammer the token endpoint at 1 Hz forever. The deadline
|
|
1411
|
+
* must always be in the future, whatever upstream says.
|
|
1412
|
+
*/
|
|
1413
|
+
const MIN_REFRESH_LEAD_MS = 3e4;
|
|
1414
|
+
function commitCopilotToken(token, refreshIn) {
|
|
1415
|
+
state.copilotToken = token;
|
|
1416
|
+
state.copilotTokenGeneration += 1;
|
|
1417
|
+
const lead = Math.max(clampRefreshIn(refreshIn) * 1e3 - REFRESH_SKEW_MS, MIN_REFRESH_LEAD_MS);
|
|
1418
|
+
state.copilotTokenRefreshAt = Date.now() + lead;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1161
1421
|
* Fetch the Copilot token and keep it fresh in the background.
|
|
1162
1422
|
*
|
|
1163
1423
|
* Returns a disposer that stops the refresh loop. **Long-lived callers**
|
|
1164
|
-
* (`start`/`claude`/`codex`, via `setupAndServe`) can ignore it — the
|
|
1424
|
+
* (`start`/`claude`/`codex`, via `setupAndServe`) can ignore it — the timer
|
|
1165
1425
|
* is `unref()`d, so it never holds the event loop open on its own. The one-shot
|
|
1166
1426
|
* `models` command calls it, so ownership of the timer is explicit rather than
|
|
1167
1427
|
* implied by a runtime flag. (`check-usage` does not call this function at all;
|
|
@@ -1170,95 +1430,222 @@ const writeGithubAgentToken = (token) => writeTokenFileAtomic(PATHS.GITHUB_AGENT
|
|
|
1170
1430
|
* Both halves are load-bearing, for different failure modes. Without the
|
|
1171
1431
|
* `unref()`, `github-router models` printed its full correct output and then
|
|
1172
1432
|
* hung forever: its success path just returns (only the failure branches call
|
|
1173
|
-
* `process.exit`), and the un-unref'd
|
|
1433
|
+
* `process.exit`), and the un-unref'd timer pinned the event loop. Without
|
|
1174
1434
|
* the disposer, that fix would depend on a property no caller can see, so the
|
|
1175
1435
|
* next one-shot command would inherit the same trap.
|
|
1436
|
+
*
|
|
1437
|
+
* The schedule is a SELF-RE-ARMING `setTimeout`, not a fixed `setInterval`.
|
|
1438
|
+
* Three reasons: the delay tracks each response's own `refresh_in` instead of
|
|
1439
|
+
* the one captured at startup; a failure can back off (5s → 15s → 60s → 300s)
|
|
1440
|
+
* instead of waiting a full period during which the token is already dead;
|
|
1441
|
+
* and one timer cannot disagree with itself the way a period timer plus a
|
|
1442
|
+
* separate retry timer can.
|
|
1176
1443
|
*/
|
|
1177
1444
|
const setupCopilotToken = async () => {
|
|
1178
1445
|
const { token, refresh_in } = await getCopilotToken();
|
|
1179
|
-
|
|
1180
|
-
consola.debug(
|
|
1446
|
+
commitCopilotToken(token, refresh_in);
|
|
1447
|
+
consola.debug(`GitHub Copilot Token fetched successfully! (credential ${credentialFingerprint(state.githubToken)})`);
|
|
1181
1448
|
if (state.showToken) consola.info("Copilot token:", token);
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1449
|
+
let handle;
|
|
1450
|
+
let disposed = false;
|
|
1451
|
+
let failures = 0;
|
|
1452
|
+
const arm = (delayMs) => {
|
|
1453
|
+
if (disposed) return;
|
|
1454
|
+
handle = setTimeout(() => {
|
|
1455
|
+
tick();
|
|
1456
|
+
}, delayMs);
|
|
1457
|
+
handle.unref?.();
|
|
1458
|
+
};
|
|
1459
|
+
const tick = async () => {
|
|
1460
|
+
const outcome = await refreshCopilotToken("interval");
|
|
1461
|
+
if (disposed) return;
|
|
1462
|
+
if (outcome === "refreshed") {
|
|
1463
|
+
failures = 0;
|
|
1464
|
+
arm(nextDelayFromState());
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const delay = REFRESH_BACKOFF_MS[Math.min(failures, REFRESH_BACKOFF_MS.length - 1)];
|
|
1468
|
+
failures += 1;
|
|
1469
|
+
arm(delay);
|
|
1470
|
+
};
|
|
1471
|
+
arm(nextDelayFromState());
|
|
1188
1472
|
return () => {
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
1191
|
-
|
|
1473
|
+
if (disposed) return;
|
|
1474
|
+
disposed = true;
|
|
1475
|
+
if (handle) clearTimeout(handle);
|
|
1192
1476
|
};
|
|
1193
1477
|
};
|
|
1478
|
+
/**
|
|
1479
|
+
* Delay until the next scheduled refresh, from the deadline already stored on
|
|
1480
|
+
* `state`. Floored at 1s so a pathological deadline can never busy-loop.
|
|
1481
|
+
*/
|
|
1482
|
+
function nextDelayFromState() {
|
|
1483
|
+
const deadline = state.copilotTokenRefreshAt;
|
|
1484
|
+
if (deadline === void 0) return DEFAULT_REFRESH_IN_S * 1e3;
|
|
1485
|
+
return Math.max(deadline - Date.now(), 1e3);
|
|
1486
|
+
}
|
|
1194
1487
|
let inflightRefresh;
|
|
1195
1488
|
let lastRefreshSuccess = 0;
|
|
1196
1489
|
let lastRefreshFailure = 0;
|
|
1197
1490
|
const REFRESH_SUCCESS_COOLDOWN_MS = 3e4;
|
|
1198
1491
|
const REFRESH_FAILURE_COOLDOWN_MS = 5e3;
|
|
1492
|
+
/**
|
|
1493
|
+
* Read the credential from disk, returning it only if it is usable.
|
|
1494
|
+
*
|
|
1495
|
+
* Every failure mode here resolves to `undefined` (meaning "keep using what
|
|
1496
|
+
* is in memory") rather than throwing or returning a partial value. The file
|
|
1497
|
+
* is replaced by temp+rename (`writeTokenFileAtomic`), and on Windows a read
|
|
1498
|
+
* racing that rename fails with EPERM/EBUSY/EACCES/ENOENT. None of those mean
|
|
1499
|
+
* the in-memory credential went bad, so none of them may clear it — and a
|
|
1500
|
+
* filesystem error must never escape into the refresh loop and kill the timer.
|
|
1501
|
+
*/
|
|
1502
|
+
async function readGithubTokenIfUsable() {
|
|
1503
|
+
if (state.githubTokenSource === "explicit") return void 0;
|
|
1504
|
+
try {
|
|
1505
|
+
const trimmed = (await readGithubToken()).trim();
|
|
1506
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1507
|
+
} catch (err) {
|
|
1508
|
+
consola.debug("Could not re-read GitHub credential from disk:", err);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Perform one exchange, preferring a newer credential from disk if there is one.
|
|
1514
|
+
*
|
|
1515
|
+
* The disk token is a CANDIDATE: it is passed to the exchange directly and
|
|
1516
|
+
* published to `state.githubToken` only after that exchange succeeds. It is
|
|
1517
|
+
* never installed globally on spec — `state` is shared by every concurrent
|
|
1518
|
+
* request, so an unvalidated credential visible there would be sent by
|
|
1519
|
+
* unrelated in-flight requests and 401 them while a working credential was
|
|
1520
|
+
* still in hand.
|
|
1521
|
+
*/
|
|
1522
|
+
async function exchangeWithFreshestCredential() {
|
|
1523
|
+
const onDisk = await readGithubTokenIfUsable();
|
|
1524
|
+
const inMemory = state.githubToken;
|
|
1525
|
+
if (onDisk && onDisk !== inMemory) {
|
|
1526
|
+
consola.info(`GitHub credential on disk differs from the one in memory (${credentialFingerprint(inMemory)} → ${credentialFingerprint(onDisk)}); trying it.`);
|
|
1527
|
+
const result = await getCopilotToken(onDisk);
|
|
1528
|
+
state.githubToken = onDisk;
|
|
1529
|
+
state.githubTokenSource = "file";
|
|
1530
|
+
return result;
|
|
1531
|
+
}
|
|
1532
|
+
return getCopilotToken();
|
|
1533
|
+
}
|
|
1199
1534
|
async function refreshCopilotToken(reason) {
|
|
1200
1535
|
if (inflightRefresh) return inflightRefresh;
|
|
1201
|
-
|
|
1202
|
-
const now = Date.now();
|
|
1203
|
-
if (now - lastRefreshSuccess < REFRESH_SUCCESS_COOLDOWN_MS) {
|
|
1204
|
-
consola.debug(`refreshCopilotToken(${reason}) skipped: prior success within ${REFRESH_SUCCESS_COOLDOWN_MS}ms`);
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
if (now - lastRefreshFailure < REFRESH_FAILURE_COOLDOWN_MS) {
|
|
1208
|
-
consola.debug(`refreshCopilotToken(${reason}) skipped: prior failure within ${REFRESH_FAILURE_COOLDOWN_MS}ms`);
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
inflightRefresh = (async () => {
|
|
1213
|
-
consola.debug(`Refreshing Copilot token (reason=${reason})`);
|
|
1536
|
+
const run = async () => {
|
|
1214
1537
|
try {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1538
|
+
if (reason === "401-retry" || reason === "expiry") {
|
|
1539
|
+
const now = Date.now();
|
|
1540
|
+
const onDisk = await readGithubTokenIfUsable();
|
|
1541
|
+
if (!Boolean(onDisk && onDisk !== state.githubToken)) {
|
|
1542
|
+
if (now - lastRefreshSuccess < REFRESH_SUCCESS_COOLDOWN_MS) {
|
|
1543
|
+
consola.debug(`refreshCopilotToken(${reason}) skipped: prior success within ${REFRESH_SUCCESS_COOLDOWN_MS}ms`);
|
|
1544
|
+
return "skipped";
|
|
1545
|
+
}
|
|
1546
|
+
if (now - lastRefreshFailure < REFRESH_FAILURE_COOLDOWN_MS) {
|
|
1547
|
+
consola.debug(`refreshCopilotToken(${reason}) skipped: prior failure within ${REFRESH_FAILURE_COOLDOWN_MS}ms`);
|
|
1548
|
+
return "skipped";
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
consola.debug(`Refreshing Copilot token (reason=${reason})`);
|
|
1553
|
+
try {
|
|
1554
|
+
const { token, refresh_in } = await exchangeWithFreshestCredential();
|
|
1555
|
+
commitCopilotToken(token, refresh_in);
|
|
1556
|
+
lastRefreshSuccess = Date.now();
|
|
1557
|
+
consola.debug(`Copilot token refreshed (credential ${credentialFingerprint(state.githubToken)}, generation ${state.copilotTokenGeneration})`);
|
|
1558
|
+
if (state.showToken) consola.info("Refreshed Copilot token:", token);
|
|
1559
|
+
return "refreshed";
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
lastRefreshFailure = Date.now();
|
|
1562
|
+
consola.error(`Failed to refresh Copilot token (reason=${reason}):`, error instanceof Error ? error.message : error);
|
|
1563
|
+
if (error instanceof CopilotTokenExchangeError) {
|
|
1564
|
+
if (error.kind === "credential_rejected") return "credential_rejected";
|
|
1565
|
+
if (error.kind === "entitlement_lapsed") return "entitlement_lapsed";
|
|
1566
|
+
}
|
|
1567
|
+
return "transient_failure";
|
|
1568
|
+
}
|
|
1223
1569
|
} finally {
|
|
1224
|
-
inflightRefresh = void 0;
|
|
1570
|
+
if (inflightRefresh === attempt) inflightRefresh = void 0;
|
|
1225
1571
|
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1572
|
+
};
|
|
1573
|
+
const attempt = run();
|
|
1574
|
+
inflightRefresh = attempt;
|
|
1575
|
+
return attempt;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Refresh the Copilot token if its derived deadline has passed.
|
|
1579
|
+
*
|
|
1580
|
+
* Called on EVERY request. The common path is one `Date.now()` and one
|
|
1581
|
+
* integer compare — no I/O — against an upstream call that costs hundreds of
|
|
1582
|
+
* milliseconds, so the overhead is not measurable. Sampling (every Nth
|
|
1583
|
+
* request) was considered and rejected: a session that idles and then resumes
|
|
1584
|
+
* may issue only a handful of requests, so a sampled guard would fail to fire
|
|
1585
|
+
* in precisely the scenario it exists for. Actual refreshes do not get more
|
|
1586
|
+
* frequent, because `refreshCopilotToken` is single-flighted and the deadline
|
|
1587
|
+
* moves forward on success.
|
|
1588
|
+
*/
|
|
1589
|
+
async function ensureFreshCopilotToken() {
|
|
1590
|
+
const deadline = state.copilotTokenRefreshAt;
|
|
1591
|
+
if (deadline === void 0 || Date.now() < deadline) return;
|
|
1592
|
+
await refreshCopilotToken("expiry");
|
|
1228
1593
|
}
|
|
1229
1594
|
/**
|
|
1230
1595
|
* Try `request()`. If it returns a 401, refresh the Copilot token (subject
|
|
1231
1596
|
* to the single-flight + refresh-storm-protection of `refreshCopilotToken`)
|
|
1232
|
-
* and retry once
|
|
1233
|
-
*
|
|
1597
|
+
* and retry once — but ONLY if the token actually moved on.
|
|
1598
|
+
*
|
|
1599
|
+
* The retry criterion is the generation counter, not the refresh's verdict
|
|
1600
|
+
* and not a token-string comparison. Both alternatives are wrong:
|
|
1601
|
+
*
|
|
1602
|
+
* - By verdict: requests A and B both hold token T0 and both 401. A
|
|
1603
|
+
* refreshes to T1. B's refresh call lands inside the 30s success cooldown
|
|
1604
|
+
* and reports "skipped", so a verdict-based rule would give up and fail B
|
|
1605
|
+
* with a 503 — even though T1 is sitting in state and would have worked.
|
|
1606
|
+
* - By string: upstream demonstrably returns a byte-identical token from
|
|
1607
|
+
* two consecutive successful exchanges, so an unchanged string does not
|
|
1608
|
+
* mean an unchanged credential state.
|
|
1609
|
+
*
|
|
1610
|
+
* Comparing generations answers the question the request actually has: is
|
|
1611
|
+
* what is in state now different from what I already tried? When it is not,
|
|
1612
|
+
* we skip a re-send that is guaranteed to 401 again.
|
|
1234
1613
|
*
|
|
1235
1614
|
* The `request` callback is responsible for capturing `state.copilotToken`
|
|
1236
1615
|
* locally before any await; this helper does NOT re-build the request
|
|
1237
1616
|
* itself, just re-invokes the callback after a refresh.
|
|
1238
1617
|
*/
|
|
1239
1618
|
async function tryRefreshAndRetry(request, routePath) {
|
|
1619
|
+
await ensureFreshCopilotToken();
|
|
1620
|
+
const generationBefore = state.copilotTokenGeneration;
|
|
1240
1621
|
const first = await request();
|
|
1241
1622
|
if (first.status !== 401) return first;
|
|
1242
1623
|
consola.warn(`${routePath}: upstream returned 401, attempting one token refresh + retry`);
|
|
1243
1624
|
await refreshCopilotToken("401-retry");
|
|
1625
|
+
if (state.copilotTokenGeneration === generationBefore) {
|
|
1626
|
+
consola.debug(`${routePath}: no newer Copilot token available (generation ${generationBefore}); not retrying`);
|
|
1627
|
+
return first;
|
|
1628
|
+
}
|
|
1244
1629
|
return request();
|
|
1245
1630
|
}
|
|
1246
1631
|
async function setupGitHubToken(options) {
|
|
1247
1632
|
try {
|
|
1248
|
-
const githubToken = await
|
|
1633
|
+
const githubToken = await readGithubTokenWithRetry();
|
|
1249
1634
|
if (githubToken && !options?.force) {
|
|
1250
1635
|
state.githubToken = githubToken;
|
|
1636
|
+
state.githubTokenSource = "file";
|
|
1251
1637
|
if (state.showToken) consola.info("GitHub token:", githubToken);
|
|
1252
1638
|
await logUser();
|
|
1253
1639
|
return;
|
|
1254
1640
|
}
|
|
1255
|
-
consola.info("Not logged in, getting new access token");
|
|
1641
|
+
consola.info(options?.force ? "Re-authenticating, getting new access token" : "Not logged in, getting new access token");
|
|
1256
1642
|
const response = await getDeviceCode();
|
|
1257
1643
|
consola.debug("Device code response:", response);
|
|
1258
1644
|
consola.info(`Please enter the code "${response.user_code}" in ${response.verification_uri}`);
|
|
1259
1645
|
const token = await pollAccessToken(response);
|
|
1260
1646
|
await writeGithubToken(token);
|
|
1261
1647
|
state.githubToken = token;
|
|
1648
|
+
state.githubTokenSource = "file";
|
|
1262
1649
|
if (state.showToken) consola.info("GitHub token:", token);
|
|
1263
1650
|
await logUser();
|
|
1264
1651
|
} catch (error) {
|
|
@@ -1342,4 +1729,4 @@ async function warnIfAgentScopesInsufficient() {
|
|
|
1342
1729
|
//#endregion
|
|
1343
1730
|
export { state as A, GITHUB_GRAPHQL_URL as C, githubAgentGraphQLHeaders as D, copilotVersion as E, githubAgentHeaders as O, GITHUB_API_BASE_URL as S, copilotHeaders as T, forwardError as _, cacheCopilotVersion as a, sanitizeTransportText as b, filterBetaHeader as c, resolveModel as d, sleep as f, HTTPError as g, isAllowedCopilotHost as h, tryRefreshAndRetry as i, githubHeaders as k, isNullish as l, getGitHubUser as m, setupGitHubAgentToken as n, cacheModels as o, getModels as p, setupGitHubToken as r, cacheVSCodeVersion as s, setupCopilotToken as t, resolveCodexModel as u, classifyTransportError as v, copilotBaseUrl as w, withTransientRetry as x, fetchWithTransientRetry as y };
|
|
1344
1731
|
|
|
1345
|
-
//# sourceMappingURL=token-
|
|
1732
|
+
//# sourceMappingURL=token-Bz6HoH2H.js.map
|