codex-copilot-dx 0.4.15 → 0.4.16

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
@@ -48,7 +48,9 @@ codex-copilot-dx doctor
48
48
 
49
49
  The doctor checks the GitHub token, Codex config, Claude Code settings, Claude App gateway profile, and whether the local adapter port is listening.
50
50
 
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:
51
+ 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.
52
+
53
+ 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
54
 
53
55
  ```bash
54
56
  rm ~/.local/share/copilot-api/github_token
@@ -90,6 +92,10 @@ Environment variables:
90
92
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
91
93
  | `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
94
  | `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
95
+ | `CCDX_GITHUB_TOKEN` | unset | Explicit GitHub Copilot OAuth token to validate and import before device login |
96
+ | `CCDX_GITHUB_TOKEN_PATH` | unset | Explicit file containing a GitHub Copilot OAuth token to validate and import before device login |
97
+ | `CCDX_GITHUB_TOKEN_PATHS` | unset | Multiple token files separated by the platform path delimiter (`:` on macOS/Linux, `;` on Windows) |
98
+ | `CCDX_DISABLE_TOKEN_DISCOVERY` | unset | Set to `1` to skip local token discovery and go straight to the saved token or device flow |
93
99
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
94
100
  | `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
95
101
 
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.16",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.mjs CHANGED
@@ -6,6 +6,10 @@ import { status } from "./status.mjs";
6
6
 
7
7
  const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Public GitHub Copilot client ID.
8
8
  const SCOPE = "read:user";
9
+ const GITHUB_API = "https://api.github.com";
10
+ const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
11
+ const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
12
+ const MAX_AUTH_JSON_BYTES = 1024 * 1024;
9
13
 
