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.
Files changed (38) hide show
  1. package/dist/{attribution-settings-DLRQEKpU.js → attribution-settings-Cqh_A9yx.js} +3 -3
  2. package/dist/{attribution-settings-DLRQEKpU.js.map → attribution-settings-Cqh_A9yx.js.map} +1 -1
  3. package/dist/{auth-DIFbHOu0.js → auth-Q1tfmfCT.js} +2 -2
  4. package/dist/{auth-DIFbHOu0.js.map → auth-Q1tfmfCT.js.map} +1 -1
  5. package/dist/browser-ext/manifest.json +1 -1
  6. package/dist/{check-usage-DfnrR-d_.js → check-usage-CHLz4tU_.js} +3 -3
  7. package/dist/{check-usage-DfnrR-d_.js.map → check-usage-CHLz4tU_.js.map} +1 -1
  8. package/dist/{claude-BpQVQE0T.js → claude-CqFkkQGo.js} +7 -7
  9. package/dist/{claude-BpQVQE0T.js.map → claude-CqFkkQGo.js.map} +1 -1
  10. package/dist/{codex-CGMiGJy1.js → codex-gGkDJHZJ.js} +5 -5
  11. package/dist/{codex-CGMiGJy1.js.map → codex-gGkDJHZJ.js.map} +1 -1
  12. package/dist/engine-BM2mIXsc.js +2 -0
  13. package/dist/{gate-discovery-BBA7cV1D.js → gate-discovery-e114xla0.js} +2 -2
  14. package/dist/{gate-discovery-BBA7cV1D.js.map → gate-discovery-e114xla0.js.map} +1 -1
  15. package/dist/{get-copilot-usage-Ds09xSmH.js → get-copilot-usage-BRN18uMJ.js} +2 -2
  16. package/dist/{get-copilot-usage-Ds09xSmH.js.map → get-copilot-usage-BRN18uMJ.js.map} +1 -1
  17. package/dist/{internal-stop-hook-CO2NDmGL.js → internal-stop-hook-BLPxEImO.js} +2 -2
  18. package/dist/{internal-stop-hook-CO2NDmGL.js.map → internal-stop-hook-BLPxEImO.js.map} +1 -1
  19. package/dist/main.js +8 -8
  20. package/dist/{models-BFQUp0ov.js → models-d0gkuYGy.js} +2 -2
  21. package/dist/{models-BFQUp0ov.js.map → models-d0gkuYGy.js.map} +1 -1
  22. package/dist/{peer-mcp-personas-CN8xaXcV.js → peer-mcp-personas-pzk1zcDa.js} +111 -8
  23. package/dist/peer-mcp-personas-pzk1zcDa.js.map +1 -0
  24. package/dist/{provision-CiPjSx7J.js → provision-BIUvGrMS.js} +2 -2
  25. package/dist/{provision-CiPjSx7J.js.map → provision-BIUvGrMS.js.map} +1 -1
  26. package/dist/{serve-CpTMr5D9.js → serve-BYbEyMoF.js} +6 -6
  27. package/dist/{serve-CpTMr5D9.js.map → serve-BYbEyMoF.js.map} +1 -1
  28. package/dist/{server-setup-B02Myghc.js → server-setup-CNKsKERG.js} +566 -8
  29. package/dist/server-setup-CNKsKERG.js.map +1 -0
  30. package/dist/{start-CIV9eXVb.js → start-Cy_fEtEw.js} +3 -3
  31. package/dist/{start-CIV9eXVb.js.map → start-Cy_fEtEw.js.map} +1 -1
  32. package/dist/{token-iaSGp76k.js → token-Bz6HoH2H.js} +438 -51
  33. package/dist/token-Bz6HoH2H.js.map +1 -0
  34. package/package.json +2 -1
  35. package/dist/engine-DUJyXWZn.js +0 -2
  36. package/dist/peer-mcp-personas-CN8xaXcV.js.map +0 -1
  37. package/dist/server-setup-B02Myghc.js.map +0 -1
  38. 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 (codes.some((value) => DETERMINISTIC_CODES.has(value)) || messages.includes("self signed certificate") || messages.includes("certificate has expired") || messages.includes("unable to verify the first certificate") || messages.includes("unable to get local issuer certificate") || messages.includes("hostname/ip does not match certificate") || messages.includes("tls handshake") || messages.includes("ssl routines") || messages.includes("econnrefused") || messages.includes("enotfound")) return {
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: "overloaded_error",
414
- message: `Upstream transport was interrupted at ${target} after ${error.attempts} attempts; retry later. ${diagnostic}`
482
+ type: "api_error",
483
+ message: `Upstream transport was interrupted at ${target} after ${error.attempts} attempts. ${diagnostic}`
415
484
  }
416
- }, 503);
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
- const getCopilotToken = async () => {
567
- const response = await fetchWithTransientRetry(() => fetch(`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, { headers: githubHeaders(state) }), { label: "/copilot_internal/v2/token" });
568
- if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
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 interval
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 interval pinned the event loop. Without
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
- state.copilotToken = token;
1180
- consola.debug("GitHub Copilot Token fetched successfully!");
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
- const refreshInterval = Math.max((refresh_in - 60) * 1e3, 1e3);
1183
- const handle = setInterval(() => {
1184
- refreshCopilotToken("interval");
1185
- }, refreshInterval);
1186
- handle.unref?.();
1187
- let stopped = false;
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 (stopped) return;
1190
- stopped = true;
1191
- clearInterval(handle);
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
- if (reason === "401-retry") {
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
- const { token } = await getCopilotToken();
1216
- state.copilotToken = token;
1217
- lastRefreshSuccess = Date.now();
1218
- consola.debug("Copilot token refreshed");
1219
- if (state.showToken) consola.info("Refreshed Copilot token:", token);
1220
- } catch (error) {
1221
- lastRefreshFailure = Date.now();
1222
- consola.error(`Failed to refresh Copilot token (reason=${reason}):`, error);
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
- return inflightRefresh;
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. After one retry, propagate whatever the second attempt
1233
- * returned — the caller's existing 401-handling path is preserved.
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 readGithubToken();
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-iaSGp76k.js.map
1732
+ //# sourceMappingURL=token-Bz6HoH2H.js.map