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/README.md +28 -15
- package/bin/cli.mjs +7 -1
- package/package.json +1 -1
- package/src/adapter.mjs +47 -10
- package/src/anthropic-handler.mjs +25 -6
- package/src/anthropic.mjs +19 -7
- package/src/atomic-file.mjs +76 -2
- package/src/auth-profile.mjs +11 -34
- package/src/auth.mjs +265 -52
- package/src/cli-auth.mjs +124 -94
- package/src/cli-error.mjs +44 -0
- package/src/cli-options.mjs +8 -5
- package/src/copilot-client.mjs +5 -1
- package/src/copilot-responses-compat.mjs +9 -2
- package/src/copilot.mjs +2 -1
- package/src/http-transport.mjs +138 -0
- package/src/lock.mjs +199 -17
- package/src/models.mjs +17 -0
- package/src/pm-studio-asar.mjs +399 -19
- package/src/pm-studio-relay.mjs +26 -5
- package/src/pm-studio-setup.mjs +351 -16
- package/src/pm-studio-status.mjs +5 -1
- package/src/response-history.mjs +122 -13
- package/src/responses-bridge.mjs +22 -4
- package/src/responses-handler.mjs +123 -36
- package/src/responses-image-pressure.mjs +30 -0
- package/src/responses-proxy.mjs +13 -1
- package/src/responses-request.mjs +17 -5
- package/src/runtime-config.mjs +10 -0
- package/src/token-counter-service.mjs +188 -0
- package/src/token-counter-worker.mjs +10 -0
- package/src/usage.mjs +25 -7
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
|
|
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
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
733
|
+
function githubTokenMetadataData(identity, token, now = () => new Date()) {
|
|
540
734
|
const normalized = normalizeGithubIdentity(identity);
|
|
541
|
-
if (!normalized) return
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
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:
|
|
548
|
-
}, null, 2)}\n
|
|
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
|
-
|
|
556
|
-
fs.
|
|
557
|
-
fs.chmodSync(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
110
|
+
deviceCodeTimeoutMs,
|
|
111
|
+
sleepImpl,
|
|
98
112
|
now = Date.now,
|
|
99
113
|
} = {}) {
|
|
100
|
-
const
|
|
101
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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 });
|