codex-copilot-dx 0.4.15 → 0.4.17

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 CHANGED
@@ -27,7 +27,7 @@ npx codex-copilot-dx@latest
27
27
  > Tip: the `@latest` suffix forces `npx` to fetch the newest release instead of using a stale cached copy.
28
28
 
29
29
  On first run, it will:
30
- 1. Authenticate with GitHub via device flow (if needed)
30
+ 1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
31
31
  2. Print the local package version and warn when a newer npm release is available
32
32
  3. Start the adapter on loopback (`127.0.0.1:2026`)
33
33
  4. Configure Codex (`~/.codex/config.toml`) to use the adapter, including stale shell env base URLs if present
@@ -36,6 +36,8 @@ On first run, it will:
36
36
 
37
37
  Claude Code picks up the new `ANTHROPIC_BASE_URL` on its next launch.
38
38
 
39
+ If an existing `codex-copilot-dx` adapter is already running on the configured host and port, a second launch reuses it instead of starting another proxy. The second launch still refreshes Codex and Claude Code config, then exits.
40
+
39
41
  Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `codex-copilot-dx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
40
42
 
41
43
  ### Diagnostics
@@ -48,7 +50,9 @@ codex-copilot-dx doctor
48
50
 
49
51
  The doctor checks the GitHub token, Codex config, Claude Code settings, Claude App gateway profile, and whether the local adapter port is listening.
50
52
 
51
- If Copilot token refresh fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
53
+ When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. If a saved token later fails with `401` or `403`, the adapter repeats that discovery and retries once before asking you to reauthenticate.
54
+
55
+ If Copilot token refresh still fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
52
56
 
53
57
  ```bash
54
58
  rm ~/.local/share/copilot-api/github_token
@@ -71,6 +75,8 @@ CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
71
75
 
72
76
  This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Restart Claude App after running the command.
73
77
 
78
+ When reusing an already-running adapter, Claude App profile updates require `CCDX_CLAUDE_DESKTOP_API_KEY` or `CCDX_PROXY_API_KEY` so the profile key matches the running process. Otherwise the existing adapter is left untouched.
79
+
74
80
  ## Configuration
75
81
 
76
82
  Environment variables:
@@ -90,6 +96,13 @@ Environment variables:
90
96
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
91
97
  | `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
92
98
  | `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
99
+ | `CCDX_GITHUB_TOKEN` | unset | Explicit GitHub Copilot OAuth token to validate and import before device login |
100
+ | `CCDX_GITHUB_TOKEN_PATH` | unset | Explicit file containing a GitHub Copilot OAuth token to validate and import before device login |
101
+ | `CCDX_GITHUB_TOKEN_PATHS` | unset | Multiple token files separated by the platform path delimiter (`:` on macOS/Linux, `;` on Windows) |
102
+ | `CCDX_DISABLE_TOKEN_DISCOVERY` | unset | Set to `1` to skip local token discovery and go straight to the saved token or device flow |
103
+ | `CCDX_TOKEN_LOCK_TIMEOUT_MS` | `600000` | Maximum time to wait for another local process to finish GitHub token login/import |
104
+ | `CCDX_TOKEN_LOCK_STALE_MS` | `900000` | Age after which a stale GitHub token lock file can be removed |
105
+ | `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
93
106
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
94
107
  | `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
95
108
 
package/bin/cli.mjs CHANGED
@@ -11,10 +11,12 @@ import { status } from "../src/status.mjs";
11
11
  import { printUsageSummary } from "../src/usage.mjs";
12
12
  import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
13
13
  import { runDoctor } from "../src/doctor.mjs";
14
+ import { checkRunningAdapter } from "../src/running-adapter.mjs";
14
15
 
15
16
  const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
16
17
  const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
17
18
  const MODEL_REFRESH_TIMEOUT_MS = parseInt(process.env.CCDX_MODEL_REFRESH_TIMEOUT_MS || "5000", 10);
19
+ const EXISTING_ADAPTER_TIMEOUT_MS = parseInt(process.env.CCDX_EXISTING_ADAPTER_TIMEOUT_MS || "500", 10);
18
20
  const LOCAL_VERSION = localPackageVersion();
19
21
  const command = process.argv[2];
20
22
  const CONFIGURE_CLAUDE_DESKTOP = command === "--configure-claude-desktop" || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
