codex-copilot-dx 0.4.23 → 0.4.25

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 { createHash } from "node:crypto";
5
6
  import { status } from "./status.mjs";
6
7
  import { withFileLock } from "./lock.mjs";
7
8
 
@@ -22,6 +23,10 @@ export function githubTokenLockPath(home = os.homedir()) {
22
23
  return `${githubTokenPath(home)}.lock`;
23
24
  }
24
25
 
26
+ export function githubTokenMetadataPath(home = os.homedir()) {
27
+ return `${githubTokenPath(home)}.account.json`;
28
+ }
29
+
25
30
  export function githubReauthMessage(reason, home = os.homedir()) {
26
31
  const tokenPath = githubTokenPath(home);
27
32
  return `${reason}
@@ -210,6 +215,73 @@ function readSavedGithubToken(home = os.homedir()) {
210
215
  return fs.readFileSync(filePath, "utf8").trim();
211
216
  }
212
217
 
218
+ function normalizeGithubIdentity(identity) {
219
+ if (!identity || typeof identity !== "object") return null;
220
+ const login = typeof identity.login === "string" ? identity.login.trim() : "";
221
+ const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
222
+ if (!login && !id) return null;
223
+ return { login, id };
224
+ }
225
+
226
+ function tokenFingerprint(token) {
227
+ return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
228
+ }
229
+
230
+ export function readGithubTokenMetadata(home = os.homedir(), token = null) {
231
+ try {
232
+ const parsed = JSON.parse(fs.readFileSync(githubTokenMetadataPath(home), "utf8"));
233
+ if (token && parsed.token_fingerprint !== tokenFingerprint(token)) return null;
234
+ const identity = normalizeGithubIdentity(parsed);
235
+ return identity ? { ...identity, token_fingerprint: parsed.token_fingerprint || "" } : null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ function githubIdentityMatches(identity, expected) {
242
+ const actual = normalizeGithubIdentity(identity);
243
+ const wanted = normalizeGithubIdentity(expected);
244
+ if (!wanted) return true;
245
+ if (!actual) return false;
246
+ if (wanted.id && actual.id) return wanted.id === actual.id;
247
+ return Boolean(wanted.login && actual.login && wanted.login.toLowerCase() === actual.login.toLowerCase());
248
+ }
249
+
250
+ function expectedGithubIdentity(home, env, token) {
251
+ const configuredLogin = String(env.CCDX_GITHUB_LOGIN || "").trim();
252
+ if (configuredLogin) return { login: configuredLogin };
253
+ return token ? readGithubTokenMetadata(home, token) : null;
254
+ }
255
+
256
+ export async function fetchGithubIdentity(token, { fetchImpl = fetch, signal } = {}) {
257
+ if (typeof token !== "string" || !token.trim()) return { ok: false, reason: "empty_token" };
258
+ try {
259
+ const resp = await fetchImpl(`${GITHUB_API}/user`, {
260
+ headers: { Authorization: `token ${token.trim()}`, Accept: "application/json" },
261
+ signal,
262
+ });
263
+ if (!resp.ok) return { ok: false, status: resp.status, reason: "github_user_failed" };
264
+ const data = await resp.json();
265
+ const identity = normalizeGithubIdentity(data);
266
+ return identity ? { ok: true, ...identity } : { ok: false, reason: "github_identity_missing" };
267
+ } catch (error) {
268
+ return { ok: false, transient: true, reason: "github_user_request_failed", error };
269
+ }
270
+ }
271
+
272
+ export async function ensureGithubTokenMetadata(token, {
273
+ home = os.homedir(),
274
+ fetchImpl = fetch,
275
+ signal,
276
+ } = {}) {
277
+ const existing = readGithubTokenMetadata(home, token);
278
+ if (existing) return existing;
279
+ const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
280
+ if (!identity.ok) return null;
281
+ writeGithubTokenMetadata(identity, home, token);
282
+ return normalizeGithubIdentity(identity);
283
+ }
284
+
213
285
  export async function validateGithubToken(token, {
214
286
  fetchImpl = fetch,
215
287
  signal,
@@ -239,6 +311,7 @@ export async function validateGithubToken(token, {
239
311
  userData = await userResp.json();
240
312
  } catch {}
241
313
  const login = typeof userData.login === "string" ? userData.login : "";
314
+ const id = userData.id === undefined || userData.id === null ? "" : String(userData.id);
242
315
 
243
316
  let copilotResp;
244
317
  try {
@@ -267,7 +340,7 @@ export async function validateGithubToken(token, {
267
340
  return { ok: false, reason: "copilot_token_missing", login };
268
341
  }
269
342
 
270
- return { ok: true, login, copilotTokenData };
343
+ return { ok: true, login, id, copilotTokenData };
271
344
  }
272
345
 
273
346
  export async function discoverGithubToken({
@@ -276,10 +349,13 @@ export async function discoverGithubToken({
276
349
  fetchImpl = fetch,
277
350
  signal,
278
351
  excludeTokens = [],
352
+ expectedIdentity,
353
+ strictExpectedIdentity = false,
279
354
  } = {}) {
280
355
  const excluded = new Set(excludeTokens);
281
356
  const seen = new Set(excluded);
282
357
  const failures = [];
358
+ const candidates = [];
283
359
 
284
360
  for (const source of githubTokenSources({ home, env })) {
285
361
  let token = "";
@@ -294,10 +370,40 @@ export async function discoverGithubToken({
294
370
  seen.add(token);
295
371
 
296
372
  const validation = await validateGithubToken(token, { fetchImpl, signal });
297
- if (validation.ok) return { ok: true, token, source, validation };
373
+ if (validation.ok) {
374
+ const candidate = { ok: true, token, source, validation };
375
+ const explicitSource = source.type === "env" || source.type === "token-file";
376
+ if (expectedIdentity && (strictExpectedIdentity || !explicitSource)) {
377
+ if (githubIdentityMatches(validation, expectedIdentity)) return candidate;
378
+ failures.push({ source, validation, reason: "github_account_mismatch" });
379
+ continue;
380
+ }
381
+ if (explicitSource) return candidate;
382
+ candidates.push(candidate);
383
+ continue;
384
+ }
298
385
  failures.push({ source, validation });
299
386
  }
300
387
 
388
+ if (candidates.length) {
389
+ const identities = new Set(candidates.map(({ validation, token }) => {
390
+ if (validation.id) return `id:${validation.id}`;
391
+ if (validation.login) return `login:${validation.login.toLowerCase()}`;
392
+ return `token:${token}`;
393
+ }));
394
+ if (identities.size === 1) return candidates[0];
395
+ return {
396
+ ok: false,
397
+ ambiguous: true,
398
+ candidates: candidates.map(({ source, validation }) => ({
399
+ source,
400
+ login: validation.login,
401
+ id: validation.id,
402
+ })),
403
+ failures,
404
+ };
405
+ }
406
+
301
407
  return { ok: false, failures };
302
408
  }
303
409
 
@@ -314,19 +420,34 @@ export async function importDiscoveredGithubToken({
314
420
  const run = async () => {
315
421
  const excluded = new Set(excludeTokens);
316
422
  const savedToken = readSavedGithubToken(home);
423
+ const expectedIdentity = expectedGithubIdentity(home, env, savedToken);
424
+ const strictExpectedIdentity = Boolean(String(env.CCDX_GITHUB_LOGIN || "").trim());
317
425
  if (savedToken && !excluded.has(savedToken)) {
318
426
  if (!validateSavedToken) {
319
427
  return { ok: true, token: savedToken, source: { type: "saved-token" }, imported: false };
320
428
  }
321
429
  const validation = await validateGithubToken(savedToken, { fetchImpl, signal });
322
- if (validation.ok) {
430
+ if (validation.ok && githubIdentityMatches(validation, expectedIdentity)) {
431
+ writeGithubTokenMetadata(validation, home, savedToken);
323
432
  return { ok: true, token: savedToken, source: { type: "saved-token" }, validation, imported: false };
324
433
  }
325
434
  }
326
435
 
327
- const discovered = await discoverGithubToken({ home, env, fetchImpl, signal, excludeTokens });
436
+ const discovered = await discoverGithubToken({
437
+ home,
438
+ env,
439
+ fetchImpl,
440
+ signal,
441
+ excludeTokens,
442
+ expectedIdentity,
443
+ strictExpectedIdentity,
444
+ });
445
+ if (discovered.ambiguous) {
446
+ const logins = [...new Set(discovered.candidates.map((candidate) => candidate.login || candidate.id || "unknown"))];
447
+ throw new Error(`Multiple GitHub Copilot accounts were found (${logins.join(", ")}). Set CCDX_GITHUB_LOGIN or CCDX_GITHUB_TOKEN_PATH to select one explicitly.`);
448
+ }
328
449
  if (!discovered.ok) return null;
329
- writeToken(discovered.token, home);
450
+ writeToken(discovered.token, home, discovered.validation);
330
451
  const login = discovered.validation.login ? ` for ${discovered.validation.login}` : "";
331
452
  log(status("ok", `Imported GitHub token from ${sourceDescription(discovered.source)}${login}`));
332
453
  return { ...discovered, imported: true };
@@ -421,7 +542,8 @@ export async function ensureAuth({
421
542
  const data = await pollResp.json();
422
543
  const r = interpretPoll(data);
423
544
  if (r.state === "done") {
424
- writeToken(r.token, home);
545
+ const identity = await fetchGithubIdentity(r.token, { fetchImpl, signal });
546
+ writeToken(r.token, home, identity.ok ? identity : null);
425
547
  log(status("ok", "Login successful"));
426
548
  return;
427
549
  }
@@ -432,9 +554,28 @@ export async function ensureAuth({
432
554
  }, tokenLockOptions(env));
433
555
  }
434
556
 
435
- export function writeToken(token, home = os.homedir()) {
557
+ export function writeGithubTokenMetadata(identity, home = os.homedir(), token = "") {
558
+ const normalized = normalizeGithubIdentity(identity);
559
+ if (!normalized) return false;
560
+ const filePath = githubTokenMetadataPath(home);
561
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
562
+ fs.writeFileSync(filePath, `${JSON.stringify({
563
+ ...normalized,
564
+ token_fingerprint: tokenFingerprint(token),
565
+ updated_at: new Date().toISOString(),
566
+ }, null, 2)}\n`, { mode: 0o600 });
567
+ fs.chmodSync(filePath, 0o600);
568
+ return true;
569
+ }
570
+
571
+ export function writeToken(token, home = os.homedir(), identity = null) {
436
572
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
437
573
  fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
438
574
  fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
439
575
  fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
576
+ if (normalizeGithubIdentity(identity)) {
577
+ writeGithubTokenMetadata(identity, home, token);
578
+ } else {
579
+ try { fs.unlinkSync(githubTokenMetadataPath(home)); } catch (e) { if (e?.code !== "ENOENT") throw e; }
580
+ }
440
581
  }
package/src/copilot.mjs CHANGED
@@ -3,24 +3,28 @@ import os from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
- import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
+ import { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
7
7
 
8
8
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
9
9
  let apiBase = DEFAULT_API_BASE;
10
10
 
11
11
  // Cache of model id -> supported_endpoints, populated from listModels(). Used to
12
12
  // route requests to /responses vs /chat/completions based on real model metadata.
13
- const modelEndpointCache = new Map();
13
+ let modelEndpointCache = new Map();
14
14
 
15
15
  export function cacheModelEndpoints(models) {
16
16
  const data = Array.isArray(models) ? models : models?.data;
17
- if (!Array.isArray(data)) return;
17
+ if (!Array.isArray(data)) return false;
18
+ const next = new Map();
18
19
  for (const model of data) {
19
20
  const id = String(model?.id || "").trim();
20
21
  if (id && Array.isArray(model?.supported_endpoints)) {
21
- modelEndpointCache.set(id, model.supported_endpoints);
22
+ next.set(id, [...model.supported_endpoints]);
22
23
  }
23
24
  }
25
+ if (!next.size) return false;
26
+ modelEndpointCache = next;
27
+ return true;
24
28
  }
25
29
 
26
30
  // Returns the cached supported_endpoints for a model id, or null if unknown.
@@ -463,6 +467,7 @@ async function refreshCopilotToken({
463
467
  throw new Error(`Failed to get Copilot token: ${resp.status}`);
464
468
  }
465
469
  const data = await resp.json();
470
+ await ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
466
471
  return cacheCopilotTokenData(data);
467
472
  }
468
473
 
package/src/doctor.mjs CHANGED
@@ -2,9 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
- import { githubTokenPath } from "./auth.mjs";
5
+ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from "./auth.mjs";
6
6
  import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
7
7
  import { status } from "./status.mjs";
8
+ import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
9
+ import { checkRunningAdapter } from "./running-adapter.mjs";
8
10
 
9
11
  function localGatewayBaseUrl(host, port) {
10
12
  const safeHost = String(host || "127.0.0.1");
@@ -55,7 +57,60 @@ export function inspectGitHubToken({ home = os.homedir() } = {}) {
55
57
  if (!token.text.trim()) {
56
58
  return [{ kind: "err", message: `GitHub token file is empty at ${displayPath(home, filePath)}` }];
57
59
  }
58
- return [{ kind: "ok", message: "GitHub token found" }];
60
+ const metadata = readGithubTokenMetadata(home, token.text.trim());
61
+ const account = metadata?.login ? ` for ${metadata.login}` : "";
62
+ return [{ kind: "ok", message: `GitHub token found${account}` }];
63
+ }
64
+
65
+ export async function inspectGitHubTokenOnline({
66
+ home = os.homedir(),
67
+ fetchImpl = fetch,
68
+ timeoutMs = 10000,
69
+ } = {}) {
70
+ const token = readText(githubTokenPath(home));
71
+ if (!token.ok || !token.text.trim()) {
72
+ return [{ kind: "warn", message: "Online Copilot check skipped because the GitHub token is missing" }];
73
+ }
74
+
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
77
+ try {
78
+ const validation = await validateGithubToken(token.text.trim(), {
79
+ fetchImpl,
80
+ signal: controller.signal,
81
+ });
82
+ if (!validation.ok) {
83
+ const statusText = validation.status ? ` (HTTP ${validation.status})` : "";
84
+ return [{ kind: validation.transient ? "warn" : "err", message: `GitHub Copilot authentication failed: ${validation.reason}${statusText}` }];
85
+ }
86
+
87
+ const tokenData = validation.copilotTokenData;
88
+ const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
89
+ const headers = buildHeaders({
90
+ token: tokenData.token,
91
+ version: FALLBACK_VSCODE_VERSION,
92
+ initiator: "user",
93
+ vision: false,
94
+ });
95
+ const modelResp = await fetchImpl(`${apiBase}/models`, { headers, signal: controller.signal });
96
+ if (!modelResp.ok) {
97
+ return [
98
+ { kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
99
+ { kind: "err", message: `Copilot models endpoint returned HTTP ${modelResp.status}` },
100
+ ];
101
+ }
102
+ const models = await modelResp.json();
103
+ const data = Array.isArray(models) ? models : models?.data;
104
+ return [
105
+ { kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
106
+ { kind: "ok", message: `Copilot models endpoint returned ${Array.isArray(data) ? data.length : 0} models` },
107
+ ];
108
+ } catch (e) {
109
+ const reason = e?.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e.message;
110
+ return [{ kind: "warn", message: `Online Copilot check failed: ${reason}` }];
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
59
114
  }
60
115
 
61
116
  export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
@@ -180,6 +235,10 @@ export async function collectDoctorChecks({
180
235
  port = 2026,
181
236
  checkAdapter = true,
182
237
  checkAdapterListeningFn = checkAdapterListening,
238
+ checkRunningAdapterFn = checkRunningAdapter,
239
+ online = false,
240
+ fetchImpl = fetch,
241
+ onlineTimeoutMs = 10000,
183
242
  } = {}) {
184
243
  const checks = [
185
244
  ...inspectGitHubToken({ home }),
@@ -188,11 +247,27 @@ export async function collectDoctorChecks({
188
247
  ...inspectClaudeAppConfig({ home, platform, env, host, port }),
189
248
  ];
190
249
 
250
+ if (online) {
251
+ checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
252
+ }
253
+
191
254
  if (checkAdapter) {
192
- const listening = await checkAdapterListeningFn({ host, port });
193
- checks.push(listening
194
- ? { kind: "ok", message: `Adapter is listening on ${localGatewayBaseUrl(host, port)}` }
195
- : { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
255
+ let running;
256
+ try {
257
+ running = await checkRunningAdapterFn({ host, port, fetchImpl });
258
+ } catch {
259
+ running = null;
260
+ }
261
+ if (running?.ok) {
262
+ checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
263
+ } else if (running?.incompatible) {
264
+ checks.push({ kind: "warn", message: `Adapter ${running.data?.version || "legacy"} is running at ${running.baseUrl}, but it is incompatible with this CLI` });
265
+ } else {
266
+ const listening = await checkAdapterListeningFn({ host, port });
267
+ checks.push(listening
268
+ ? { kind: "warn", message: `A service is listening on ${localGatewayBaseUrl(host, port)}, but it is not a compatible codex-copilot-dx adapter` }
269
+ : { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
270
+ }
196
271
  }
197
272
 
198
273
  return checks;
@@ -200,7 +275,7 @@ export async function collectDoctorChecks({
200
275
 
201
276
  export async function runDoctor(options = {}) {
202
277
  const log = options.log || console.log;
203
- log("codex-copilot-dx doctor");
278
+ log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
204
279
  const checks = await collectDoctorChecks(options);
205
280
  for (const check of checks) log(status(check.kind, check.message));
206
281
  return checks;
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
6
+
7
+ export function modelCachePath(home = os.homedir()) {
8
+ return path.join(home, ".local", "share", "codex-copilot-dx", "models.json");
9
+ }
10
+
11
+ function modelData(models) {
12
+ const data = Array.isArray(models) ? models : models?.data;
13
+ return Array.isArray(data) ? data : null;
14
+ }
15
+
16
+ export function loadModelCache({
17
+ home = os.homedir(),
18
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
19
+ now = Date.now,
20
+ } = {}) {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(modelCachePath(home), "utf8"));
23
+ const savedAt = Date.parse(parsed.saved_at);
24
+ if (!Number.isFinite(savedAt) || now() - savedAt > maxAgeMs) return null;
25
+ if (!modelData(parsed.models)) return null;
26
+ return parsed.models;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function saveModelCache(models, { home = os.homedir() } = {}) {
33
+ if (!modelData(models)) return false;
34
+ const filePath = modelCachePath(home);
35
+ const tempPath = `${filePath}.${process.pid}.tmp`;
36
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
37
+ try {
38
+ fs.writeFileSync(tempPath, `${JSON.stringify({ saved_at: new Date().toISOString(), models })}\n`, { mode: 0o600 });
39
+ fs.renameSync(tempPath, filePath);
40
+ fs.chmodSync(filePath, 0o600);
41
+ return true;
42
+ } finally {
43
+ try { fs.unlinkSync(tempPath); } catch (e) { if (e?.code !== "ENOENT") throw e; }
44
+ }
45
+ }
@@ -1,10 +1,19 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { localPackageVersion } from "./version.mjs";
3
+
1
4
  export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
5
+ export const ADAPTER_PROTOCOL_VERSION = 2;
6
+ export const ADAPTER_VERSION = localPackageVersion();
7
+ const ADAPTER_INSTANCE_ID = randomUUID();
2
8
 
3
9
  export function adapterHealthPayload() {
4
10
  return {
5
11
  ok: true,
6
12
  name: "codex-copilot-dx",
7
13
  pid: process.pid,
14
+ version: ADAPTER_VERSION,
15
+ protocol_version: ADAPTER_PROTOCOL_VERSION,
16
+ instance_id: ADAPTER_INSTANCE_ID,
8
17
  };
9
18
  }
10
19
 
@@ -25,6 +34,8 @@ export async function checkRunningAdapter({
25
34
  port = 2026,
26
35
  fetchImpl = fetch,
27
36
  timeoutMs = 500,
37
+ expectedVersion = ADAPTER_VERSION,
38
+ expectedProtocolVersion = ADAPTER_PROTOCOL_VERSION,
28
39
  } = {}) {
29
40
  const baseUrl = adapterBaseUrl(host, port);
30
41
  const ctrl = new AbortController();
@@ -39,6 +50,17 @@ export async function checkRunningAdapter({
39
50
  if (data?.name !== "codex-copilot-dx" || data?.ok !== true) {
40
51
  return { ok: false, baseUrl, status: resp.status, data };
41
52
  }
53
+ if (data.version !== expectedVersion || data.protocol_version !== expectedProtocolVersion) {
54
+ return {
55
+ ok: false,
56
+ incompatible: true,
57
+ baseUrl,
58
+ status: resp.status,
59
+ data,
60
+ expectedVersion,
61
+ expectedProtocolVersion,
62
+ };
63
+ }
42
64
  return { ok: true, baseUrl, data };
43
65
  } catch (e) {
44
66
  return { ok: false, baseUrl, error: e };