codex-copilot-dx 0.6.15 → 0.6.19

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/src/auth.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn } from "node:child_process";
5
+ import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
5
6
  import {
6
7
  githubIdentityMatchesExpected,
7
8
  githubTokenFingerprint,
@@ -15,6 +16,11 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Public GitHub Copilot client ID.
15
16
  const SCOPE = "read:user";
16
17
  const GITHUB_API = "https://api.github.com";
17
18
  const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
19
+ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
20
+ const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
21
+ const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
22
+ const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 30 * 1000;
23
+ const MAX_DEVICE_FLOW_JSON_BYTES = 64 * 1024;
18
24
  const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
19
25
  const MAX_AUTH_JSON_BYTES = 1024 * 1024;
20
26
 
@@ -48,7 +54,196 @@ export function interpretPoll(data) {
48
54
  }
49
55
  }
50
56
 
51
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
57
+ function abortError(signal) {
58
+ if (signal?.reason instanceof Error) return signal.reason;
59
+ const error = new Error("The operation was aborted");
60
+ error.name = "AbortError";
61
+ return error;
62
+ }
63
+
64
+ function sleep(ms, { signal } = {}) {
65
+ if (signal?.aborted) return Promise.reject(abortError(signal));
66
+ return new Promise((resolve, reject) => {
67
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
68
+ const onAbort = () => {
69
+ clearTimeout(timer);
70
+ cleanup();
71
+ reject(abortError(signal));
72
+ };
73
+ const timer = setTimeout(() => {
74
+ cleanup();
75
+ resolve();
76
+ }, ms);
77
+ signal?.addEventListener("abort", onAbort, { once: true });
78
+ });
79
+ }
80
+
81
+ function awaitWithSignal(operation, signal) {
82
+ if (signal?.aborted) return Promise.reject(abortError(signal));
83
+ return new Promise((resolve, reject) => {
84
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
85
+ const finish = (callback, value) => {
86
+ cleanup();
87
+ callback(value);
88
+ };
89
+ const onAbort = () => finish(reject, abortError(signal));
90
+ signal?.addEventListener("abort", onAbort, { once: true });
91
+ Promise.resolve()
92
+ .then(operation)
93
+ .then((value) => finish(resolve, value), (error) => finish(reject, error));
94
+ });
95
+ }
96
+
97
+ async function discardBoundedResponseBody(response) {
98
+ const transport = await import("./http-transport.mjs");
99
+ return transport.discardBoundedResponseBody(response);
100
+ }
101
+
102
+ async function readDeviceFlowJson(response, signal, label) {
103
+ if (typeof response?.body?.getReader === "function") {
104
+ const transport = await import("./http-transport.mjs");
105
+ const text = await awaitWithSignal(() => transport.readBoundedResponseText(response, {
106
+ maxBytes: MAX_DEVICE_FLOW_JSON_BYTES,
107
+ label,
108
+ }), signal);
109
+ return JSON.parse(text);
110
+ }
111
+ return awaitWithSignal(() => response.json(), signal);
112
+ }
113
+
114
+ function cancelResponseBody(response) {
115
+ try { Promise.resolve(response?.body?.cancel?.()).catch(() => {}); } catch {}
116
+ }
117
+
118
+ function deviceCodeTimeoutError(timeoutMs) {
119
+ const error = new Error(`Device code request timed out after ${timeoutMs}ms`);
120
+ error.code = "CCDX_DEVICE_CODE_TIMEOUT";
121
+ return error;
122
+ }
123
+
124
+ export async function requestGithubDeviceCode({
125
+ fetchImpl = fetch,
126
+ signal,
127
+ timeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
128
+ } = {}) {
129
+ const parsedTimeout = Number(timeoutMs);
130
+ const deadlineMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0
131
+ ? parsedTimeout
132
+ : DEFAULT_DEVICE_CODE_TIMEOUT_MS;
133
+ const controller = new AbortController();
134
+ let response;
135
+ const onCallerAbort = () => controller.abort(signal.reason);
136
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
137
+ if (signal?.aborted) onCallerAbort();
138
+ const timer = setTimeout(() => controller.abort(deviceCodeTimeoutError(deadlineMs)), deadlineMs);
139
+ try {
140
+ response = await awaitWithSignal(() => fetchImpl("https://github.com/login/device/code", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
143
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
144
+ signal: controller.signal,
145
+ }), controller.signal);
146
+ if (!response.ok) {
147
+ await awaitWithSignal(() => discardBoundedResponseBody(response), controller.signal);
148
+ return { response, data: null, jsonError: null };
149
+ }
150
+ try {
151
+ const data = await readDeviceFlowJson(response, controller.signal, "Device code response");
152
+ return { response, data, jsonError: null };
153
+ } catch (error) {
154
+ if (controller.signal.aborted) throw error;
155
+ return { response, data: null, jsonError: error };
156
+ }
157
+ } catch (error) {
158
+ cancelResponseBody(response);
159
+ if (signal?.aborted) throw abortError(signal);
160
+ if (controller.signal.aborted) throw abortError(controller.signal);
161
+ throw error;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ signal?.removeEventListener("abort", onCallerAbort);
165
+ }
166
+ }
167
+
168
+ export async function pollGithubDeviceFlow({
169
+ deviceCode,
170
+ interval,
171
+ expiresIn,
172
+ fetchImpl = fetch,
173
+ signal,
174
+ sleepImpl = sleep,
175
+ now = Date.now,
176
+ } = {}) {
177
+ const parsedInterval = Number(interval);
178
+ const parsedExpires = Number(expiresIn);
179
+ let waitMs = (Number.isFinite(parsedInterval) && parsedInterval > 0
180
+ ? parsedInterval
181
+ : DEFAULT_DEVICE_INTERVAL_SECONDS) * 1000;
182
+ const expiresMs = (Number.isFinite(parsedExpires) && parsedExpires > 0
183
+ ? parsedExpires
184
+ : DEFAULT_DEVICE_EXPIRES_SECONDS) * 1000;
185
+ const deadline = now() + expiresMs;
186
+
187
+ while (true) {
188
+ const remainingMs = deadline - now();
189
+ if (remainingMs <= 0) return { state: "expired" };
190
+ await sleepImpl(Math.min(waitMs, remainingMs), { signal });
191
+ if (signal?.aborted) throw abortError(signal);
192
+ if (now() >= deadline) return { state: "expired" };
193
+
194
+ const pollController = new AbortController();
195
+ let deadlineExpired = false;
196
+ const onCallerAbort = () => pollController.abort(signal.reason);
197
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
198
+ if (signal?.aborted) onCallerAbort();
199
+ const deadlineTimer = setTimeout(() => {
200
+ deadlineExpired = true;
201
+ pollController.abort();
202
+ }, Math.max(1, deadline - now()));
203
+ let result;
204
+ let pollResponse;
205
+ try {
206
+ pollResponse = await fetchImpl(ACCESS_TOKEN_URL, {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
209
+ body: JSON.stringify({
210
+ client_id: CLIENT_ID,
211
+ device_code: deviceCode,
212
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
213
+ }),
214
+ signal: pollController.signal,
215
+ });
216
+ if (now() >= deadline) {
217
+ await awaitWithSignal(() => discardBoundedResponseBody(pollResponse), pollController.signal);
218
+ return { state: "expired" };
219
+ }
220
+ if (!pollResponse.ok) {
221
+ await awaitWithSignal(() => discardBoundedResponseBody(pollResponse), pollController.signal);
222
+ if (now() >= deadline) return { state: "expired" };
223
+ continue;
224
+ }
225
+ result = interpretPoll(await readDeviceFlowJson(
226
+ pollResponse,
227
+ pollController.signal,
228
+ "Device Flow poll response",
229
+ ));
230
+ if (now() >= deadline) return { state: "expired" };
231
+ } catch (error) {
232
+ cancelResponseBody(pollResponse);
233
+ if (signal?.aborted) throw abortError(signal);
234
+ if (deadlineExpired || now() >= deadline) return { state: "expired" };
235
+ throw error;
236
+ } finally {
237
+ clearTimeout(deadlineTimer);
238
+ signal?.removeEventListener("abort", onCallerAbort);
239
+ }
240
+ if (result.state === "slow") {
241
+ waitMs += 5000;
242
+ continue;
243
+ }
244
+ if (result.state !== "wait") return result;
245
+ }
246
+ }
52
247
 