10
14
  export function githubTokenPath(home = os.homedir()) {
11
15
  return path.join(home, ".local", "share", "copilot-api", "github_token");
@@ -31,6 +35,263 @@ export function interpretPoll(data) {
31
35
 
32
36
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
33
37
 
38
+ function expandHome(filePath, home = os.homedir()) {
39
+ if (!filePath) return "";
40
+ if (filePath === "~") return home;
41
+ if (filePath.startsWith("~/")) return path.join(home, filePath.slice(2));
42
+ return filePath;
43
+ }
44
+
45
+ function isTokenDiscoveryDisabled(env = process.env) {
46
+ return DISABLE_TOKEN_DISCOVERY_VALUES.has(String(env.CCDX_DISABLE_TOKEN_DISCOVERY || "").toLowerCase());
47
+ }
48
+
49
+ function splitPathList(value) {
50
+ return String(value || "")
51
+ .split(path.delimiter)
52
+ .map((item) => item.trim())
53
+ .filter(Boolean);
54
+ }
55
+
56
+ function localAuthRoots(home, env = process.env) {
57
+ const roots = [
58
+ path.join(home, "Library", "Application Support"),
59
+ env.APPDATA ? expandHome(env.APPDATA, home) : "",
60
+ env.XDG_CONFIG_HOME ? expandHome(env.XDG_CONFIG_HOME, home) : path.join(home, ".config"),
61
+ ].filter(Boolean);
62
+ return [...new Set(roots)];
63
+ }
64
+
65
+ function addAuthJsonSource(sources, seen, filePath) {
66
+ if (seen.has(filePath)) return;
67
+ if (!fs.existsSync(filePath)) return;
68
+ seen.add(filePath);
69
+ sources.push({ type: "auth-json", path: filePath });
70
+ }
71
+
72
+ function localAuthJsonSources(home, env = process.env) {
73
+ const sources = [];
74
+ const seen = new Set();
75
+
76
+ for (const root of localAuthRoots(home, env)) {
77
+ let appDirs;
78
+ try {
79
+ appDirs = fs.readdirSync(root, { withFileTypes: true });
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ for (const appDirent of appDirs) {
85
+ if (!appDirent.isDirectory()) continue;
86
+ const appDir = path.join(root, appDirent.name);
87
+ addAuthJsonSource(sources, seen, path.join(appDir, "auth.json"));
88
+
89
+ const profilesDir = path.join(appDir, "profiles");
90
+ let profileDirs;
91
+ try {
92
+ profileDirs = fs.readdirSync(profilesDir, { withFileTypes: true });
93
+ } catch {
94
+ continue;
95
+ }
96
+
97
+ for (const profileDirent of profileDirs) {
98
+ if (!profileDirent.isDirectory()) continue;
99
+ addAuthJsonSource(sources, seen, path.join(profilesDir, profileDirent.name, "auth.json"));
100
+ }
101
+ }
102
+ }
103
+
104
+ return sources;
105
+ }
106
+
107
+ function explicitTokenFileSources(home, env = process.env) {
108
+ const paths = [];
109
+ if (typeof env.CCDX_GITHUB_TOKEN_PATH === "string" && env.CCDX_GITHUB_TOKEN_PATH.trim()) {
110
+ paths.push(env.CCDX_GITHUB_TOKEN_PATH.trim());
111
+ }
112
+ paths.push(...splitPathList(env.CCDX_GITHUB_TOKEN_PATHS));
113
+
114
+ const seen = new Set();
115
+ return paths
116
+ .map((filePath) => expandHome(filePath, home))
117
+ .filter((filePath) => {
118
+ if (seen.has(filePath)) return false;
119
+ seen.add(filePath);
120
+ return true;
121
+ })
122
+ .map((filePath) => ({
123
+ type: "token-file",
124
+ name: "configured token file",
125
+ path: filePath,
126
+ }));
127
+ }
128
+
129
+ function looksLikeCopilotAuthJson(json) {
130
+ if (!json || typeof json !== "object") return false;
131
+ if (json.ghcAuth || json.gitHubTokens || json.githubToken || json.githubCopilot || json.copilot) return true;
132
+ return typeof json.access_token === "string" && Boolean(json.copilotAccess || json.copilotToken || json.copilot_token);
133
+ }
134
+
135
+ function readSmallJson(filePath) {
136
+ const stat = fs.statSync(filePath);
137
+ if (!stat.isFile() || stat.size > MAX_AUTH_JSON_BYTES) return null;
138
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
139
+ }
140
+
141
+ export function githubTokenSources({
142
+ home = os.homedir(),
143
+ env = process.env,
144
+ } = {}) {
145
+ if (isTokenDiscoveryDisabled(env)) return [];
146
+ const sources = [];
147
+ if (typeof env.CCDX_GITHUB_TOKEN === "string" && env.CCDX_GITHUB_TOKEN.trim()) {
148
+ sources.push({ type: "env", name: "CCDX_GITHUB_TOKEN", token: env.CCDX_GITHUB_TOKEN.trim() });
149
+ }
150
+ sources.push(...explicitTokenFileSources(home, env));
151
+ sources.push(...localAuthJsonSources(home, env));
152
+ return sources;
153
+ }
154
+
155
+ export function sourceDescription(source) {
156
+ if (!source) return "unknown source";
157
+ if (source.type === "env") return source.name;
158
+ if (source.type === "token-file") return `${source.name} (${source.path})`;
159
+ if (source.type === "auth-json") return `local auth file (${source.path})`;
160
+ return source.path || source.name || "unknown source";
161
+ }
162
+
163
+ export function extractGithubTokenFromAuthJson(json) {
164
+ const candidates = [
165
+ json?.ghcAuth?.gitHubTokens?.access_token,
166
+ json?.ghcAuth?.gitHubTokens?.accessToken,
167
+ json?.ghcAuth?.githubToken,
168
+ json?.gitHubTokens?.access_token,
169
+ json?.gitHubTokens?.accessToken,
170
+ json?.githubToken,
171
+ json?.access_token,
172
+ json?.accessToken,
173
+ ];
174
+ return candidates.find((value) => typeof value === "string" && value.trim())?.trim() || "";
175
+ }
176
+
177
+ export function readGithubTokenSource(source) {
178
+ if (source?.type === "env") return source.token || "";
179
+ if (source?.type === "token-file") return fs.readFileSync(source.path, "utf8").trim();
180
+ if (source?.type === "auth-json") {
181
+ const json = readSmallJson(source.path);
182
+ if (!looksLikeCopilotAuthJson(json)) return "";
183
+ return extractGithubTokenFromAuthJson(json);
184
+ }
185
+ return "";
186
+ }
187
+
188
+ export async function validateGithubToken(token, {
189
+ fetchImpl = fetch,
190
+ signal,
191
+ } = {}) {
192
+ if (typeof token !== "string" || !token.trim()) {
193
+ return { ok: false, reason: "empty_token" };
194
+ }
195
+
196
+ const headers = { Authorization: `token ${token.trim()}`, Accept: "application/json" };
197
+ let userResp;
198
+ try {
199
+ userResp = await fetchImpl(`${GITHUB_API}/user`, { headers, signal });
200
+ } catch (e) {
201
+ return { ok: false, transient: true, reason: "github_user_request_failed", error: e };
202
+ }
203
+
204
+ if (!userResp.ok) {
205
+ return {
206
+ ok: false,
207
+ status: userResp.status,
208
+ reason: userResp.status === 401 || userResp.status === 403 ? "github_token_invalid" : "github_user_failed",
209
+ };
210
+ }
211
+
212
+ let userData = {};
213
+ try {
214
+ userData = await userResp.json();
215
+ } catch {}
216
+ const login = typeof userData.login === "string" ? userData.login : "";
217
+
218
+ let copilotResp;
219
+ try {
220
+ copilotResp = await fetchImpl(COPILOT_TOKEN_URL, { headers, signal });
221
+ } catch (e) {
222
+ return { ok: false, transient: true, reason: "copilot_token_request_failed", login, error: e };
223
+ }
224
+
225
+ if (!copilotResp.ok) {
226
+ return {
227
+ ok: false,
228
+ status: copilotResp.status,
229
+ reason: copilotResp.status === 401 || copilotResp.status === 403 ? "copilot_access_denied" : "copilot_token_failed",
230
+ login,
231
+ };
232
+ }
233
+
234
+ let copilotTokenData = {};
235
+ try {
236
+ copilotTokenData = await copilotResp.json();
237
+ } catch (e) {
238
+ return { ok: false, reason: "copilot_token_parse_failed", login, error: e };
239
+ }
240
+
241
+ if (!copilotTokenData.token) {
242
+ return { ok: false, reason: "copilot_token_missing", login };
243
+ }
244
+
245
+ return { ok: true, login, copilotTokenData };
246
+ }
247
+
248
+ export async function discoverGithubToken({
249
+ home = os.homedir(),
250
+ env = process.env,
251
+ fetchImpl = fetch,
252
+ signal,
253
+ excludeTokens = [],
254
+ } = {}) {
255
+ const excluded = new Set(excludeTokens);
256
+ const seen = new Set(excluded);
257
+ const failures = [];
258
+
259
+ for (const source of githubTokenSources({ home, env })) {
260
+ let token = "";
261
+ try {
262
+ token = readGithubTokenSource(source).trim();
263
+ } catch (e) {
264
+ failures.push({ source, reason: "read_failed", error: e });
265
+ continue;
266
+ }
267
+
268
+ if (!token || seen.has(token)) continue;
269
+ seen.add(token);
270
+
271
+ const validation = await validateGithubToken(token, { fetchImpl, signal });
272
+ if (validation.ok) return { ok: true, token, source, validation };
273
+ failures.push({ source, validation });
274
+ }
275
+
276
+ return { ok: false, failures };
277
+ }
278
+
279
+ export async function importDiscoveredGithubToken({
280
+ home = os.homedir(),
281
+ env = process.env,
282
+ fetchImpl = fetch,
283
+ signal,
284
+ excludeTokens = [],
285
+ log = console.log,
286
+ } = {}) {
287
+ const discovered = await discoverGithubToken({ home, env, fetchImpl, signal, excludeTokens });
288
+ if (!discovered.ok) return null;
289
+ writeToken(discovered.token, home);
290
+ const login = discovered.validation.login ? ` for ${discovered.validation.login}` : "";
291
+ log(status("ok", `Imported GitHub token from ${sourceDescription(discovered.source)}${login}`));
292
+ return discovered;
293
+ }
294
+
34
295
  // On macOS, copy the user code and open the verification page. Fail quietly.
35
296
  function openAndCopy(userCode, verificationUri) {
36
297
  if (process.platform !== "darwin") return;
@@ -50,32 +311,48 @@ function openAndCopy(userCode, verificationUri) {
50
311
  } catch {}
51
312
  }
52
313
 
53
- export async function ensureAuth() {
54
- const GITHUB_TOKEN_PATH = githubTokenPath();
314
+ export async function ensureAuth({
315
+ home = os.homedir(),
316
+ env = process.env,
317
+ fetchImpl = fetch,
318
+ signal,
319
+ log = console.log,
320
+ openAndCopyFn = openAndCopy,
321
+ } = {}) {
322
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
55
323
  if (fs.existsSync(GITHUB_TOKEN_PATH)) {
56
- console.log(status("ok", "GitHub token found"));
57
- return;
324
+ const existing = fs.readFileSync(GITHUB_TOKEN_PATH, "utf8").trim();
325
+ if (existing) {
326
+ log(status("ok", "GitHub token found"));
327
+ return;
328
+ }
329
+ log(status("warn", "GitHub token file is empty"));
58
330
  }
59
- console.log(status("wait", "No GitHub token found. Starting device login..."));
331
+
332
+ const imported = await importDiscoveredGithubToken({ home, env, fetchImpl, signal, log });
333
+ if (imported) return;
334
+
335
+ log(status("wait", "No usable GitHub token found. Starting device login..."));
60
336
 
61
337
  // Request a device code.
62
- const codeResp = await fetch("https://github.com/login/device/code", {
338
+ const codeResp = await fetchImpl("https://github.com/login/device/code", {
63
339
  method: "POST",
64
340
  headers: { "Content-Type": "application/json", Accept: "application/json" },
65
341
  body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
342
+ signal,
66
343
  });
67
344
  if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
68
345
  const { device_code, user_code, verification_uri, interval } = await codeResp.json();
69
346
 
70
347
  // 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);
348
+ log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
349
+ openAndCopyFn(user_code, verification_uri);
73
350
 
74
351
  // Poll until GitHub completes the device flow.
75
352
  let waitMs = (interval || 5) * 1000;
76
353
  while (true) {
77
354
  await sleep(waitMs);
78
- const pollResp = await fetch("https://github.com/login/oauth/access_token", {
355
+ const pollResp = await fetchImpl("https://github.com/login/oauth/access_token", {
79
356
  method: "POST",
80
357
  headers: { "Content-Type": "application/json", Accept: "application/json" },
81
358
  body: JSON.stringify({
@@ -83,6 +360,7 @@ export async function ensureAuth() {
83
360
  device_code,
84
361
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
85
362
  }),
363
+ signal,
86
364
  });
87
365
  if (!pollResp.ok) {
88
366
  // Treat transient network/server errors as pending and retry.
@@ -91,8 +369,8 @@ export async function ensureAuth() {
91
369
  const data = await pollResp.json();
92
370
  const r = interpretPoll(data);
93
371
  if (r.state === "done") {
94
- writeToken(r.token);
95
- console.log(status("ok", "Login successful"));
372
+ writeToken(r.token, home);
373
+ log(status("ok", "Login successful"));
96
374
  return;
97
375
  }
98
376
  if (r.state === "slow") { waitMs += 5000; continue; }
@@ -101,8 +379,9 @@ export async function ensureAuth() {
101
379
  }
102
380
  }
103
381
 
104
- function writeToken(token) {
105
- const GITHUB_TOKEN_PATH = githubTokenPath();
382
+ export function writeToken(token, home = os.homedir()) {
383
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
106
384
  fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
107
385
  fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
386
+ fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
108
387
  }
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;
@@ -246,8 +247,8 @@ export async function refreshVSCodeVersion() {
246
247
  let copilotToken = null;
247
248
  let copilotTokenExpiry = 0;
248
249
 
249
- function getGithubToken() {
250
- const GITHUB_TOKEN_PATH = githubTokenPath();
250
+ function getGithubToken({ home = os.homedir() } = {}) {
251
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
251
252
  if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
252
253
  throw new Error("GitHub token not found. Run codex-copilot-dx again to log in.");
253
254
  }
@@ -256,20 +257,14 @@ function getGithubToken() {
256
257
  return token;
257
258
  }
258
259
 
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`, {
260
+ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
261
+ return fetchImpl(`${GITHUB_API}/copilot_internal/v2/token`, {
263
262
  headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
264
263
  signal,
265
264
  });
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();
265
+ }
266
+
267
+ function cacheCopilotTokenData(data) {
273
268
  if (!data.token) throw new Error("Copilot token response missing token field");
274
269
  copilotToken = data.token;
275
270
  apiBase = parseApiBase(data);
@@ -280,6 +275,35 @@ export async function getCopilotToken({ signal } = {}) {
280
275
  return copilotToken;
281
276
  }
282
277
 
278
+ export async function getCopilotToken({
279
+ signal,
280
+ home = os.homedir(),
281
+ env = process.env,
282
+ fetchImpl = fetch,
283
+ } = {}) {
284
+ if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
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
+ });
296
+ if (imported?.validation?.copilotTokenData?.token) {
297
+ return cacheCopilotTokenData(imported.validation.copilotTokenData);
298
+ }
299
+ throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
300
+ }
301
+ throw new Error(`Failed to get Copilot token: ${resp.status}`);
302
+ }
303
+ const data = await resp.json();
304
+ return cacheCopilotTokenData(data);
305
+ }
306
+
283
307
  // chatReq is already converted by the adapter. The caller parses the raw Response.
284
308
  export async function chatCompletions(chatReq, { signal } = {}) {
285
309
  const token = await getCopilotToken({ signal });