@@ -56,6 +58,49 @@ async function refreshClaudeDesktopModelDefs() {
56
58
  }
57
59
  }
58
60
 
61
+ function currentClaudeDesktopApiKey() {
62
+ return process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "";
63
+ }
64
+
65
+ async function reuseRunningAdapterIfAvailable() {
66
+ const running = await checkRunningAdapter({
67
+ port: ADAPTER_PORT,
68
+ host: ADAPTER_HOST,
69
+ timeoutMs: EXISTING_ADAPTER_TIMEOUT_MS,
70
+ });
71
+ if (!running.ok) return false;
72
+
73
+ console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
74
+ ensureCodexConfig(ADAPTER_PORT);
75
+ ensureClaudeConfig(ADAPTER_PORT);
76
+
77
+ if (CONFIGURE_CLAUDE_DESKTOP) {
78
+ const claudeDesktopApiKey = currentClaudeDesktopApiKey();
79
+ if (claudeDesktopApiKey) {
80
+ const result = applyClaudeDesktopConfig({
81
+ port: ADAPTER_PORT,
82
+ host: ADAPTER_HOST,
83
+ gatewayApiKey: claudeDesktopApiKey,
84
+ modelIds: claudeDesktopModelIds(process.env),
85
+ });
86
+ console.log(status("ok", `Configured Claude App gateway profile at ${result.baseUrl}`));
87
+ console.log(formatClaudeDesktopApplyResult(result));
88
+ } else {
89
+ console.log(status("warn", "Existing adapter is running; skip Claude App profile update unless CCDX_CLAUDE_DESKTOP_API_KEY or CCDX_PROXY_API_KEY is set"));
90
+ }
91
+ } else {
92
+ console.log(status("ok", "Claude App support available with --configure-claude-desktop"));
93
+ }
94
+
95
+ openCodex();
96
+ console.log(`
97
+ ${status("ok", "Ready, using the existing codex-copilot-dx adapter")}
98
+
99
+ Adapter: ${running.baseUrl}
100
+ `);
101
+ return true;
102
+ }
103
+
59
104
  if (command === "--version" || command === "-v" || command === "version") {
60
105
  console.log(`codex-copilot-dx v${LOCAL_VERSION}`);
61
106
  process.exit(0);
@@ -83,6 +128,8 @@ checkForUpdate({ currentVersion: LOCAL_VERSION }).then(({ latestVersion, updateA
83
128
  });
84
129
 
85
130
  try {
131
+ if (await reuseRunningAdapterIfAvailable()) process.exit(0);
132
+
86
133
  // Ensure GitHub login, using device flow if no token exists.
87
134
  await ensureAuth();
88
135
 
@@ -95,8 +142,8 @@ try {
95
142
  }
96
143
 
97
144
  const claudeDesktopApiKey = CONFIGURE_CLAUDE_DESKTOP
98
- ? (process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || generatedClaudeDesktopApiKey())
99
- : (process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "");
145
+ ? (currentClaudeDesktopApiKey() || generatedClaudeDesktopApiKey())
146
+ : currentClaudeDesktopApiKey();
100
147
 
101
148
  // Start the in-process adapter.
102
149
  await startAdapter(ADAPTER_PORT, ADAPTER_HOST, { claudeDesktopApiKey, claudeDesktopModelDefs });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adapter.mjs CHANGED
@@ -8,6 +8,7 @@ import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens
8
8
  import { claudeDesktopModelsResponse, resolveAnthropicModel } from "./models.mjs";
9
9
  import { status } from "./status.mjs";
10
10
  import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
11
+ import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
11
12
 
12
13
  // Models that only support Responses API (not chat/completions)
13
14
  const RESPONSES_ONLY = new Set([
@@ -574,6 +575,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
574
575
  const server = http.createServer((req, res) => {
575
576
  const pathname = requestPath(req.url);
576
577
 
578
+ if (req.method === "GET" && pathname === ADAPTER_HEALTH_PATH) {
579
+ res.writeHead(200, { "Content-Type": "application/json" });
580
+ res.end(JSON.stringify(adapterHealthPayload()));
581
+ return;
582
+ }
583
+
577
584
  if (req.method === "POST" && pathname === "/v1/responses") {
578
585
  (async () => {
579
586
  const abort = createRequestAbort(req, res);
package/src/auth.mjs CHANGED
@@ -3,14 +3,25 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn } from "node:child_process";
5
5
  import { status } from "./status.mjs";
6
+ import { withFileLock } from "./lock.mjs";
6
7
 
7
8
  const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Public GitHub Copilot client ID.
8
9
  const SCOPE = "read:user";
10
+ const GITHUB_API = "https://api.github.com";
11
+ const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
12
+ const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
13
+ const MAX_AUTH_JSON_BYTES = 1024 * 1024;
14
+ const DEFAULT_TOKEN_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
15
+ const DEFAULT_TOKEN_LOCK_STALE_MS = 15 * 60 * 1000;
9
16
 
10
17
  export function githubTokenPath(home = os.homedir()) {
11
18
  return path.join(home, ".local", "share", "copilot-api", "github_token");
12
19
  }
13
20
 
21
+ export function githubTokenLockPath(home = os.homedir()) {
22
+ return `${githubTokenPath(home)}.lock`;
23
+ }
24
+
14
25
  export function githubReauthMessage(reason, home = os.homedir()) {
15
26
  const tokenPath = githubTokenPath(home);
16
27
  return `${reason}
@@ -31,6 +42,300 @@ export function interpretPoll(data) {
31
42
 
32
43
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
33
44
 
45
+ function expandHome(filePath, home = os.homedir()) {
46
+ if (!filePath) return "";
47
+ if (filePath === "~") return home;
48
+ if (filePath.startsWith("~/")) return path.join(home, filePath.slice(2));
49
+ return filePath;
50
+ }
51
+
52
+ function isTokenDiscoveryDisabled(env = process.env) {
53
+ return DISABLE_TOKEN_DISCOVERY_VALUES.has(String(env.CCDX_DISABLE_TOKEN_DISCOVERY || "").toLowerCase());
54
+ }
55
+
56
+ function positiveInt(value, fallback) {
57
+ const n = Number.parseInt(value, 10);
58
+ return Number.isFinite(n) && n > 0 ? n : fallback;
59
+ }
60
+
61
+ function tokenLockOptions(env = process.env) {
62
+ return {
63
+ timeoutMs: positiveInt(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, DEFAULT_TOKEN_LOCK_TIMEOUT_MS),
64
+ staleMs: positiveInt(env.CCDX_TOKEN_LOCK_STALE_MS, DEFAULT_TOKEN_LOCK_STALE_MS),
65
+ };
66
+ }
67
+
68
+ function splitPathList(value) {
69
+ return String(value || "")
70
+ .split(path.delimiter)
71
+ .map((item) => item.trim())
72
+ .filter(Boolean);
73
+ }
74
+
75
+ function localAuthRoots(home, env = process.env) {
76
+ const roots = [
77
+ path.join(home, "Library", "Application Support"),
78
+ env.APPDATA ? expandHome(env.APPDATA, home) : "",
79
+ env.XDG_CONFIG_HOME ? expandHome(env.XDG_CONFIG_HOME, home) : path.join(home, ".config"),
80
+ ].filter(Boolean);
81
+ return [...new Set(roots)];
82
+ }
83
+
84
+ function addAuthJsonSource(sources, seen, filePath) {
85
+ if (seen.has(filePath)) return;
86
+ if (!fs.existsSync(filePath)) return;
87
+ seen.add(filePath);
88
+ sources.push({ type: "auth-json", path: filePath });
89
+ }
90
+
91
+ function localAuthJsonSources(home, env = process.env) {
92
+ const sources = [];
93
+ const seen = new Set();
94
+
95
+ for (const root of localAuthRoots(home, env)) {
96
+ let appDirs;
97
+ try {
98
+ appDirs = fs.readdirSync(root, { withFileTypes: true });
99
+ } catch {
100
+ continue;
101
+ }
102
+
103
+ for (const appDirent of appDirs) {
104
+ if (!appDirent.isDirectory()) continue;
105
+ const appDir = path.join(root, appDirent.name);
106
+ addAuthJsonSource(sources, seen, path.join(appDir, "auth.json"));
107
+
108
+ const profilesDir = path.join(appDir, "profiles");
109
+ let profileDirs;
110
+ try {
111
+ profileDirs = fs.readdirSync(profilesDir, { withFileTypes: true });
112
+ } catch {
113
+ continue;
114
+ }
115
+
116
+ for (const profileDirent of profileDirs) {
117
+ if (!profileDirent.isDirectory()) continue;
118
+ addAuthJsonSource(sources, seen, path.join(profilesDir, profileDirent.name, "auth.json"));
119
+ }
120
+ }
121
+ }
122
+
123
+ return sources;
124
+ }
125
+
126
+ function explicitTokenFileSources(home, env = process.env) {
127
+ const paths = [];
128
+ if (typeof env.CCDX_GITHUB_TOKEN_PATH === "string" && env.CCDX_GITHUB_TOKEN_PATH.trim()) {
129
+ paths.push(env.CCDX_GITHUB_TOKEN_PATH.trim());
130
+ }
131
+ paths.push(...splitPathList(env.CCDX_GITHUB_TOKEN_PATHS));
132
+
133
+ const seen = new Set();
134
+ return paths
135
+ .map((filePath) => expandHome(filePath, home))
136
+ .filter((filePath) => {
137
+ if (seen.has(filePath)) return false;
138
+ seen.add(filePath);
139
+ return true;
140
+ })
141
+ .map((filePath) => ({
142
+ type: "token-file",
143
+ name: "configured token file",
144
+ path: filePath,
145
+ }));
146
+ }
147
+
148
+ function looksLikeCopilotAuthJson(json) {
149
+ if (!json || typeof json !== "object") return false;
150
+ if (json.ghcAuth || json.gitHubTokens || json.githubToken || json.githubCopilot || json.copilot) return true;
151
+ return typeof json.access_token === "string" && Boolean(json.copilotAccess || json.copilotToken || json.copilot_token);
152
+ }
153
+
154
+ function readSmallJson(filePath) {
155
+ const stat = fs.statSync(filePath);
156
+ if (!stat.isFile() || stat.size > MAX_AUTH_JSON_BYTES) return null;
157
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
158
+ }
159
+
160
+ export function githubTokenSources({
161
+ home = os.homedir(),
162
+ env = process.env,
163
+ } = {}) {
164
+ if (isTokenDiscoveryDisabled(env)) return [];
165
+ const sources = [];
166
+ if (typeof env.CCDX_GITHUB_TOKEN === "string" && env.CCDX_GITHUB_TOKEN.trim()) {
167
+ sources.push({ type: "env", name: "CCDX_GITHUB_TOKEN", token: env.CCDX_GITHUB_TOKEN.trim() });
168
+ }
169
+ sources.push(...explicitTokenFileSources(home, env));
170
+ sources.push(...localAuthJsonSources(home, env));
171
+ return sources;
172
+ }
173
+
174
+ export function sourceDescription(source) {
175
+ if (!source) return "unknown source";
176
+ if (source.type === "env") return source.name;
177
+ if (source.type === "token-file") return `${source.name} (${source.path})`;
178
+ if (source.type === "auth-json") return `local auth file (${source.path})`;
179
+ return source.path || source.name || "unknown source";
180
+ }
181
+
182
+ export function extractGithubTokenFromAuthJson(json) {
183
+ const candidates = [
184
+ json?.ghcAuth?.gitHubTokens?.access_token,
185
+ json?.ghcAuth?.gitHubTokens?.accessToken,
186
+ json?.ghcAuth?.githubToken,
187
+ json?.gitHubTokens?.access_token,
188
+ json?.gitHubTokens?.accessToken,
189
+ json?.githubToken,
190
+ json?.access_token,
191
+ json?.accessToken,
192
+ ];
193
+ return candidates.find((value) => typeof value === "string" && value.trim())?.trim() || "";
194
+ }
195
+
196
+ export function readGithubTokenSource(source) {
197
+ if (source?.type === "env") return source.token || "";
198
+ if (source?.type === "token-file") return fs.readFileSync(source.path, "utf8").trim();
199
+ if (source?.type === "auth-json") {
200
+ const json = readSmallJson(source.path);
201
+ if (!looksLikeCopilotAuthJson(json)) return "";
202
+ return extractGithubTokenFromAuthJson(json);
203
+ }
204
+ return "";
205
+ }
206
+
207
+ function readSavedGithubToken(home = os.homedir()) {
208
+ const filePath = githubTokenPath(home);
209
+ if (!fs.existsSync(filePath)) return "";
210
+ return fs.readFileSync(filePath, "utf8").trim();
211
+ }
212
+
213
+ export async function validateGithubToken(token, {
214
+ fetchImpl = fetch,
215
+ signal,
216
+ } = {}) {
217
+ if (typeof token !== "string" || !token.trim()) {
218
+ return { ok: false, reason: "empty_token" };
219
+ }
220
+
221
+ const headers = { Authorization: `token ${token.trim()}`, Accept: "application/json" };
222
+ let userResp;
223
+ try {
224
+ userResp = await fetchImpl(`${GITHUB_API}/user`, { headers, signal });
225
+ } catch (e) {
226
+ return { ok: false, transient: true, reason: "github_user_request_failed", error: e };
227
+ }
228
+
229
+ if (!userResp.ok) {
230
+ return {
231
+ ok: false,
232
+ status: userResp.status,
233
+ reason: userResp.status === 401 || userResp.status === 403 ? "github_token_invalid" : "github_user_failed",
234
+ };
235
+ }
236
+
237
+ let userData = {};
238
+ try {
239
+ userData = await userResp.json();
240
+ } catch {}
241
+ const login = typeof userData.login === "string" ? userData.login : "";
242
+
243
+ let copilotResp;
244
+ try {
245
+ copilotResp = await fetchImpl(COPILOT_TOKEN_URL, { headers, signal });
246
+ } catch (e) {
247
+ return { ok: false, transient: true, reason: "copilot_token_request_failed", login, error: e };
248
+ }
249
+
250
+ if (!copilotResp.ok) {
251
+ return {
252
+ ok: false,
253
+ status: copilotResp.status,
254
+ reason: copilotResp.status === 401 || copilotResp.status === 403 ? "copilot_access_denied" : "copilot_token_failed",
255
+ login,
256
+ };
257
+ }
258
+
259
+ let copilotTokenData = {};
260
+ try {
261
+ copilotTokenData = await copilotResp.json();
262
+ } catch (e) {
263
+ return { ok: false, reason: "copilot_token_parse_failed", login, error: e };
264
+ }
265
+
266
+ if (!copilotTokenData.token) {
267
+ return { ok: false, reason: "copilot_token_missing", login };
268
+ }
269
+
270
+ return { ok: true, login, copilotTokenData };
271
+ }
272
+
273
+ export async function discoverGithubToken({
274
+ home = os.homedir(),
275
+ env = process.env,
276
+ fetchImpl = fetch,
277
+ signal,
278
+ excludeTokens = [],
279
+ } = {}) {
280
+ const excluded = new Set(excludeTokens);
281
+ const seen = new Set(excluded);
282
+ const failures = [];
283
+
284
+ for (const source of githubTokenSources({ home, env })) {
285
+ let token = "";
286
+ try {
287
+ token = readGithubTokenSource(source).trim();
288
+ } catch (e) {
289
+ failures.push({ source, reason: "read_failed", error: e });
290
+ continue;
291
+ }
292
+
293
+ if (!token || seen.has(token)) continue;
294
+ seen.add(token);
295
+
296
+ const validation = await validateGithubToken(token, { fetchImpl, signal });
297
+ if (validation.ok) return { ok: true, token, source, validation };
298
+ failures.push({ source, validation });
299
+ }
300
+
301
+ return { ok: false, failures };
302
+ }
303
+
304
+ export async function importDiscoveredGithubToken({
305
+ home = os.homedir(),
306
+ env = process.env,
307
+ fetchImpl = fetch,
308
+ signal,
309
+ excludeTokens = [],
310
+ log = console.log,
311
+ lock = true,
312
+ validateSavedToken = false,
313
+ } = {}) {
314
+ const run = async () => {
315
+ const excluded = new Set(excludeTokens);
316
+ const savedToken = readSavedGithubToken(home);
317
+ if (savedToken && !excluded.has(savedToken)) {
318
+ if (!validateSavedToken) {
319
+ return { ok: true, token: savedToken, source: { type: "saved-token" }, imported: false };
320
+ }
321
+ const validation = await validateGithubToken(savedToken, { fetchImpl, signal });
322
+ if (validation.ok) {
323
+ return { ok: true, token: savedToken, source: { type: "saved-token" }, validation, imported: false };
324
+ }
325
+ }
326
+
327
+ const discovered = await discoverGithubToken({ home, env, fetchImpl, signal, excludeTokens });
328
+ if (!discovered.ok) return null;
329
+ writeToken(discovered.token, home);
330
+ const login = discovered.validation.login ? ` for ${discovered.validation.login}` : "";
331
+ log(status("ok", `Imported GitHub token from ${sourceDescription(discovered.source)}${login}`));
332
+ return { ...discovered, imported: true };
333
+ };
334
+
335
+ if (!lock) return run();
336
+ return withFileLock(githubTokenLockPath(home), run, tokenLockOptions(env));
337
+ }
338
+
34
339
  // On macOS, copy the user code and open the verification page. Fail quietly.
35
340
  function openAndCopy(userCode, verificationUri) {
36
341
  if (process.platform !== "darwin") return;
@@ -50,59 +355,86 @@ function openAndCopy(userCode, verificationUri) {
50
355
  } catch {}
51
356
  }
52
357
 
53
- export async function ensureAuth() {
54
- const GITHUB_TOKEN_PATH = githubTokenPath();
358
+ export async function ensureAuth({
359
+ home = os.homedir(),
360
+ env = process.env,
361
+ fetchImpl = fetch,
362
+ signal,
363
+ log = console.log,
364
+ openAndCopyFn = openAndCopy,
365
+ } = {}) {
366
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
55
367
  if (fs.existsSync(GITHUB_TOKEN_PATH)) {
56
- console.log(status("ok", "GitHub token found"));
57
- return;
368
+ const existing = fs.readFileSync(GITHUB_TOKEN_PATH, "utf8").trim();
369
+ if (existing) {
370
+ log(status("ok", "GitHub token found"));
371
+ return;
372
+ }
373
+ log(status("warn", "GitHub token file is empty"));
58
374
  }
59
- console.log(status("wait", "No GitHub token found. Starting device login..."));
60
-
61
- // Request a device code.
62
- const codeResp = await fetch("https://github.com/login/device/code", {
63
- method: "POST",
64
- headers: { "Content-Type": "application/json", Accept: "application/json" },
65
- body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
66
- });
67
- if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
68
- const { device_code, user_code, verification_uri, interval } = await codeResp.json();
69
-
70
- // Prompt the user.
71
- console.log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
72
- openAndCopy(user_code, verification_uri);
73
-
74
- // Poll until GitHub completes the device flow.
75
- let waitMs = (interval || 5) * 1000;
76
- while (true) {
77
- await sleep(waitMs);
78
- const pollResp = await fetch("https://github.com/login/oauth/access_token", {
375
+
376
+ await withFileLock(githubTokenLockPath(home), async () => {
377
+ const savedToken = readSavedGithubToken(home);
378
+ if (savedToken) {
379
+ log(status("ok", "GitHub token found"));
380
+ return;
381
+ }
382
+
383
+ const imported = await importDiscoveredGithubToken({ home, env, fetchImpl, signal, log, lock: false });
384
+ if (imported) return;
385
+
386
+ log(status("wait", "No usable GitHub token found. Starting device login..."));
387
+
388
+ // Request a device code while holding the auth lock so concurrent starts do not
389
+ // trigger multiple browser/device-flow sessions for the same local token.
390
+ const codeResp = await fetchImpl("https://github.com/login/device/code", {
79
391
  method: "POST",
80
392
  headers: { "Content-Type": "application/json", Accept: "application/json" },
81
- body: JSON.stringify({
82
- client_id: CLIENT_ID,
83
- device_code,
84
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
85
- }),
393
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
394
+ signal,
86
395
  });
87
- if (!pollResp.ok) {
88
- // Treat transient network/server errors as pending and retry.
89
- continue;
90
- }
91
- const data = await pollResp.json();
92
- const r = interpretPoll(data);
93
- if (r.state === "done") {
94
- writeToken(r.token);
95
- console.log(status("ok", "Login successful"));
96
- return;
396
+ if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
397
+ const { device_code, user_code, verification_uri, interval } = await codeResp.json();
398
+
399
+ // Prompt the user.
400
+ log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
401
+ openAndCopyFn(user_code, verification_uri);
402
+
403
+ // Poll until GitHub completes the device flow.
404
+ let waitMs = (interval || 5) * 1000;
405
+ while (true) {
406
+ await sleep(waitMs);
407
+ const pollResp = await fetchImpl("https://github.com/login/oauth/access_token", {
408
+ method: "POST",
409
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
410
+ body: JSON.stringify({
411
+ client_id: CLIENT_ID,
412
+ device_code,
413
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
414
+ }),
415
+ signal,
416
+ });
417
+ if (!pollResp.ok) {
418
+ // Treat transient network/server errors as pending and retry.
419
+ continue;
420
+ }
421
+ const data = await pollResp.json();
422
+ const r = interpretPoll(data);
423
+ if (r.state === "done") {
424
+ writeToken(r.token, home);
425
+ log(status("ok", "Login successful"));
426
+ return;
427
+ }
428
+ if (r.state === "slow") { waitMs += 5000; continue; }
429
+ if (r.state === "fail") throw new Error(`Login failed: ${r.error}`);
430
+ // wait: continue polling.
97
431
  }
98
- if (r.state === "slow") { waitMs += 5000; continue; }
99
- if (r.state === "fail") throw new Error(`Login failed: ${r.error}`);
100
- // wait: continue polling.
101
- }
432
+ }, tokenLockOptions(env));
102
433
  }
103
434
 
104
- function writeToken(token) {
105
- const GITHUB_TOKEN_PATH = githubTokenPath();
435
+ export function writeToken(token, home = os.homedir()) {
436
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
106
437
  fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
107
438
  fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
439
+ fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
108
440
  }
package/src/copilot.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { status } from "./status.mjs";
4
- import { githubReauthMessage, githubTokenPath } from "./auth.mjs";
5
+ import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
5
6
 
6
7
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
7
8
  let apiBase = DEFAULT_API_BASE;
@@ -245,9 +246,10 @@ export async function refreshVSCodeVersion() {
245
246
 
246
247
  let copilotToken = null;
247
248
  let copilotTokenExpiry = 0;
249
+ let copilotTokenRefresh = null;
248
250
 
249
- function getGithubToken() {
250
- const GITHUB_TOKEN_PATH = githubTokenPath();
251
+ function getGithubToken({ home = os.homedir() } = {}) {
252
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
251
253
  if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
252
254
  throw new Error("GitHub token not found. Run codex-copilot-dx again to log in.");
253
255
  }
@@ -256,20 +258,14 @@ function getGithubToken() {
256
258
  return token;
257
259
  }
258
260
 
259
- export async function getCopilotToken({ signal } = {}) {
260
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
261
- const ghToken = getGithubToken();
262
- const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
261
+ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
262
+ return fetchImpl(`${GITHUB_API}/copilot_internal/v2/token`, {
263
263
  headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
264
264
  signal,
265
265
  });
266
- if (!resp.ok) {
267
- if (resp.status === 401 || resp.status === 403) {
268
- throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
269
- }
270
- throw new Error(`Failed to get Copilot token: ${resp.status}`);
271
- }
272
- const data = await resp.json();
266
+ }
267
+
268
+ function cacheCopilotTokenData(data) {
273
269
  if (!data.token) throw new Error("Copilot token response missing token field");
274
270
  copilotToken = data.token;
275
271
  apiBase = parseApiBase(data);
@@ -280,6 +276,55 @@ export async function getCopilotToken({ signal } = {}) {
280
276
  return copilotToken;
281
277
  }
282
278
 
279
+ async function refreshCopilotToken({
280
+ signal,
281
+ home = os.homedir(),
282
+ env = process.env,
283
+ fetchImpl = fetch,
284
+ } = {}) {
285
+ const ghToken = getGithubToken({ home });
286
+ const resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
287
+ if (!resp.ok) {
288
+ if (resp.status === 401 || resp.status === 403) {
289
+ const imported = await importDiscoveredGithubToken({
290
+ home,
291
+ env,
292
+ fetchImpl,
293
+ signal,
294
+ excludeTokens: [ghToken],
295
+ validateSavedToken: true,
296
+ });
297
+ if (imported?.validation?.copilotTokenData?.token) {
298
+ return cacheCopilotTokenData(imported.validation.copilotTokenData);
299
+ }
300
+ throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
301
+ }
302
+ throw new Error(`Failed to get Copilot token: ${resp.status}`);
303
+ }
304
+ const data = await resp.json();
305
+ return cacheCopilotTokenData(data);
306
+ }
307
+
308
+ export async function getCopilotToken(options = {}) {
309
+ if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
310
+ const home = options.home || os.homedir();
311
+ const refreshKey = githubTokenPath(home);
312
+ if (copilotTokenRefresh?.key === refreshKey) return copilotTokenRefresh.promise;
313
+
314
+ const promise = refreshCopilotToken(options).finally(() => {
315
+ if (copilotTokenRefresh?.promise === promise) copilotTokenRefresh = null;
316
+ });
317
+ copilotTokenRefresh = { key: refreshKey, promise };
318
+ return promise;
319
+ }
320
+
321
+ export function resetCopilotTokenForTests() {
322
+ copilotToken = null;
323
+ copilotTokenExpiry = 0;
324
+ copilotTokenRefresh = null;
325
+ apiBase = DEFAULT_API_BASE;
326
+ }
327
+
283
328
  // chatReq is already converted by the adapter. The caller parses the raw Response.
284
329
  export async function chatCompletions(chatReq, { signal } = {}) {
285
330
  const token = await getCopilotToken({ signal });
package/src/lock.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+
8
+ function maybeRemoveStaleLock(lockPath, staleMs, nowMs) {
9
+ if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
10
+ try {
11
+ const stat = fs.statSync(lockPath);
12
+ if (nowMs - stat.mtimeMs <= staleMs) return false;
13
+ fs.unlinkSync(lockPath);
14
+ return true;
15
+ } catch (e) {
16
+ if (e?.code === "ENOENT") return true;
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export async function withFileLock(lockPath, fn, {
22
+ timeoutMs = 5000,
23
+ staleMs = 15 * 60 * 1000,
24
+ pollMs = 50,
25
+ now = Date.now,
26
+ } = {}) {
27
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
28
+ const started = now();
29
+ let fd = null;
30
+
31
+ while (fd === null) {
32
+ try {
33
+ fd = fs.openSync(lockPath, "wx", 0o600);
34
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
35
+ break;
36
+ } catch (e) {
37
+ if (e?.code !== "EEXIST") throw e;
38
+ const nowMs = now();
39
+ if (maybeRemoveStaleLock(lockPath, staleMs, nowMs)) continue;
40
+ if (nowMs - started >= timeoutMs) {
41
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
42
+ }
43
+ await sleep(Math.min(pollMs, Math.max(1, timeoutMs - (nowMs - started))));
44
+ }
45
+ }
46
+
47
+ try {
48
+ return await fn();
49
+ } finally {
50
+ try {
51
+ if (fd !== null) fs.closeSync(fd);
52
+ } finally {
53
+ try {
54
+ fs.unlinkSync(lockPath);
55
+ } catch (e) {
56
+ if (e?.code !== "ENOENT") throw e;
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,48 @@
1
+ export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
2
+
3
+ export function adapterHealthPayload() {
4
+ return {
5
+ ok: true,
6
+ name: "codex-copilot-dx",
7
+ pid: process.pid,
8
+ };
9
+ }
10
+
11
+ export function adapterProbeHost(host = "127.0.0.1") {
12
+ const normalized = String(host || "127.0.0.1").toLowerCase();
13
+ if (normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]") return "127.0.0.1";
14
+ return host || "127.0.0.1";
15
+ }
16
+
17
+ export function adapterBaseUrl(host = "127.0.0.1", port = 2026) {
18
+ const probeHost = String(adapterProbeHost(host));
19
+ const urlHost = probeHost.includes(":") && !probeHost.startsWith("[") ? `[${probeHost}]` : probeHost;
20
+ return `http://${urlHost}:${port}`;
21
+ }
22
+
23
+ export async function checkRunningAdapter({
24
+ host = "127.0.0.1",
25
+ port = 2026,
26
+ fetchImpl = fetch,
27
+ timeoutMs = 500,
28
+ } = {}) {
29
+ const baseUrl = adapterBaseUrl(host, port);
30
+ const ctrl = new AbortController();
31
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
32
+ try {
33
+ const resp = await fetchImpl(`${baseUrl}${ADAPTER_HEALTH_PATH}`, {
34
+ headers: { Accept: "application/json" },
35
+ signal: ctrl.signal,
36
+ });
37
+ if (!resp.ok) return { ok: false, baseUrl, status: resp.status };
38
+ const data = await resp.json();
39
+ if (data?.name !== "codex-copilot-dx" || data?.ok !== true) {
40
+ return { ok: false, baseUrl, status: resp.status, data };
41
+ }
42
+ return { ok: true, baseUrl, data };
43
+ } catch (e) {
44
+ return { ok: false, baseUrl, error: e };
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }