codex-copilot-dx 0.5.6 → 0.6.0

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
@@ -4,7 +4,8 @@ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/
4
4
 
5
5
  ## How it works
6
6
 
7
- A single in-process adapter (port `2026`) exposes both APIs over your Copilot subscription:
7
+ A single in-process adapter (port `2026`) exposes both APIs over one Copilot
8
+ account by default, or over isolated Codex and Claude accounts when configured:
8
9
 
9
10
  - **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
10
11
  - **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
@@ -33,8 +34,8 @@ global install, use `npx codex-copilot-dx@latest`.
33
34
  Both installed commands expose the same options and subcommands; help, version,
34
35
  doctor, status, and argument-error output use the command name that was invoked.
35
36
 
36
- To query the models currently advertised as selectable for the saved Copilot
37
- account, run:
37
+ To query the models currently advertised as selectable for the saved Codex
38
+ Copilot account, run:
38
39
 
39
40
  ```bash
40
41
  ccdx models
@@ -48,6 +49,54 @@ being advertised does not guarantee that a later inference request will avoid
48
49
  quota, rate-limit, or policy enforcement. `codex-copilot-dx models` is fully
49
50
  equivalent.
50
51
 
52
+ ### Separate GitHub account for Claude
53
+
54
+ The default remains fully backward compatible: Codex and Claude requests share
55
+ the existing GitHub authentication. To keep an enterprise account for Codex
56
+ while using a different personal account that has Claude models enabled, run:
57
+
58
+ ```bash
59
+ ccdx auth login claude --github-login <personal-github-login>
60
+ ```
61
+
62
+ This is the only command that starts the additional Device Flow. It verifies
63
+ the selected GitHub identity, Copilot entitlement, and an enabled Claude model
64
+ before saving anything. It refuses the current Codex account and never changes,
65
+ migrates, or deletes the existing Codex credential. The optional login pin is
66
+ recommended so authorizing the wrong browser account fails before activation.
67
+
68
+ The existing Codex token remains at
69
+ `~/.local/share/copilot-api/github_token`. The isolated Claude credential is
70
+ stored with mode `0600` under
71
+ `~/.local/share/copilot-api/profiles/claude/github_token`, with matching account
72
+ metadata committed last. Restart a running adapter after authentication.
73
+
74
+ Once enabled, routing is fixed by API surface rather than by model name:
75
+
76
+ - `/v1/responses`, `/v1/responses/compact`, their Chat fallback, and normal
77
+ `/v1/models` use the Codex account.
78
+ - `/v1/messages` and Claude App model discovery use the isolated Claude account.
79
+ - `/v1/messages/count_tokens` remains local and uses neither account.
80
+
81
+ The two profiles keep separate service tokens, API bases, refresh/backoff state,
82
+ model endpoint metadata, and model caches. If the isolated Claude profile later
83
+ becomes invalid, Claude requests fail with a reauthentication instruction while
84
+ Codex remains available; CCDX never silently falls back to the enterprise
85
+ account. Reauthorize it explicitly with:
86
+
87
+ ```bash
88
+ ccdx auth login claude --reauth --github-login <personal-github-login>
89
+ ```
90
+
91
+ These commands are read-only and never start Device Flow:
92
+
93
+ ```bash
94
+ ccdx auth status
95
+ ccdx auth status --online
96
+ ccdx models --profile claude
97
+ ccdx doctor --online --profile all
98
+ ```
99
+
51
100
  To change the model used by Codex Auto-review, run:
52
101
 
53
102
  ```bash
