codex-copilot-dx 0.4.14 → 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,6 +48,15 @@ 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
+ 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:
54
+
55
+ ```bash
56
+ rm ~/.local/share/copilot-api/github_token
57
+ codex-copilot-dx
58
+ ```
59
+
51
60
  ### Claude App opt-in
52
61
 
53
62
  Claude App support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
@@ -83,6 +92,10 @@ Environment variables:
83
92
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
84
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 |
85
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 |
86
99
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
87
100
  | `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
88
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.14",
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,11 +6,23 @@ 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");
12
16
  }
13
17
 
18
+ export function githubReauthMessage(reason, home = os.homedir()) {
19
+ const tokenPath = githubTokenPath(home);
20
+ return `${reason}
21
+ Delete the saved GitHub token, then run codex-copilot-dx again to log in:
22
+ rm '${tokenPath}'
23
+ codex-copilot-dx`;
24
+ }
25
+
14
26
  // Map GitHub polling responses to a small local state machine.
15
27
  export function interpretPoll(data) {
16
28
  if (typeof data.access_token === "string" && data.access_token) return { state: "done", token: data.access_token };
@@ -23,6 +35,263 @@ export function interpretPoll(data) {
23
35
 
24
36
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
25
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
+
26
295
  // On macOS, copy the user code and open the verification page. Fail quietly.
27
296
  function openAndCopy(userCode, verificationUri) {
28
297
  if (process.platform !== "darwin") return;
@@ -42,32 +311,48 @@ function openAndCopy(userCode, verificationUri) {
42
311
  } catch {}
43
312
  }
44
313
 
45
- export async function ensureAuth() {
46
- 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);
47
323
  if (fs.existsSync(GITHUB_TOKEN_PATH)) {
48
- console.log(status("ok", "GitHub token found"));
49
- 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"));
50
330
  }
51
- 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..."));
52
336
 
53
337
  // Request a device code.
54
- const codeResp = await fetch("https://github.com/login/device/code", {
338
+ const codeResp = await fetchImpl("https://github.com/login/device/code", {
55
339
  method: "POST",
56
340
  headers: { "Content-Type": "application/json", Accept: "application/json" },
57
341
  body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
342
+ signal,
58
343
  });
59
344
  if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
60
345
  const { device_code, user_code, verification_uri, interval } = await codeResp.json();
61
346
 
62
347
  // Prompt the user.
63
- console.log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
64
- 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);
65
350
 
66
351
  // Poll until GitHub completes the device flow.
67
352
  let waitMs = (interval || 5) * 1000;
68
353
  while (true) {
69
354
  await sleep(waitMs);
70
- const pollResp = await fetch("https://github.com/login/oauth/access_token", {
355
+ const pollResp = await fetchImpl("https://github.com/login/oauth/access_token", {
71
356
  method: "POST",
72
357
  headers: { "Content-Type": "application/json", Accept: "application/json" },
73
358
  body: JSON.stringify({
@@ -75,6 +360,7 @@ export async function ensureAuth() {
75
360
  device_code,
76
361
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
77
362
  }),
363
+ signal,
78
364
  });
79
365
  if (!pollResp.ok) {
80
366
  // Treat transient network/server errors as pending and retry.
@@ -83,8 +369,8 @@ export async function ensureAuth() {
83
369
  const data = await pollResp.json();
84
370
  const r = interpretPoll(data);
85
371
  if (r.state === "done") {
86
- writeToken(r.token);
87
- console.log(status("ok", "Login successful"));
372
+ writeToken(r.token, home);
373
+ log(status("ok", "Login successful"));
88
374
  return;
89
375
  }
90
376
  if (r.state === "slow") { waitMs += 5000; continue; }
@@ -93,8 +379,9 @@ export async function ensureAuth() {
93
379
  }
94
380
  }
95
381
 
96
- function writeToken(token) {
97
- const GITHUB_TOKEN_PATH = githubTokenPath();
382
+ export function writeToken(token, home = os.homedir()) {
383
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
98
384
  fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
99
385
  fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
386
+ fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
100
387
  }
package/src/copilot.mjs CHANGED
@@ -1,10 +1,9 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import os from "node:os";
4
3
  import { randomUUID } from "node:crypto";
5
4
  import { status } from "./status.mjs";
5
+ import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
6
 
7
- const GITHUB_TOKEN_PATH = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
8
7
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
9
8
  let apiBase = DEFAULT_API_BASE;
10
9
  const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
@@ -248,22 +247,24 @@ export async function refreshVSCodeVersion() {
248
247
  let copilotToken = null;
249
248
  let copilotTokenExpiry = 0;
250
249
 
251
- function getGithubToken() {
250
+ function getGithubToken({ home = os.homedir() } = {}) {
251
+ const GITHUB_TOKEN_PATH = githubTokenPath(home);
252
252
  if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
253
- throw new Error("GitHub token not found. Run the tool once to log in.");
253
+ throw new Error("GitHub token not found. Run codex-copilot-dx again to log in.");
254
254
  }
255
- return fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
255
+ const token = fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
256
+ if (!token) throw new Error(githubReauthMessage("GitHub token file is empty."));
257
+ return token;
256
258
  }
257
259
 
258
- export async function getCopilotToken({ signal } = {}) {
259
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
260
- const ghToken = getGithubToken();
261
- 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`, {
262
262
  headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
263
263
  signal,
264
264
  });
265
- if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
266
- const data = await resp.json();
265
+ }
266
+
267
+ function cacheCopilotTokenData(data) {
267
268
  if (!data.token) throw new Error("Copilot token response missing token field");
268
269
  copilotToken = data.token;
269
270
  apiBase = parseApiBase(data);
@@ -274,6 +275,35 @@ export async function getCopilotToken({ signal } = {}) {
274
275
  return copilotToken;
275
276
  }
276
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
+
277
307
  // chatReq is already converted by the adapter. The caller parses the raw Response.
278
308
  export async function chatCompletions(chatReq, { signal } = {}) {
279
309
  const token = await getCopilotToken({ signal });