codex-copilot-dx 0.5.6 → 0.6.0
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 +55 -3
- package/bin/cli.mjs +63 -92
- package/package.json +1 -1
- package/src/adapter.mjs +56 -14
- package/src/auth-profile.mjs +205 -0
- package/src/cli-auth.mjs +493 -0
- package/src/cli-models.mjs +38 -13
- package/src/cli-options.mjs +92 -12
- package/src/cli-status.mjs +29 -3
- package/src/copilot-client.mjs +226 -0
- package/src/copilot-token-session.mjs +362 -0
- package/src/copilot.mjs +45 -387
- package/src/doctor.mjs +169 -23
- package/src/model-cache.mjs +22 -6
- package/src/observability.mjs +53 -3
- package/src/profile-model-runtime.mjs +261 -0
- package/src/profile-runtime.mjs +80 -0
- package/src/responses-handler.mjs +7 -6
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import {
|
|
4
|
+
fetchGithubIdentity,
|
|
5
|
+
githubReauthMessage,
|
|
6
|
+
githubTokenPath,
|
|
7
|
+
importDiscoveredGithubToken,
|
|
8
|
+
readGithubTokenMetadata,
|
|
9
|
+
writeGithubTokenMetadata,
|
|
10
|
+
} from "./auth.mjs";
|
|
11
|
+
import {
|
|
12
|
+
githubIdentitiesEqual,
|
|
13
|
+
githubIdentityLabel,
|
|
14
|
+
githubTokenFingerprint,
|
|
15
|
+
normalizeGithubIdentity,
|
|
16
|
+
} from "./github-identity.mjs";
|
|
17
|
+
import { status } from "./status.mjs";
|
|
18
|
+
|
|
19
|
+
const GITHUB_API = "https://api.github.com";
|
|
20
|
+
|
|
21
|
+
function abortError(signal) {
|
|
22
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
23
|
+
const error = new Error("The operation was aborted");
|
|
24
|
+
error.name = "AbortError";
|
|
25
|
+
error.code = "ABORT_ERR";
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isAbortError(error, signal) {
|
|
30
|
+
return signal?.aborted || error?.name === "AbortError" || error?.code === "ABORT_ERR";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function waitForSingleflight(flight, signal) {
|
|
34
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
35
|
+
flight.waiters += 1;
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let finished = false;
|
|
38
|
+
const finish = (handler, value) => {
|
|
39
|
+
if (finished) return;
|
|
40
|
+
finished = true;
|
|
41
|
+
signal?.removeEventListener("abort", onAbort);
|
|
42
|
+
flight.waiters -= 1;
|
|
43
|
+
if (!flight.settled && flight.waiters === 0) flight.controller.abort();
|
|
44
|
+
handler(value);
|
|
45
|
+
};
|
|
46
|
+
const onAbort = () => finish(reject, abortError(signal));
|
|
47
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
if (signal?.aborted) onAbort();
|
|
49
|
+
flight.promise.then(
|
|
50
|
+
(value) => finish(resolve, value),
|
|
51
|
+
(error) => finish(reject, error),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function refreshError(message, { statusCode, transient = false } = {}) {
|
|
57
|
+
const error = new Error(message);
|
|
58
|
+
if (statusCode) error.statusCode = statusCode;
|
|
59
|
+
if (transient) error.transient = true;
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function copilotProfileLabel(profile) {
|
|
64
|
+
if (profile === "codex") return "Copilot";
|
|
65
|
+
const name = String(profile || "profile");
|
|
66
|
+
return `${name[0].toUpperCase()}${name.slice(1)} Copilot`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createCopilotTokenSession({
|
|
70
|
+
profile = "codex",
|
|
71
|
+
home: fixedHome,
|
|
72
|
+
tokenPath: configuredTokenPath,
|
|
73
|
+
readGithubCredentials,
|
|
74
|
+
allowTokenDiscovery = profile === "codex" && configuredTokenPath === undefined,
|
|
75
|
+
readGithubIdentity: readProfileGithubIdentity,
|
|
76
|
+
writeGithubIdentity: writeProfileGithubIdentity,
|
|
77
|
+
reauthMessage,
|
|
78
|
+
tokenFetchImpl = fetch,
|
|
79
|
+
defaultApiBase,
|
|
80
|
+
parseApiBase,
|
|
81
|
+
fetchCopilotUpstream,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const hasIsolatedTokenPath = typeof configuredTokenPath === "function"
|
|
84
|
+
|| (typeof configuredTokenPath === "string" && configuredTokenPath.trim().length > 0);
|
|
85
|
+
if (profile !== "codex" && !hasIsolatedTokenPath && typeof readGithubCredentials !== "function") {
|
|
86
|
+
throw new Error(`Authentication profile ${profile} requires an isolated tokenPath or readGithubCredentials loader`);
|
|
87
|
+
}
|
|
88
|
+
if (profile !== "codex" && allowTokenDiscovery) {
|
|
89
|
+
throw new Error(`Authentication profile ${profile} cannot enable legacy token discovery`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let apiBase = defaultApiBase;
|
|
93
|
+
let copilotToken = null;
|
|
94
|
+
let copilotTokenExpiry = 0;
|
|
95
|
+
let refreshFlight = null;
|
|
96
|
+
let tokenSourceKey = null;
|
|
97
|
+
let githubFingerprint = "";
|
|
98
|
+
let githubIdentity = null;
|
|
99
|
+
let refreshRetryAt = 0;
|
|
100
|
+
|
|
101
|
+
const resolveHome = (home) => fixedHome || home || os.homedir();
|
|
102
|
+
const usesDefaultTokenStore = configuredTokenPath === undefined && typeof readGithubCredentials !== "function";
|
|
103
|
+
const resolveTokenPath = (home) => {
|
|
104
|
+
if (typeof configuredTokenPath === "function") return configuredTokenPath(resolveHome(home));
|
|
105
|
+
return configuredTokenPath || githubTokenPath(resolveHome(home));
|
|
106
|
+
};
|
|
107
|
+
const formatReauth = (reason, home) => {
|
|
108
|
+
if (typeof reauthMessage === "function") {
|
|
109
|
+
return reauthMessage(reason, {
|
|
110
|
+
profile,
|
|
111
|
+
home: resolveHome(home),
|
|
112
|
+
tokenPath: resolveTokenPath(home),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (usesDefaultTokenStore) return githubReauthMessage(reason, resolveHome(home));
|
|
116
|
+
return `${reason}\nRun \`ccdx auth login ${profile} --reauth\`, then restart ccdx.`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
async function loadCredentials(home) {
|
|
120
|
+
const resolvedHome = resolveHome(home);
|
|
121
|
+
const tokenFile = resolveTokenPath(resolvedHome);
|
|
122
|
+
if (typeof readGithubCredentials === "function") {
|
|
123
|
+
const credentials = await readGithubCredentials({
|
|
124
|
+
profile,
|
|
125
|
+
home: resolvedHome,
|
|
126
|
+
tokenPath: tokenFile,
|
|
127
|
+
});
|
|
128
|
+
if (!credentials || credentials.valid === false || !String(credentials.token || "").trim()) {
|
|
129
|
+
const reason = credentials?.reason ? ` (${credentials.reason})` : "";
|
|
130
|
+
throw refreshError(formatReauth(`${profile} authentication profile is invalid${reason}.`, resolvedHome), {
|
|
131
|
+
statusCode: 401,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
token: String(credentials.token).trim(),
|
|
136
|
+
identity: normalizeGithubIdentity(credentials.identity),
|
|
137
|
+
sourceKey: credentials.paths?.tokenPath || tokenFile,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!fs.existsSync(tokenFile)) {
|
|
141
|
+
const message = usesDefaultTokenStore
|
|
142
|
+
? "GitHub token not found. Run ccdx again to log in."
|
|
143
|
+
: formatReauth(`GitHub token for ${profile} profile was not found.`, resolvedHome);
|
|
144
|
+
throw refreshError(message, { statusCode: 401 });
|
|
145
|
+
}
|
|
146
|
+
const token = fs.readFileSync(tokenFile, "utf8").trim();
|
|
147
|
+
if (!token) {
|
|
148
|
+
const reason = profile === "codex" ? "GitHub token file is empty." : `GitHub token for ${profile} profile is empty.`;
|
|
149
|
+
throw refreshError(formatReauth(reason, resolvedHome), { statusCode: 401 });
|
|
150
|
+
}
|
|
151
|
+
return { token, identity: null, sourceKey: tokenFile };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function identityForToken(token, credentialsIdentity, { home, fetchImpl, signal }) {
|
|
155
|
+
if (credentialsIdentity) return credentialsIdentity;
|
|
156
|
+
let cached = null;
|
|
157
|
+
if (typeof readProfileGithubIdentity === "function") {
|
|
158
|
+
cached = await readProfileGithubIdentity(token, {
|
|
159
|
+
profile,
|
|
160
|
+
home: resolveHome(home),
|
|
161
|
+
tokenPath: resolveTokenPath(home),
|
|
162
|
+
});
|
|
163
|
+
} else if (usesDefaultTokenStore) {
|
|
164
|
+
cached = readGithubTokenMetadata(resolveHome(home), token);
|
|
165
|
+
}
|
|
166
|
+
if (cached) return cached;
|
|
167
|
+
const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
|
|
168
|
+
return identity.ok ? identity : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function persistIdentity(identity, home, token) {
|
|
172
|
+
if (!identity) return;
|
|
173
|
+
if (typeof writeProfileGithubIdentity === "function") {
|
|
174
|
+
writeProfileGithubIdentity(identity, {
|
|
175
|
+
profile,
|
|
176
|
+
home: resolveHome(home),
|
|
177
|
+
token,
|
|
178
|
+
tokenPath: resolveTokenPath(home),
|
|
179
|
+
});
|
|
180
|
+
} else if (usesDefaultTokenStore) {
|
|
181
|
+
writeGithubTokenMetadata(identity, resolveHome(home), token);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function cacheTokenData(data, {
|
|
186
|
+
sourceKey,
|
|
187
|
+
githubToken,
|
|
188
|
+
identity,
|
|
189
|
+
allowAccountSwitch = false,
|
|
190
|
+
}) {
|
|
191
|
+
if (!data.token) throw new Error("Copilot token response missing token field");
|
|
192
|
+
const fingerprint = githubTokenFingerprint(githubToken);
|
|
193
|
+
const normalizedIdentity = normalizeGithubIdentity(identity);
|
|
194
|
+
const tokenChanged = Boolean(githubFingerprint && fingerprint !== githubFingerprint);
|
|
195
|
+
if (!allowAccountSwitch && githubIdentity && normalizedIdentity
|
|
196
|
+
&& !githubIdentitiesEqual(githubIdentity, normalizedIdentity)) {
|
|
197
|
+
const profileLabel = profile === "codex" ? "" : `${profile} `;
|
|
198
|
+
throw new Error(`Refusing to switch ${profileLabel}GitHub Copilot account from ${githubIdentityLabel(githubIdentity)} to ${githubIdentityLabel(normalizedIdentity)} while the adapter is running. Restart ccdx to switch accounts intentionally.`);
|
|
199
|
+
}
|
|
200
|
+
if (!allowAccountSwitch && githubIdentity && tokenChanged && !normalizedIdentity) {
|
|
201
|
+
const profileLabel = profile === "codex" ? "" : `${profile} `;
|
|
202
|
+
const error = new Error(`The saved ${profileLabel}GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.`);
|
|
203
|
+
error.transient = true;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
copilotToken = data.token;
|
|
207
|
+
apiBase = parseApiBase(data);
|
|
208
|
+
copilotTokenExpiry = typeof data.expires_at === "number"
|
|
209
|
+
? data.expires_at * 1000
|
|
210
|
+
: Date.now() + 25 * 60 * 1000;
|
|
211
|
+
tokenSourceKey = sourceKey;
|
|
212
|
+
githubFingerprint = fingerprint;
|
|
213
|
+
if (normalizedIdentity) githubIdentity = normalizedIdentity;
|
|
214
|
+
refreshRetryAt = 0;
|
|
215
|
+
console.log(status("ok", `${copilotProfileLabel(profile)} token refreshed`));
|
|
216
|
+
return copilotToken;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function refresh({
|
|
220
|
+
signal,
|
|
221
|
+
home,
|
|
222
|
+
env = process.env,
|
|
223
|
+
fetchImpl = tokenFetchImpl,
|
|
224
|
+
tokenRetryOptions,
|
|
225
|
+
} = {}) {
|
|
226
|
+
const resolvedHome = resolveHome(home);
|
|
227
|
+
const credentials = await loadCredentials(resolvedHome);
|
|
228
|
+
let response;
|
|
229
|
+
try {
|
|
230
|
+
response = await fetchCopilotUpstream(`${GITHUB_API}/copilot_internal/v2/token`, {
|
|
231
|
+
headers: { Authorization: `token ${credentials.token}`, Accept: "application/json" },
|
|
232
|
+
signal,
|
|
233
|
+
}, { fetchImpl, ...tokenRetryOptions });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!isAbortError(error, signal)) error.transient = true;
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
if ((response.status === 401 || response.status === 403) && allowTokenDiscovery) {
|
|
240
|
+
const imported = await importDiscoveredGithubToken({
|
|
241
|
+
home: resolvedHome,
|
|
242
|
+
env,
|
|
243
|
+
fetchImpl,
|
|
244
|
+
signal,
|
|
245
|
+
excludeTokens: [credentials.token],
|
|
246
|
+
validateSavedToken: true,
|
|
247
|
+
});
|
|
248
|
+
if (imported?.validation?.copilotTokenData?.token) {
|
|
249
|
+
const explicit = imported.source?.type === "env" || imported.source?.type === "token-file";
|
|
250
|
+
return cacheTokenData(imported.validation.copilotTokenData, {
|
|
251
|
+
sourceKey: credentials.sourceKey,
|
|
252
|
+
githubToken: imported.token,
|
|
253
|
+
identity: imported.validation,
|
|
254
|
+
allowAccountSwitch: explicit,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (response.status === 401 || response.status === 403) {
|
|
259
|
+
const prefix = profile === "codex" ? "Failed to get Copilot token" : `Failed to get Copilot token for ${profile} profile`;
|
|
260
|
+
throw refreshError(formatReauth(`${prefix}: ${response.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`, resolvedHome), {
|
|
261
|
+
statusCode: response.status,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const prefix = profile === "codex" ? "Failed to get Copilot token" : `Failed to get Copilot token for ${profile} profile`;
|
|
265
|
+
throw refreshError(`${prefix}: ${response.status}`, {
|
|
266
|
+
statusCode: response.status,
|
|
267
|
+
transient: response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const data = await response.json();
|
|
271
|
+
const identity = await identityForToken(credentials.token, credentials.identity, {
|
|
272
|
+
home: resolvedHome,
|
|
273
|
+
fetchImpl,
|
|
274
|
+
signal,
|
|
275
|
+
});
|
|
276
|
+
const token = cacheTokenData(data, {
|
|
277
|
+
sourceKey: credentials.sourceKey,
|
|
278
|
+
githubToken: credentials.token,
|
|
279
|
+
identity,
|
|
280
|
+
});
|
|
281
|
+
persistIdentity(identity, resolvedHome, credentials.token);
|
|
282
|
+
return token;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function canUseCached(sourceKey, now = Date.now(), allowCurrent = false) {
|
|
286
|
+
return Boolean(copilotToken
|
|
287
|
+
&& (tokenSourceKey === sourceKey || allowCurrent)
|
|
288
|
+
&& now < copilotTokenExpiry);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function getToken(options = {}) {
|
|
292
|
+
const home = resolveHome(options.home);
|
|
293
|
+
const sourceKey = resolveTokenPath(home);
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
const allowCurrent = options.home === undefined || fixedHome !== undefined;
|
|
296
|
+
if (canUseCached(sourceKey, now, allowCurrent) && now < copilotTokenExpiry - 60000) return Promise.resolve(copilotToken);
|
|
297
|
+
if (canUseCached(sourceKey, now, allowCurrent) && now < refreshRetryAt) return Promise.resolve(copilotToken);
|
|
298
|
+
if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
|
|
299
|
+
|
|
300
|
+
let flight = refreshFlight?.key === sourceKey ? refreshFlight : null;
|
|
301
|
+
if (flight?.controller.signal.aborted) {
|
|
302
|
+
if (refreshFlight === flight) refreshFlight = null;
|
|
303
|
+
flight = null;
|
|
304
|
+
}
|
|
305
|
+
if (!flight) {
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
const tokenRetryOptions = options.tokenRetryOptions
|
|
308
|
+
?? (canUseCached(sourceKey, now, allowCurrent) ? { retries: 0 } : undefined);
|
|
309
|
+
flight = { key: sourceKey, controller, waiters: 0, settled: false, promise: null };
|
|
310
|
+
flight.promise = refresh({ ...options, home, signal: controller.signal, tokenRetryOptions })
|
|
311
|
+
.catch((error) => {
|
|
312
|
+
const fallbackNow = Date.now();
|
|
313
|
+
if (error?.transient && canUseCached(sourceKey, fallbackNow)) {
|
|
314
|
+
refreshRetryAt = Math.min(copilotTokenExpiry, fallbackNow + 5000);
|
|
315
|
+
const profileLabel = copilotProfileLabel(profile);
|
|
316
|
+
console.warn(status("warn", `${profileLabel} token refresh failed temporarily; using the existing token until ${new Date(copilotTokenExpiry).toISOString()} (${error.message})`));
|
|
317
|
+
return copilotToken;
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
})
|
|
321
|
+
.finally(() => {
|
|
322
|
+
flight.settled = true;
|
|
323
|
+
if (refreshFlight === flight) refreshFlight = null;
|
|
324
|
+
});
|
|
325
|
+
refreshFlight = flight;
|
|
326
|
+
}
|
|
327
|
+
return waitForSingleflight(flight, options.signal);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function runtimeStatus(now = Date.now()) {
|
|
331
|
+
let upstreamHost = "unknown";
|
|
332
|
+
try { upstreamHost = new URL(apiBase).hostname; } catch {}
|
|
333
|
+
return {
|
|
334
|
+
profile,
|
|
335
|
+
token_cached: Boolean(copilotToken),
|
|
336
|
+
token_expires_in_ms: copilotToken ? Math.max(0, copilotTokenExpiry - now) : null,
|
|
337
|
+
token_refresh_in_flight: Boolean(refreshFlight),
|
|
338
|
+
token_refresh_backoff_ms: Math.max(0, refreshRetryAt - now),
|
|
339
|
+
account_bound: Boolean(githubIdentity),
|
|
340
|
+
upstream_host: upstreamHost,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function resetForTests() {
|
|
345
|
+
refreshFlight?.controller.abort();
|
|
346
|
+
copilotToken = null;
|
|
347
|
+
copilotTokenExpiry = 0;
|
|
348
|
+
refreshFlight = null;
|
|
349
|
+
tokenSourceKey = null;
|
|
350
|
+
githubFingerprint = "";
|
|
351
|
+
githubIdentity = null;
|
|
352
|
+
refreshRetryAt = 0;
|
|
353
|
+
apiBase = defaultApiBase;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return Object.freeze({
|
|
357
|
+
getToken,
|
|
358
|
+
getApiBase: () => apiBase,
|
|
359
|
+
runtimeStatus,
|
|
360
|
+
resetForTests,
|
|
361
|
+
});
|
|
362
|
+
}
|