53
248
  function expandHome(filePath, home = os.homedir()) {
54
249
  if (!filePath) return "";
@@ -465,6 +660,9 @@ export async function ensureAuth({
465
660
  signal,
466
661
  log = console.log,
467
662
  openAndCopyFn = openAndCopy,
663
+ deviceCodeTimeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
664
+ sleepImpl = sleep,
665
+ now = Date.now,
468
666
  } = {}) {
469
667
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
470
668
  if (fs.existsSync(GITHUB_TOKEN_PATH)) {
@@ -490,74 +688,89 @@ export async function ensureAuth({
490
688
 
491
689
  // Request a device code while holding the auth lock so concurrent starts do not
492
690
  // trigger multiple browser/device-flow sessions for the same local token.
493
- const codeResp = await fetchImpl("https://github.com/login/device/code", {
494
- method: "POST",
495
- headers: { "Content-Type": "application/json", Accept: "application/json" },
496
- body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
691
+ const codeResult = await requestGithubDeviceCode({
692
+ fetchImpl,
497
693
  signal,
694
+ timeoutMs: deviceCodeTimeoutMs,
498
695
  });
499
- if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
500
- const { device_code, user_code, verification_uri, interval } = await codeResp.json();
696
+ const codeResp = codeResult.response;
697
+ if (!codeResp.ok) {
698
+ throw new Error(`device code request failed: ${codeResp.status}`);
699
+ }
700
+ if (codeResult.jsonError) throw codeResult.jsonError;
701
+ const {
702
+ device_code,
703
+ user_code,
704
+ verification_uri,
705
+ interval,
706
+ expires_in,
707
+ } = codeResult.data;
501
708
 
502
709
  // Prompt the user.
503
710
  log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
504
711
  openAndCopyFn(user_code, verification_uri);
505
712
 
506
- // Poll until GitHub completes the device flow.
507
- let waitMs = (interval || 5) * 1000;
508
- while (true) {
509
- await sleep(waitMs);
510
- const pollResp = await fetchImpl("https://github.com/login/oauth/access_token", {
511
- method: "POST",
512
- headers: { "Content-Type": "application/json", Accept: "application/json" },
513
- body: JSON.stringify({
514
- client_id: CLIENT_ID,
515
- device_code,
516
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
517
- }),
518
- signal,
519
- });
520
- if (!pollResp.ok) {
521
- // Treat transient network/server errors as pending and retry.
522
- continue;
523
- }
524
- const data = await pollResp.json();
525
- const r = interpretPoll(data);
526
- if (r.state === "done") {
527
- const identity = await fetchGithubIdentity(r.token, { fetchImpl, signal });
528
- writeToken(r.token, home, identity.ok ? identity : null);
529
- log(status("ok", "Login successful"));
530
- return;
531
- }
532
- if (r.state === "slow") { waitMs += 5000; continue; }
533
- if (r.state === "fail") throw new Error(`Login failed: ${r.error}`);
534
- // wait: continue polling.
713
+ const result = await pollGithubDeviceFlow({
714
+ deviceCode: device_code,
715
+ interval,
716
+ expiresIn: expires_in,
717
+ fetchImpl,
718
+ signal,
719
+ sleepImpl,
720
+ now,
721
+ });
722
+ if (result.state === "done") {
723
+ const identity = await fetchGithubIdentity(result.token, { fetchImpl, signal });
724
+ writeToken(result.token, home, identity.ok ? identity : null);
725
+ log(status("ok", "Login successful"));
726
+ return;
535
727
  }
728
+ if (result.state === "expired") throw new Error("Login failed: device code expired");
729
+ throw new Error(`Login failed: ${result.error}`);
536
730
  }, tokenLockOptions(env));
537
731
  }
538
732
 
539
- export function writeGithubTokenMetadata(identity, home = os.homedir(), token = "") {
733
+ function githubTokenMetadataData(identity, token, now = () => new Date()) {
540
734
  const normalized = normalizeGithubIdentity(identity);
541
- if (!normalized) return false;
542
- const filePath = githubTokenMetadataPath(home);
543
- fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
544
- fs.writeFileSync(filePath, `${JSON.stringify({
735
+ if (!normalized) return null;
736
+ const timestamp = now();
737
+ const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
738
+ return `${JSON.stringify({
545
739
  ...normalized,
546
740
  token_fingerprint: githubTokenFingerprint(token),
547
- updated_at: new Date().toISOString(),
548
- }, null, 2)}\n`, { mode: 0o600 });
741
+ updated_at: updatedAt,
742
+ }, null, 2)}\n`;
743
+ }
744
+
745
+ export function writeGithubTokenMetadata(identity, home = os.homedir(), token = "", {
746
+ now = () => new Date(),
747
+ writeFile = atomicWriteFileSync,
748
+ } = {}) {
749
+ const metadata = githubTokenMetadataData(identity, token, now);
750
+ if (metadata === null) return false;
751
+ const filePath = githubTokenMetadataPath(home);
752
+ const directory = path.dirname(filePath);
753
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
754
+ fs.chmodSync(directory, 0o700);
755
+ writeFile(filePath, metadata, { mode: 0o600, preserveMode: false });
549
756
  fs.chmodSync(filePath, 0o600);
550
757
  return true;
551
758
  }
552
759
 
553
- export function writeToken(token, home = os.homedir(), identity = null) {
760
+ export function writeToken(token, home = os.homedir(), identity = null, {
761
+ now = () => new Date(),
762
+ writeFile = atomicWriteFileSync,
763
+ unlinkFile = fs.unlinkSync,
764
+ } = {}) {
554
765
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
555
- fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
556
- fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
557
- fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
558
- if (normalizeGithubIdentity(identity)) {
559
- writeGithubTokenMetadata(identity, home, token);
560
- } else {
561
- try { fs.unlinkSync(githubTokenMetadataPath(home)); } catch (e) { if (e?.code !== "ENOENT") throw e; }
562
- }
766
+ const directory = path.dirname(GITHUB_TOKEN_PATH);
767
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
768
+ fs.chmodSync(directory, 0o700);
769
+ atomicWriteFilePairSync(
770
+ GITHUB_TOKEN_PATH,
771
+ token,
772
+ githubTokenMetadataPath(home),
773
+ githubTokenMetadataData(identity, token, now),
774
+ { mode: 0o600, writeFile, unlinkFile },
775
+ );
563
776
  }
package/src/cli-auth.mjs CHANGED
@@ -3,7 +3,8 @@ import { spawn } from "node:child_process";
3
3
  import {
4
4
  discoverGithubToken,
5
5
  fetchGithubIdentity,
6
- interpretPoll,
6
+ pollGithubDeviceFlow,
7
+ requestGithubDeviceCode,
7
8
  sourceDescription,
8
9
  validateGithubToken,
9
10
  } from "./auth.mjs";
@@ -20,6 +21,10 @@ import {
20
21
  githubTokenFingerprint,
21
22
  normalizeGithubIdentity,
22
23
  } from "./github-identity.mjs";
24
+ import {
25
+ MAX_UPSTREAM_MODEL_CATALOG_BYTES,
26
+ readBoundedResponseText,
27
+ } from "./http-transport.mjs";
23
28
  import { saveModelCache } from "./model-cache.mjs";
24
29
  import { isClaudeCopilotModel } from "./models.mjs";
25
30
  import { profileRouting } from "./profile-routing.mjs";
@@ -31,36 +36,7 @@ import {
31
36
  terminalCell,
32
37
  } from "./cli-table.mjs";
33
38
 
34
- const CLIENT_ID = "Iv1.b507a08c87ecfe98";
35
- const SCOPE = "read:user";
36
- const DEVICE_CODE_URL = "https://github.com/login/device/code";
37
- const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
38
- const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
39
- const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
40
-
41
- function abortError(signal) {
42
- if (signal?.reason instanceof Error) return signal.reason;
43
- const error = new Error("The operation was aborted");
44
- error.name = "AbortError";
45
- return error;
46
- }
47
-
48
- function sleep(ms, { signal } = {}) {
49
- if (signal?.aborted) return Promise.reject(abortError(signal));
50
- return new Promise((resolve, reject) => {
51
- const cleanup = () => signal?.removeEventListener("abort", onAbort);
52
- const onAbort = () => {
53
- clearTimeout(timer);
54
- cleanup();
55
- reject(abortError(signal));
56
- };
57
- const timer = setTimeout(() => {
58
- cleanup();
59
- resolve();
60
- }, ms);
61
- signal?.addEventListener("abort", onAbort, { once: true });
62
- });
63
- }
39
+ const DEFAULT_COPILOT_CATALOG_TIMEOUT_MS = 30 * 1000;
64
40
 
65
41
  function openAndCopy(userCode, verificationUri) {
66
42
  if (process.platform !== "darwin") return;
@@ -80,13 +56,50 @@ function responseDetail(value) {
80
56
  return String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
81
57
  }
82
58
 
83
- async function jsonResponse(response, label) {
84
- if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}`);
85
- try {
86
- return await response.json();
87
- } catch {
88
- throw new Error(`${label} returned invalid JSON`);
59
+ function abortError(signal) {
60
+ if (signal?.reason instanceof Error) return signal.reason;
61
+ const error = new Error("The operation was aborted");
62
+ error.name = "AbortError";
63
+ return error;
64
+ }
65
+
66
+ function cancelResponseBody(response) {
67
+ try { Promise.resolve(response?.body?.cancel?.()).catch(() => {}); } catch {}
68
+ }
69
+
70
+ function fetchWithSignal(operation, signal) {
71
+ if (signal?.aborted) return Promise.reject(abortError(signal));
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const finish = (callback, value) => {
75
+ if (settled) return false;
76
+ settled = true;
77
+ signal?.removeEventListener("abort", onAbort);
78
+ callback(value);
79
+ return true;
80
+ };
81
+ const onAbort = () => finish(reject, abortError(signal));
82
+ signal?.addEventListener("abort", onAbort, { once: true });
83
+ Promise.resolve()
84
+ .then(operation)
85
+ .then((response) => {
86
+ if (!finish(resolve, response)) cancelResponseBody(response);
87
+ }, (error) => finish(reject, error));
88
+ });
89
+ }
90
+
91
+ function catalogTimeoutError(label, timeoutMs) {
92
+ const error = new Error(`${label} model catalog timed out after ${timeoutMs}ms`);
93
+ error.code = "CCDX_COPILOT_CATALOG_TIMEOUT";
94
+ return error;
95
+ }
96
+
97
+ async function jsonResponse({ response, data, jsonError }, label) {
98
+ if (!response.ok) {
99
+ throw new Error(`${label} failed with HTTP ${response.status}`);
89
100
  }
101
+ if (jsonError) throw new Error(`${label} returned invalid JSON`);
102
+ return data;
90
103
  }
91
104
 
92
105
  export async function requestDeviceFlowToken({
@@ -94,16 +107,16 @@ export async function requestDeviceFlowToken({
94
107
  signal,
95
108
  log = console.log,
96
109
  openAndCopyFn = openAndCopy,
97
- sleepImpl = sleep,
110
+ deviceCodeTimeoutMs,
111
+ sleepImpl,
98
112
  now = Date.now,
99
113
  } = {}) {
100
- const codeResponse = await fetchImpl(DEVICE_CODE_URL, {
101
- method: "POST",
102
- headers: { "Content-Type": "application/json", Accept: "application/json" },
103
- body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
114
+ const codeResult = await requestGithubDeviceCode({
115
+ fetchImpl,
104
116
  signal,
117
+ timeoutMs: deviceCodeTimeoutMs,
105
118
  });
106
- const code = await jsonResponse(codeResponse, "Device code request");
119
+ const code = await jsonResponse(codeResult, "Device code request");
107
120
  if (!code.device_code || !code.user_code || !code.verification_uri) {
108
121
  throw new Error("Device code response is missing required fields");
109
122
  }
@@ -111,32 +124,18 @@ export async function requestDeviceFlowToken({
111
124
  log(`\n${status("info", `Open ${code.verification_uri}`)}\n${status("info", `Enter Claude account code: ${code.user_code}`)}\n`);
112
125
  openAndCopyFn(code.user_code, code.verification_uri);
113
126
 
114
- let waitMs = (Number(code.interval) || DEFAULT_DEVICE_INTERVAL_SECONDS) * 1000;
115
- const expiresMs = (Number(code.expires_in) || DEFAULT_DEVICE_EXPIRES_SECONDS) * 1000;
116
- const deadline = now() + expiresMs;
117
-
118
- while (now() < deadline) {
119
- await sleepImpl(waitMs, { signal });
120
- const pollResponse = await fetchImpl(ACCESS_TOKEN_URL, {
121
- method: "POST",
122
- headers: { "Content-Type": "application/json", Accept: "application/json" },
123
- body: JSON.stringify({
124
- client_id: CLIENT_ID,
125
- device_code: code.device_code,
126
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
127
- }),
128
- signal,
129
- });
130
- if (!pollResponse.ok) continue;
131
- const result = interpretPoll(await pollResponse.json());
132
- if (result.state === "done") return result.token;
133
- if (result.state === "slow") {
134
- waitMs += 5000;
135
- continue;
136
- }
137
- if (result.state === "fail") throw new Error(`Claude login failed: ${result.error}`);
138
- }
139
- throw new Error("Claude login failed: device code expired");
127
+ const result = await pollGithubDeviceFlow({
128
+ deviceCode: code.device_code,
129
+ interval: code.interval,
130
+ expiresIn: code.expires_in,
131
+ fetchImpl,
132
+ signal,
133
+ sleepImpl,
134
+ now,
135
+ });
136
+ if (result.state === "done") return result.token;
137
+ if (result.state === "expired") throw new Error("Claude login failed: device code expired");
138
+ throw new Error(`Claude login failed: ${result.error}`);
140
139
  }
141
140
 
142
141
  async function codexIdentity({ home, fetchImpl, signal }) {
@@ -166,33 +165,58 @@ async function fetchCopilotCatalog(tokenData, {
166
165
  signal,
167
166
  vscodeVersion = FALLBACK_VSCODE_VERSION,
168
167
  label = "Copilot",
168
+ timeoutMs = DEFAULT_COPILOT_CATALOG_TIMEOUT_MS,
169
169
  } = {}) {
170
- const apiBase = tokenData?.endpoints?.api || DEFAULT_API_BASE;
171
- const headers = buildHeaders({
172
- token: tokenData.token,
173
- version: vscodeVersion,
174
- initiator: "user",
175
- vision: false,
176
- });
177
- const modelResponse = await fetchImpl(`${apiBase}/models`, {
178
- headers,
179
- redirect: "error",
180
- signal,
181
- });
182
- const modelText = await modelResponse.text();
183
- if (!modelResponse.ok) {
184
- const detail = responseDetail(modelText);
185
- throw new Error(`${label} model catalog failed with HTTP ${modelResponse.status}${detail ? `: ${detail}` : ""}`);
186
- }
187
-
188
- let catalog;
170
+ const parsedTimeout = Number(timeoutMs);
171
+ const deadlineMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0
172
+ ? parsedTimeout
173
+ : DEFAULT_COPILOT_CATALOG_TIMEOUT_MS;
174
+ const controller = new AbortController();
175
+ const onCallerAbort = () => controller.abort(signal?.reason);
176
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
177
+ if (signal?.aborted) onCallerAbort();
178
+ const timer = setTimeout(() => controller.abort(catalogTimeoutError(label, deadlineMs)), deadlineMs);
179
+ let modelResponse;
189
180
  try {
190
- catalog = JSON.parse(modelText);
191
- } catch {
192
- throw new Error(`${label} model catalog returned invalid JSON`);
181
+ const apiBase = tokenData?.endpoints?.api || DEFAULT_API_BASE;
182
+ const headers = buildHeaders({
183
+ token: tokenData.token,
184
+ version: vscodeVersion,
185
+ initiator: "user",
186
+ vision: false,
187
+ });
188
+ modelResponse = await fetchWithSignal(() => fetchImpl(`${apiBase}/models`, {
189
+ headers,
190
+ redirect: "error",
191
+ signal: controller.signal,
192
+ }), controller.signal);
193
+ const modelText = await readBoundedResponseText(modelResponse, {
194
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
195
+ label: `${label} model catalog`,
196
+ signal: controller.signal,
197
+ });
198
+ if (!modelResponse.ok) {
199
+ const detail = responseDetail(modelText);
200
+ throw new Error(`${label} model catalog failed with HTTP ${modelResponse.status}${detail ? `: ${detail}` : ""}`);
201
+ }
202
+
203
+ let catalog;
204
+ try {
205
+ catalog = JSON.parse(modelText);
206
+ } catch {
207
+ throw new Error(`${label} model catalog returned invalid JSON`);
208
+ }
209
+ catalogModels(catalog);
210
+ return { catalog, apiBase };
211
+ } catch (error) {
212
+ if (signal?.aborted) throw abortError(signal);
213
+ if (controller.signal.aborted) throw abortError(controller.signal);
214
+ throw error;
215
+ } finally {
216
+ clearTimeout(timer);
217
+ signal?.removeEventListener("abort", onCallerAbort);
218
+ if (controller.signal.aborted) cancelResponseBody(modelResponse);
193
219
  }
194
- catalogModels(catalog);
195
- return { catalog, apiBase };
196
220
  }
197
221
 
198
222
  export async function validateClaudeCandidate(githubToken, {
@@ -201,6 +225,7 @@ export async function validateClaudeCandidate(githubToken, {
201
225
  fetchImpl = fetch,
202
226
  signal,
203
227
  vscodeVersion = FALLBACK_VSCODE_VERSION,
228
+ catalogTimeoutMs,
204
229
  } = {}) {
205
230
  const validation = await validateGithubToken(githubToken, { fetchImpl, signal });
206
231
  if (!validation.ok) {
@@ -225,6 +250,7 @@ export async function validateClaudeCandidate(githubToken, {
225
250
  signal,
226
251
  vscodeVersion,
227
252
  label: "Claude account",
253
+ timeoutMs: catalogTimeoutMs,
228
254
  });
229
255
  const models = claudeModels(catalog);
230
256
  if (!models.length) {
@@ -249,7 +275,9 @@ export async function authorizeClaudeProfile({
249
275
  signal,
250
276
  log = console.log,
251
277
  openAndCopyFn = openAndCopy,
252
- sleepImpl = sleep,
278
+ deviceCodeTimeoutMs,
279
+ catalogTimeoutMs,
280
+ sleepImpl,
253
281
  now = Date.now,
254
282
  saveModelCacheFn = saveModelCache,
255
283
  } = {}) {
@@ -302,6 +330,7 @@ export async function authorizeClaudeProfile({
302
330
  signal,
303
331
  log,
304
332
  openAndCopyFn,
333
+ deviceCodeTimeoutMs,
305
334
  sleepImpl,
306
335
  now,
307
336
  });
@@ -311,6 +340,7 @@ export async function authorizeClaudeProfile({
311
340
  expectedLogin,
312
341
  fetchImpl,
313
342
  signal,
343
+ catalogTimeoutMs,
314
344
  });
315
345
  await withAuthProfileLock(AUTH_PROFILE_CODEX, () => {
316
346
  const currentCodex = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });