codex-copilot-dx 0.4.23 → 0.4.26

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.
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ function existingMode(filePath, fallback) {
6
+ try {
7
+ return fs.statSync(filePath).mode & 0o777;
8
+ } catch (e) {
9
+ if (e?.code === "ENOENT") return fallback;
10
+ throw e;
11
+ }
12
+ }
13
+
14
+ function syncDirectory(directory) {
15
+ if (process.platform === "win32") return;
16
+ let fd;
17
+ try {
18
+ fd = fs.openSync(directory, "r");
19
+ fs.fsyncSync(fd);
20
+ } catch (e) {
21
+ if (!["EINVAL", "ENOTSUP", "EBADF"].includes(e?.code)) throw e;
22
+ } finally {
23
+ if (fd !== undefined) fs.closeSync(fd);
24
+ }
25
+ }
26
+
27
+ export function atomicWriteFileSync(filePath, data, { mode = 0o600 } = {}) {
28
+ const directory = path.dirname(filePath);
29
+ const finalMode = existingMode(filePath, mode);
30
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
31
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
32
+
33
+ let fd;
34
+ try {
35
+ fd = fs.openSync(tempPath, "wx", finalMode);
36
+ fs.writeFileSync(fd, data);
37
+ fs.fsyncSync(fd);
38
+ fs.closeSync(fd);
39
+ fd = undefined;
40
+ fs.renameSync(tempPath, filePath);
41
+ fs.chmodSync(filePath, finalMode);
42
+ syncDirectory(directory);
43
+ } finally {
44
+ if (fd !== undefined) fs.closeSync(fd);
45
+ try {
46
+ fs.unlinkSync(tempPath);
47
+ } catch (e) {
48
+ if (e?.code !== "ENOENT") throw e;
49
+ }
50
+ }
51
+ }
52
+
53
+ export function atomicWriteFileIfChangedSync(filePath, data, options = {}) {
54
+ try {
55
+ const current = fs.readFileSync(filePath);
56
+ const next = Buffer.isBuffer(data) ? data : Buffer.from(data);
57
+ if (current.equals(next)) return false;
58
+ } catch (e) {
59
+ if (e?.code !== "ENOENT") throw e;
60
+ }
61
+ atomicWriteFileSync(filePath, data, options);
62
+ return true;
63
+ }
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
  }
@@ -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 { status } from "./status.mjs";
5
+ import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
6
 
6
7
  const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
7
8
 
@@ -23,20 +24,19 @@ export function computeUpdatedSettings(settings, port) {
23
24
  }
24
25
 
25
26
  // Point Claude Code at the adapter by updating ~/.claude/settings.json.
26
- export function ensureClaudeConfig(port = 2026) {
27
+ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
27
28
  const target = `http://127.0.0.1:${port}`;
28
29
 
29
- if (!fs.existsSync(SETTINGS_PATH)) {
30
- fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
30
+ if (!fs.existsSync(filePath)) {
31
31
  const { json } = computeUpdatedSettings({}, port);
32
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(json, null, 2) + "\n");
32
+ atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
33
33
  console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
34
34
  return;
35
35
  }
36
36
 
37
37
  let raw, settings;
38
38
  try {
39
- raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
39
+ raw = fs.readFileSync(filePath, "utf-8");
40
40
  settings = JSON.parse(raw);
41
41
  } catch (e) {
42
42
  console.log(status("warn", `Could not parse ~/.claude/settings.json: ${e.message}`));
@@ -50,7 +50,7 @@ export function ensureClaudeConfig(port = 2026) {
50
50
  return;
51
51
  }
52
52
 
53
- fs.writeFileSync(`${SETTINGS_PATH}.bak`, raw);
54
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(json, null, 2) + "\n");
53
+ atomicWriteFileIfChangedSync(`${filePath}.bak`, raw);
54
+ atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
55
55
  console.log(status("ok", `Configured Claude Code ANTHROPIC_BASE_URL="${target}" and backed up settings.json.bak`));
56
56
  }
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { claudeDesktopModelIds } from "./models.mjs";
6
6
  import { status } from "./status.mjs";
7
+ import { atomicWriteFileIfChangedSync, atomicWriteFileSync } from "./atomic-file.mjs";
7
8
 
8
9
  const CONFIG_FILE = "claude_desktop_config.json";
9
10
  const CONFIG_LIBRARY_DIR = "configLibrary";
@@ -127,8 +128,7 @@ function readJsonFile(filePath, fallback = {}) {
127
128
  }
128
129
 
129
130
  function writeJsonFile(filePath, data) {
130
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
131
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
131
+ return atomicWriteFileIfChangedSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
132
132
  }
133
133
 
134
134
  function snapshotFiles(files) {
@@ -148,8 +148,7 @@ function restoreSnapshots(snapshots) {
148
148
  fs.rmSync(snapshot.filePath, { force: true });
149
149
  continue;
150
150
  }
151
- fs.mkdirSync(path.dirname(snapshot.filePath), { recursive: true });
152
- fs.writeFileSync(snapshot.filePath, snapshot.data, { mode: 0o600 });
151
+ atomicWriteFileSync(snapshot.filePath, snapshot.data, { mode: 0o600 });
153
152
  }
154
153
  }
155
154
 
@@ -0,0 +1,69 @@
1
+ const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
+ const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
+
4
+ function unexpectedArgs(args) {
5
+ throw new Error(`Unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`);
6
+ }
7
+
8
+ export function parseCliArgs(args = []) {
9
+ const [command, ...rest] = args;
10
+ if (!command) return { command: "start", configureClaudeDesktop: false, online: false };
11
+ if (HELP_COMMANDS.has(command)) {
12
+ if (rest.length) unexpectedArgs(rest);
13
+ return { command: "help", configureClaudeDesktop: false, online: false };
14
+ }
15
+ if (VERSION_COMMANDS.has(command)) {
16
+ if (rest.length) unexpectedArgs(rest);
17
+ return { command: "version", configureClaudeDesktop: false, online: false };
18
+ }
19
+ if (command === "usage") {
20
+ if (rest.length) unexpectedArgs(rest);
21
+ return { command: "usage", configureClaudeDesktop: false, online: false };
22
+ }
23
+ if (command === "doctor" || command === "--doctor") {
24
+ const invalid = rest.filter((arg) => arg !== "--online");
25
+ if (invalid.length) unexpectedArgs(invalid);
26
+ if (rest.filter((arg) => arg === "--online").length > 1) unexpectedArgs(["--online"]);
27
+ return { command: "doctor", configureClaudeDesktop: false, online: rest.includes("--online") };
28
+ }
29
+ if (command === "--configure-claude-desktop") {
30
+ if (rest.length) unexpectedArgs(rest);
31
+ return { command: "start", configureClaudeDesktop: true, online: false };
32
+ }
33
+ throw new Error(`Unknown command or option: ${command}`);
34
+ }
35
+
36
+ function integerEnv(env, name, fallback, { min, max = Number.MAX_SAFE_INTEGER }) {
37
+ const raw = env[name];
38
+ if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
39
+ const text = String(raw).trim();
40
+ if (!/^\d+$/.test(text)) throw new Error(`${name} must be an integer between ${min} and ${max}`);
41
+ const value = Number(text);
42
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
43
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ export function parseRuntimeOptions(env = process.env) {
49
+ return {
50
+ adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
51
+ adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
52
+ modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
53
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
54
+ modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 30 * 60 * 1000, { min: 0 }),
55
+ upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
56
+ streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
57
+ streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
58
+ shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1 }),
59
+ };
60
+ }
61
+
62
+ export function cliHelp() {
63
+ return `Usage:
64
+ codex-copilot-dx [--configure-claude-desktop]
65
+ codex-copilot-dx doctor [--online]
66
+ codex-copilot-dx usage
67
+ codex-copilot-dx --version
68
+ codex-copilot-dx --help`;
69
+ }
package/src/config.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 { status } from "./status.mjs";
5
+ import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
6
 
6
7
  const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
7
8
 
@@ -74,20 +75,23 @@ OPENAI_API_KEY = "dummy"
74
75
  `;
75
76
  }
76
77
 
77
- export function ensureCodexConfig(adapterPort = 2026) {
78
+ export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
78
79
  const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
79
80
 
80
- if (!fs.existsSync(CONFIG_PATH)) {
81
+ if (!fs.existsSync(filePath)) {
81
82
  // Codex config does not exist yet; create the local proxy defaults.
82
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
83
- fs.writeFileSync(CONFIG_PATH, initialCodexConfig(adapterPort));
83
+ atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
84
84
  console.log(status("ok", "Created ~/.codex/config.toml"));
85
85
  return;
86
86
  }
87
87
 
88
- let content = fs.readFileSync(CONFIG_PATH, "utf-8");
88
+ const content = fs.readFileSync(filePath, "utf-8");
89
89
  const updated = computeUpdatedCodexConfig(content, adapterPort);
90
90
 
91
- fs.writeFileSync(CONFIG_PATH, updated.content);
91
+ if (!updated.changed) {
92
+ console.log(status("ok", `Codex already points to ${baseUrl}`));
93
+ return;
94
+ }
95
+ atomicWriteFileIfChangedSync(filePath, updated.content);
92
96
  console.log(status("ok", `Configured Codex base URL: ${baseUrl}`));
93
97
  }