@@ -123,6 +172,9 @@ ccdx doctor --online
123
172
  ```
124
173
 
125
174
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
175
+ Use `--profile codex`, `--profile claude`, or `--profile all` to choose which
176
+ account entitlement is checked. Without `--profile`, the legacy Codex check is
177
+ preserved.
126
178
 
127
179
  To actively verify the protocol path through an already-running adapter, run:
128
180
 
package/bin/cli.mjs CHANGED
@@ -5,8 +5,8 @@ import { ensureCodexConfig } from "../src/config.mjs";
5
5
  import { ensureClaudeConfig } from "../src/claude-config.mjs";
6
6
  import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey, syncManagedClaudeDesktopModels } from "../src/claude-desktop-config.mjs";
7
7
  import { startAdapter } from "../src/adapter.mjs";
8
- import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
- import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, codexAutoReviewModelStatus, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
8
+ import { defaultCopilotClient, refreshVSCodeVersion } from "../src/copilot.mjs";
9
+ import { claudeDesktopModelIds } from "../src/models.mjs";
10
10
  import { status } from "../src/status.mjs";
11
11
  import { configureLogging } from "../src/log.mjs";
12
12
  import { flushUsageWrites, printUsageSummary } from "../src/usage.mjs";
@@ -14,14 +14,16 @@ import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
14
14
  import { runDoctor } from "../src/doctor.mjs";
15
15
  import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs";
16
16
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
- import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
18
- import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
17
+ import { runInBackground } from "../src/startup.mjs";
19
18
  import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
20
19
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
21
20
  import { closeHttpServer } from "../src/shutdown.mjs";
22
21
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
23
22
  import { autoReviewModelPreference } from "../src/user-settings.mjs";
24
23
  import { fetchLiveCopilotModels, formatLiveCopilotModels } from "../src/cli-models.mjs";
24
+ import { runAuthCommand } from "../src/cli-auth.mjs";
25
+ import { createProfileRuntime } from "../src/profile-runtime.mjs";
26
+ import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
25
27
 
26
28
  const LOCAL_VERSION = localPackageVersion();
27
29
  const CLI_NAME = cliCommandName();
@@ -48,6 +50,29 @@ if (CLI.command === "usage") {
48
50
  await printUsageSummary();
49
51
  process.exit(0);
50
52
  }
53
+ if (CLI.command === "auth") {
54
+ try {
55
+ const result = await runAuthCommand({
56
+ action: CLI.action,
57
+ profile: CLI.profile,
58
+ online: CLI.online,
59
+ reauth: CLI.reauth,
60
+ expectedLogin: CLI.expectedLogin,
61
+ commandName: CLI_NAME,
62
+ });
63
+ if (result.output) console.log(result.output);
64
+ if (result.action === "login") {
65
+ if (!result.changed) {
66
+ console.log(status("ok", `Claude profile is already configured for ${result.identity?.login || result.identity?.id || "the saved account"}`));
67
+ }
68
+ console.log(status("warn", `Restart the running ${CLI_NAME} adapter to activate the Claude profile`));
69
+ }
70
+ process.exit(0);
71
+ } catch (e) {
72
+ console.error(status("err", e.message));
73
+ process.exit(1);
74
+ }
75
+ }
51
76
  if (CLI.command === "status") {
52
77
  let probe;
53
78
  try {
@@ -71,7 +96,7 @@ if (CLI.command === "status") {
71
96
  }
72
97
  if (CLI.command === "models") {
73
98
  try {
74
- const catalog = await fetchLiveCopilotModels();
99
+ const catalog = await fetchLiveCopilotModels({ profile: CLI.profile });
75
100
  console.log(formatLiveCopilotModels(catalog, { commandName: CLI_NAME }));
76
101
  process.exit(0);
77
102
  } catch (e) {
@@ -113,7 +138,6 @@ const MODEL_REFRESH_TIMEOUT_MS = RUNTIME.modelRefreshTimeoutMs;
113
138
  const EXISTING_ADAPTER_TIMEOUT_MS = RUNTIME.existingAdapterTimeoutMs;
114
139
  const CONFIGURE_CLAUDE_DESKTOP = CLI.configureClaudeDesktop || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
115
140
  const LOGGING = configureLogging();
116
- const MODEL_REGISTRY = { modelDefs: undefined, models: undefined, source: "built-in" };
117
141
  let activeServer = null;
118
142
  let modelRefreshTimer = null;
119
143
  let shuttingDown = false;
@@ -123,88 +147,11 @@ if (LOGGING.filePath) {
123
147
  if (LOGGING.level === "debug") console.log(status("debug", "Debug logging enabled"));
124
148
  }
125
149
 
126
- function applyModelsToRegistry(models, source, { updateClaudeModels = true } = {}) {
127
- if (!isValidModelList(models)) throw new Error("Copilot models response contained no valid models");
128
- const modelDefs = updateClaudeModels
129
- ? claudeDesktopModelDefsFromCopilotModels(models)
130
- : MODEL_REGISTRY.modelDefs;
131
- cacheModelEndpoints(models);
132
- MODEL_REGISTRY.models = models;
133
- if (updateClaudeModels && modelDefs.length) {
134
- MODEL_REGISTRY.modelDefs = modelDefs;
135
- MODEL_REGISTRY.source = source;
136
- }
137
- return modelDefs;
138
- }
139
-
140
- function loadCachedModelRegistry() {
141
- const cached = loadModelCache();
142
- if (!cached) return false;
143
- try {
144
- const customAliases = parseModelAliasEnv(process.env.CCDX_CLAUDE_MODEL_ALIASES).length > 0;
145
- applyModelsToRegistry(cached, "cache", { updateClaudeModels: !customAliases });
146
- return true;
147
- } catch {
148
- return false;
149
- }
150
- }
151
-
152
- async function refreshClaudeDesktopModelDefs() {
153
- const customAliases = parseModelAliasEnv(process.env.CCDX_CLAUDE_MODEL_ALIASES).length > 0;
154
-
155
- const ctrl = new AbortController();
156
- const timer = setTimeout(() => ctrl.abort(), MODEL_REFRESH_TIMEOUT_MS);
157
- try {
158
- const { status: httpStatus, body } = await listModels({ signal: ctrl.signal });
159
- if (httpStatus < 200 || httpStatus >= 300) {
160
- throw new Error(`Copilot models returned HTTP ${httpStatus}`);
161
- }
162
- const models = JSON.parse(body);
163
- const autoReview = codexAutoReviewModelStatus(models, process.env, {
164
- autoReviewModel: autoReviewModelPreference().model,
165
- });
166
- if (!autoReview.available) {
167
- console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ${CLI_NAME} doctor --compat to verify the live path.`));
168
- }
169
- const gptModelIds = gptModelIdsFromCopilotModels(models);
170
- if (gptModelIds.length) {
171
- console.log(status("ok", `Refreshed GPT models from GitHub Copilot: ${gptModelIds.join(", ")}`));
172
- } else {
173
- console.log(status("warn", "Copilot models response contained no GPT models"));
174
- }
175
-
176
- const modelDefs = applyModelsToRegistry(models, "live", { updateClaudeModels: !customAliases });
177
- try {
178
- saveModelCache(models);
179
- } catch (e) {
180
- console.log(status("warn", `Could not persist the Copilot model cache (${e.message})`));
181
- }
182
- if (customAliases) {
183
- console.log(status("info", "Using CCDX_CLAUDE_MODEL_ALIASES for Claude models"));
184
- } else if (modelDefs?.length) {
185
- console.log(status("ok", `Refreshed Claude models from GitHub Copilot: ${modelDefs.map((model) => model.id).join(", ")}`));
186
- } else {
187
- console.log(status("warn", "Copilot models response contained no Claude models; using built-in Claude models"));
188
- }
189
- syncClaudeDesktopProfileModels(MODEL_REGISTRY.modelDefs);
190
- return modelDefs;
191
- } catch (e) {
192
- const fallback = MODEL_REGISTRY.modelDefs?.length ? `${MODEL_REGISTRY.source} model list` : "built-in model list";
193
- const message = ctrl.signal.aborted
194
- ? `Model refresh timed out after ${MODEL_REFRESH_TIMEOUT_MS}ms; using ${fallback}`
195
- : `Could not refresh model list; using ${fallback} (${e.message})`;
196
- console.log(status("warn", message));
197
- return MODEL_REGISTRY.modelDefs;
198
- } finally {
199
- clearTimeout(timer);
200
- }
201
- }
202
-
203
150
  function currentClaudeDesktopApiKey() {
204
151
  return String(process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "").trim();
205
152
  }
206
153
 
207
- function syncClaudeDesktopProfileModels(modelDefs = MODEL_REGISTRY.modelDefs) {
154
+ function syncClaudeDesktopProfileModels(modelDefs) {
208
155
  const result = syncManagedClaudeDesktopModels({
209
156
  port: ADAPTER_PORT,
210
157
  host: ADAPTER_HOST,
@@ -268,6 +215,7 @@ if (CLI.command === "doctor") {
268
215
  host: ADAPTER_HOST,
269
216
  online: CLI.online,
270
217
  compat: CLI.compat,
218
+ profile: CLI.profile,
271
219
  });
272
220
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
273
221
  }
@@ -296,14 +244,33 @@ try {
296
244
  // Ensure GitHub login, using device flow if no token exists.
297
245
  await ensureAuth();
298
246
 
247
+ const profileRuntime = createProfileRuntime({ codexClient: defaultCopilotClient });
248
+ if (profileRuntime.claudeMode === "isolated") {
249
+ if (profileRuntime.claudeProfile.valid) {
250
+ const account = profileRuntime.claudeProfile.identity?.login
251
+ || profileRuntime.claudeProfile.identity?.id
252
+ || "the saved account";
253
+ console.log(status("ok", `Claude requests will use the isolated GitHub account ${account}`));
254
+ } else {
255
+ console.log(status("warn", `Claude profile is configured but invalid (${profileRuntime.claudeProfile.reason}); Codex remains available, but Claude requests will fail until you run ${CLI_NAME} auth login claude --reauth and restart`));
256
+ }
257
+ }
258
+
259
+ const profileModels = createProfileModelRuntime({
260
+ codexClient: profileRuntime.codexClient,
261
+ claudeClient: profileRuntime.claudeClient,
262
+ claudeMode: profileRuntime.claudeMode,
263
+ claudeCredentialFingerprint: profileRuntime.claudeCredentialFingerprint,
264
+ timeoutMs: MODEL_REFRESH_TIMEOUT_MS,
265
+ commandName: CLI_NAME,
266
+ autoReviewModelResolver: () => autoReviewModelPreference().model,
267
+ onClaudeModelsChanged: syncClaudeDesktopProfileModels,
268
+ });
269
+
299
270
  // Refresh the VS Code version in the background; fallback is non-blocking.
300
271
  void refreshVSCodeVersion();
301
- const modelInitialization = await initializeModelRegistry({
302
- loadCached: loadCachedModelRegistry,
303
- currentModelDefs: () => MODEL_REGISTRY.modelDefs,
304
- refresh: refreshClaudeDesktopModelDefs,
305
- });
306
- const claudeDesktopModelDefs = modelInitialization.modelDefs;
272
+ const modelInitialization = await profileModels.initialize();
273
+ const claudeDesktopModelDefs = modelInitialization.claude.modelDefs;
307
274
  syncClaudeDesktopProfileModels(claudeDesktopModelDefs);
308
275
 
309
276
  if (!isLoopbackHost(ADAPTER_HOST)) {
@@ -327,7 +294,11 @@ try {
327
294
  claudeDesktopApiKey,
328
295
  claudeDesktopModelDefs,
329
296
  autoReviewModelResolver: () => autoReviewModelPreference().model,
330
- modelRegistry: MODEL_REGISTRY,
297
+ codexClient: profileRuntime.codexClient,
298
+ claudeClient: profileRuntime.claudeClient,
299
+ codexModelRegistry: profileModels.codexRegistry,
300
+ claudeModelRegistry: profileModels.claudeRegistry,
301
+ claudeMode: profileRuntime.claudeMode,
331
302
  showRequestId: CLI.showRequestId,
332
303
  upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
333
304
  streamHandshakeTimeoutMs: RUNTIME.streamHandshakeTimeoutMs,
@@ -358,7 +329,7 @@ try {
358
329
  const refreshIntervalMs = RUNTIME.modelRefreshIntervalMs;
359
330
  if (Number.isFinite(refreshIntervalMs) && refreshIntervalMs > 0) {
360
331
  modelRefreshTimer = setInterval(() => {
361
- refreshClaudeDesktopModelDefs().catch(() => {});
332
+ profileModels.refreshAll().catch(() => {});
362
333
  }, refreshIntervalMs);
363
334
  modelRefreshTimer.unref?.();
364
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { createAnthropicCountTokensHandler, createAnthropicMessagesHandler } from "./anthropic-handler.mjs";
3
- import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
3
+ import { defaultCopilotClient } from "./copilot.mjs";
4
4
  import { isValidModelList } from "./model-cache.mjs";
5
5
  import { claudeDesktopModelsResponse } from "./models.mjs";
6
6
  import {
@@ -90,10 +90,46 @@ export function createAdapterHandler(options = {}) {
90
90
  || process.env.CCDX_CLAUDE_DESKTOP_API_KEY
91
91
  || process.env.CCDX_PROXY_API_KEY
92
92
  || "";
93
- const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
94
- const responsesFn = options.responsesFn || copilotResponses;
95
- const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
96
- const listModelsFn = options.listModelsFn || listModels;
93
+ const codexClient = options.codexClient || defaultCopilotClient;
94
+ const hasExplicitClaudeMode = Object.hasOwn(options, "claudeMode");
95
+ if (hasExplicitClaudeMode && !["inherited", "isolated"].includes(options.claudeMode)) {
96
+ throw new Error(`Unsupported Claude mode: ${options.claudeMode}`);
97
+ }
98
+ if (options.claudeMode === "isolated"
99
+ && (!options.claudeClient || options.claudeClient === codexClient)) {
100
+ throw new Error("Isolated Claude mode requires a distinct Claude client");
101
+ }
102
+ if (options.claudeMode === "inherited"
103
+ && options.claudeClient && options.claudeClient !== codexClient) {
104
+ throw new Error("Inherited Claude mode requires the Codex client");
105
+ }
106
+ const claudeClient = options.claudeClient || codexClient;
107
+ // chatCompletionsFn is retained as a compatibility injection and continues to
108
+ // override both surfaces in existing tests and embedders. New dual-profile
109
+ // callers should pass codexClient/claudeClient instead.
110
+ const codexChatCompletionsFn = options.codexChatCompletionsFn
111
+ || options.chatCompletionsFn
112
+ || codexClient.chatCompletions;
113
+ const claudeChatCompletionsFn = options.claudeChatCompletionsFn
114
+ || options.chatCompletionsFn
115
+ || claudeClient.chatCompletions;
116
+ const responsesFn = options.responsesFn || codexClient.responses;
117
+ const responsesCompactFn = options.responsesCompactFn || codexClient.responsesCompact;
118
+ const listModelsFn = options.listModelsFn || codexClient.listModels;
119
+ const getCachedModelEndpointsFn = options.getCachedModelEndpointsFn
120
+ || codexClient.getCachedModelEndpoints;
121
+ const claudeMode = hasExplicitClaudeMode
122
+ ? options.claudeMode
123
+ : claudeClient === codexClient ? "inherited" : "isolated";
124
+ if (claudeMode === "isolated" && options.chatCompletionsFn) {
125
+ throw new Error("Isolated Claude mode cannot use the shared chatCompletionsFn override; pass codexChatCompletionsFn or claudeChatCompletionsFn explicitly");
126
+ }
127
+ const codexModelRegistry = options.codexModelRegistry || options.modelRegistry;
128
+ const claudeModelRegistry = options.claudeModelRegistry || options.modelRegistry || codexModelRegistry;
129
+ if (claudeMode === "isolated"
130
+ && (!options.claudeModelRegistry || claudeModelRegistry === codexModelRegistry)) {
131
+ throw new Error("Isolated Claude mode requires a distinct Claude model registry");
132
+ }
97
133
  const openAIModelEnv = options.openAIModelEnv || process.env;
98
134
  const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
99
135
  const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
@@ -102,13 +138,14 @@ export function createAdapterHandler(options = {}) {
102
138
  const requestMetrics = options.requestMetrics || createRequestMetrics();
103
139
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
104
140
  const claudeDesktopModelOptions = () => {
105
- const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
141
+ const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
106
142
  return Array.isArray(modelDefs) ? { modelDefs } : {};
107
143
  };
108
144
  const responsesHandler = createResponsesHandler({
109
145
  acquireRequest,
110
146
  autoReviewModelResolver: options.autoReviewModelResolver,
111
- chatCompletionsFn,
147
+ chatCompletionsFn: codexChatCompletionsFn,
148
+ getCachedModelEndpointsFn,
112
149
  openAIModelEnv,
113
150
  responsesPayloadOptions: options.responsesPayloadOptions,
114
151
  responsesFn,
@@ -128,7 +165,7 @@ export function createAdapterHandler(options = {}) {
128
165
  const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest });
129
166
  const anthropicMessagesHandler = createAnthropicMessagesHandler({
130
167
  acquireRequest,
131
- chatCompletionsFn,
168
+ chatCompletionsFn: claudeChatCompletionsFn,
132
169
  environment: process.env,
133
170
  modelOptions: claudeDesktopModelOptions,
134
171
  streamHandshakeTimeoutMs,
@@ -148,7 +185,12 @@ export function createAdapterHandler(options = {}) {
148
185
  metrics: requestMetrics,
149
186
  streamPerformance: streamPerformanceMetrics,
150
187
  admission: acquireRequest,
151
- modelRegistry: options.modelRegistry,
188
+ modelRegistry: codexModelRegistry,
189
+ codexClient,
190
+ claudeClient,
191
+ codexModelRegistry,
192
+ claudeModelRegistry,
193
+ claudeMode,
152
194
  })));
153
195
  return;
154
196
  }
@@ -181,24 +223,24 @@ export function createAdapterHandler(options = {}) {
181
223
  let models;
182
224
  try { models = JSON.parse(body); } catch {}
183
225
  if (isValidModelList(models)) {
184
- if (options.modelRegistry) {
185
- options.modelRegistry.models = models;
226
+ if (codexModelRegistry) {
227
+ codexModelRegistry.models = models;
186
228
  }
187
229
  res.writeHead(status, { "Content-Type": "application/json" });
188
230
  res.end(body);
189
231
  return;
190
232
  }
191
- if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
233
+ if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
192
234
  sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
193
235
  return;
194
236
  }
195
- if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, options.modelRegistry)) return;
237
+ if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, codexModelRegistry)) return;
196
238
  res.writeHead(status, { "Content-Type": "application/json" });
197
239
  res.end(body);
198
240
  })
199
241
  .catch((e) => {
200
242
  logRequestFailure("Models", e, abort);
201
- if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
243
+ if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
202
244
  if (!res.destroyed && !res.writableEnded) {
203
245
  if (!res.headersSent) res.writeHead(e?.statusCode || 502);
204
246
  res.end(JSON.stringify({ error: e.message }));
@@ -0,0 +1,205 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { atomicWriteFileSync } from "./atomic-file.mjs";
5
+ import {
6
+ githubTokenLockPath,
7
+ githubTokenMetadataPath,
8
+ githubTokenPath,
9
+ } from "./auth.mjs";
10
+ import {
11
+ githubTokenFingerprint,
12
+ normalizeGithubIdentity,
13
+ } from "./github-identity.mjs";
14
+ import { withFileLock } from "./lock.mjs";
15
+ import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
16
+
17
+ export const AUTH_PROFILE_CODEX = "codex";
18
+ export const AUTH_PROFILE_CLAUDE = "claude";
19
+
20
+ const AUTH_PROFILES = new Set([AUTH_PROFILE_CODEX, AUTH_PROFILE_CLAUDE]);
21
+
22
+ function checkedProfile(profile) {
23
+ const value = String(profile || "").trim().toLowerCase();
24
+ if (!AUTH_PROFILES.has(value)) throw new Error(`Unsupported authentication profile: ${profile}`);
25
+ return value;
26
+ }
27
+
28
+ export function authProfilePaths(profile, { home = os.homedir() } = {}) {
29
+ const name = checkedProfile(profile);
30
+ if (name === AUTH_PROFILE_CODEX) {
31
+ return {
32
+ profile: name,
33
+ tokenPath: githubTokenPath(home),
34
+ metadataPath: githubTokenMetadataPath(home),
35
+ lockPath: githubTokenLockPath(home),
36
+ };
37
+ }
38
+
39
+ const directory = path.join(home, ".local", "share", "copilot-api", "profiles", AUTH_PROFILE_CLAUDE);
40
+ const tokenPath = path.join(directory, "github_token");
41
+ return {
42
+ profile: name,
43
+ tokenPath,
44
+ metadataPath: `${tokenPath}.account.json`,
45
+ lockPath: `${tokenPath}.lock`,
46
+ };
47
+ }
48
+
49
+ function readOptionalFile(filePath) {
50
+ try {
51
+ return { exists: true, data: fs.readFileSync(filePath), mode: fs.statSync(filePath).mode & 0o777 };
52
+ } catch (error) {
53
+ if (error?.code === "ENOENT") return { exists: false, data: null, mode: 0o600 };
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ function parsedMetadata(snapshot) {
59
+ if (!snapshot.exists) return { value: null, malformed: false };
60
+ try {
61
+ return { value: JSON.parse(snapshot.data.toString("utf8")), malformed: false };
62
+ } catch {
63
+ return { value: null, malformed: true };
64
+ }
65
+ }
66
+
67
+ export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}) {
68
+ const paths = authProfilePaths(profile, { home });
69
+ const tokenSnapshot = readOptionalFile(paths.tokenPath);
70
+ const metadataSnapshot = readOptionalFile(paths.metadataPath);
71
+ const token = tokenSnapshot.exists ? tokenSnapshot.data.toString("utf8").trim() : "";
72
+ const metadataResult = parsedMetadata(metadataSnapshot);
73
+ const metadataFingerprint = metadataResult.value?.token_fingerprint;
74
+ const metadataMatchesToken = Boolean(token)
75
+ && Boolean(metadataFingerprint)
76
+ && metadataFingerprint === githubTokenFingerprint(token);
77
+ const metadataIdentity = normalizeGithubIdentity(metadataResult.value);
78
+ const identity = metadataMatchesToken ? metadataIdentity : null;
79
+
80
+ if (paths.profile === AUTH_PROFILE_CODEX) {
81
+ const configured = tokenSnapshot.exists;
82
+ return {
83
+ profile: paths.profile,
84
+ configured,
85
+ valid: configured && Boolean(token),
86
+ reason: !configured ? "unconfigured" : token ? "" : "empty_token",
87
+ token,
88
+ identity,
89
+ metadata: metadataResult.value,
90
+ paths,
91
+ };
92
+ }
93
+
94
+ // Metadata is written last, but any credential artifact reserves the Claude
95
+ // profile. A partial write must fail closed instead of borrowing Codex.
96
+ const configured = metadataSnapshot.exists || tokenSnapshot.exists;
97
+ let reason = "";
98
+ if (!configured) reason = "unconfigured";
99
+ else if (!metadataSnapshot.exists) reason = "missing_metadata";
100
+ else if (metadataResult.malformed) reason = "metadata_malformed";
101
+ else if (!tokenSnapshot.exists) reason = "missing_token";
102
+ else if (!token) reason = "empty_token";
103
+ else if (!metadataIdentity) reason = "metadata_identity_missing";
104
+ else if (!metadataFingerprint || metadataFingerprint !== githubTokenFingerprint(token)) reason = "token_metadata_mismatch";
105
+
106
+ return {
107
+ profile: paths.profile,
108
+ configured,
109
+ valid: configured && !reason,
110
+ reason,
111
+ token,
112
+ identity,
113
+ metadata: metadataResult.value,
114
+ paths,
115
+ };
116
+ }
117
+
118
+ function lockOptions(env = process.env) {
119
+ return {
120
+ timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
+ staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
122
+ };
123
+ }
124
+
125
+ export function withAuthProfileLock(profile, fn, {
126
+ home = os.homedir(),
127
+ env = process.env,
128
+ ...options
129
+ } = {}) {
130
+ const { lockPath } = authProfilePaths(profile, { home });
131
+ return withFileLock(lockPath, fn, { ...lockOptions(env), ...options });
132
+ }
133
+
134
+ function writeSecret(filePath, data, writeFile) {
135
+ writeFile(filePath, data, { mode: 0o600 });
136
+ fs.chmodSync(filePath, 0o600);
137
+ }
138
+
139
+ function restoreSnapshot(filePath, snapshot, writeFile) {
140
+ if (snapshot.exists) {
141
+ writeSecret(filePath, snapshot.data, writeFile);
142
+ return;
143
+ }
144
+ try {
145
+ fs.unlinkSync(filePath);
146
+ } catch (error) {
147
+ if (error?.code !== "ENOENT") throw error;
148
+ }
149
+ }
150
+
151
+ export function writeClaudeAuthProfile(token, identity, {
152
+ home = os.homedir(),
153
+ now = () => new Date(),
154
+ writeFile = atomicWriteFileSync,
155
+ } = {}) {
156
+ const cleanToken = String(token || "").trim();
157
+ const normalizedIdentity = normalizeGithubIdentity(identity);
158
+ if (!cleanToken) throw new Error("Claude GitHub token is empty");
159
+ if (!normalizedIdentity) throw new Error("Claude GitHub account identity is missing");
160
+
161
+ const paths = authProfilePaths(AUTH_PROFILE_CLAUDE, { home });
162
+ const directory = path.dirname(paths.tokenPath);
163
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
164
+ fs.chmodSync(directory, 0o700);
165
+
166
+ const previousToken = readOptionalFile(paths.tokenPath);
167
+ const previousMetadata = readOptionalFile(paths.metadataPath);
168
+ const timestamp = now();
169
+ const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
170
+ const metadata = `${JSON.stringify({
171
+ profile: AUTH_PROFILE_CLAUDE,
172
+ ...normalizedIdentity,
173
+ token_fingerprint: githubTokenFingerprint(cleanToken),
174
+ updated_at: updatedAt,
175
+ }, null, 2)}\n`;
176
+
177
+ try {
178
+ writeSecret(paths.tokenPath, cleanToken, writeFile);
179
+ // Commit the activation marker last. Readers never observe a configured
180
+ // Claude profile unless the token and its fingerprint agree.
181
+ writeSecret(paths.metadataPath, metadata, writeFile);
182
+ } catch (error) {
183
+ try {
184
+ restoreSnapshot(paths.tokenPath, previousToken, writeFile);
185
+ restoreSnapshot(paths.metadataPath, previousMetadata, writeFile);
186
+ } catch (rollbackError) {
187
+ error.rollbackError = rollbackError;
188
+ }
189
+ throw error;
190
+ }
191
+
192
+ return {
193
+ profile: AUTH_PROFILE_CLAUDE,
194
+ identity: normalizedIdentity,
195
+ paths,
196
+ };
197
+ }
198
+
199
+ export function profileReauthMessage(profile, { home = os.homedir() } = {}) {
200
+ const name = checkedProfile(profile);
201
+ if (name === AUTH_PROFILE_CLAUDE) {
202
+ return "Claude authentication is unavailable. Run `ccdx auth login claude --reauth`, then restart ccdx.";
203
+ }
204
+ return `Codex authentication is unavailable at ${authProfilePaths(name, { home }).tokenPath}. Run ccdx again to log in.`;
205
+ }