codex-copilot-dx 0.6.14 → 0.6.18

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
@@ -91,7 +91,10 @@ Once enabled, routing is fixed by API surface rather than by model name:
91
91
 
92
92
  The two profiles keep separate service tokens, API bases, refresh/backoff state,
93
93
  model endpoint metadata, and credential-bound model caches. Switching either
94
- GitHub account invalidates that profile's old cache instead of borrowing it. If the isolated Claude profile later
94
+ GitHub account invalidates that profile's old cache instead of borrowing it.
95
+ Cached catalogs are fresh for two hours; older catalogs remain immediately
96
+ available for up to seven days while CCDX refreshes them in the background.
97
+ If the isolated Claude profile later
95
98
  becomes invalid, Claude requests fail with a reauthentication instruction while
96
99
  Codex remains available; CCDX never silently falls back to the enterprise
97
100
  account. Reauthorize it explicitly with:
@@ -140,7 +143,8 @@ integrity, isolated Claude profile, and live relay routing without changing file
140
143
  ccdx pms status
141
144
  ```
142
145
 
143
- `ccdx pm-studio status` and `ccdx pm-studio setup` are equivalent long-form aliases.
146
+ `ccdx pm-studio status`, `ccdx pm-studio setup`, and `ccdx pm-studio restore`
147
+ are equivalent long-form aliases.
144
148
 
145
149
  `ccdx pms setup` performs local preflight, structural verification, backup,
146
150
  staging, integrity, signing, and replacement work. It does not access a PM
@@ -180,37 +184,47 @@ require reinstalling PM Studio. Setup prints the verified backup and
180
184
  manifest paths plus the version-matched restore procedure. Never restore a
181
185
  backup over a different PM Studio version or build.
182
186
 
183
- To restore, first quit PM Studio and its updater. Use the exact verified backup
184
- path printed by setup as `BACKUP_APP`, then confirm that both the installed App
185
- and backup still report the same version/build printed by setup:
187
+ To restore the original local bundle, first quit PM Studio and its updater,
188
+ then run:
186
189
 
187
190
  ```bash
188
- BACKUP_APP="<verified-backup-path-from-ccdx-pms-setup>"
189
- /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
190
- /usr/bin/plutil -extract CFBundleVersion raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
191
- /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$BACKUP_APP/Contents/Info.plist"
192
- /usr/bin/plutil -extract CFBundleVersion raw -o - "$BACKUP_APP/Contents/Info.plist"
191
+ ccdx pms restore
193
192
  ```
194
193
 
195
- Only when all four values match each other and the version/build recorded in the
196
- source-bound manifest, move the patched App aside, restore the complete local
197
- bundle, and verify it before launch:
194
+ The explicit command authorizes the local App replacement and does not show an
195
+ additional confirmation prompt. Restore neither needs the Claude profile nor
196
+ starts the adapter, Device Flow, Codex, or PM Studio. It selects the unique
197
+ source-bound backup itself, verifies the installed patch record and the exact
198
+ clean backup, stages and verifies the complete bundle, confirms that the
199
+ installed App did not change during staging, and then performs an atomic
200
+ replacement. It never restores a missing, ambiguous, drifted, or
201
+ version/build-mismatched record. The original local signature is inspected and
202
+ reported, but is not an admission gate: the exact source-bound content recorded
203
+ when setup created the backup is authoritative. The verified backup and manifest
204
+ remain available after a successful restore. Repeating restore on the same
205
+ recorded clean App is safe and does not replace it again.
206
+
207
+ If automatic restore reports that the replacement state cannot be verified, do
208
+ not launch PM Studio. Keep the recovery path printed by the command and reinstall
209
+ PM Studio. The following manual procedure is only an exceptional recovery path;
210
+ use the exact backup and manifest paths printed by setup or restore, and first
211
+ confirm that the installed App and backup still match the manifest version/build:
198
212
 
199
213
  ```bash
214
+ BACKUP_APP="<exact-verified-backup-path>"
200
215
  RECOVERY_DIR="$(/usr/bin/mktemp -d '/Applications/CCDX-PM-Studio-recovery.XXXXXX')"
201
216
  PATCHED_APP="$RECOVERY_DIR/PM Studio.app"
202
217
  mv "/Applications/PM Studio.app" "$PATCHED_APP"
203
218
  /usr/bin/ditto --rsrc --extattr --acl "$BACKUP_APP" "/Applications/PM Studio.app"
204
- /usr/bin/codesign --verify --deep --strict --verbose=2 "/Applications/PM Studio.app"
205
219
  ccdx pms status
206
220
  ```
207
221
 
208
222
  The App-patch row from `ccdx pms status` must classify the restored bundle as
209
223
  the recorded clean local content rather than integrity drift. If the version
210
- check, copy, or either verification path fails,
224
+ check, copy, or content verification fails,
211
225
  do not launch the restored App. Keep the moved patched bundle at `$PATCHED_APP`
212
226
  and reinstall PM Studio instead of applying a version-mismatched
213
- backup. CCDX never runs these restore commands or `sudo` automatically.
227
+ backup. CCDX never uses `sudo` automatically.
214
228
 
215
229
  To change the model used by Codex Auto-review, run:
216
230
 
package/bin/cli.mjs CHANGED
@@ -143,6 +143,11 @@ if (CLI.command === "pms") {
143
143
  });
144
144
  process.exit(result.ok ? 0 : 1);
145
145
  }
146
+ if (CLI.action === "restore") {
147
+ const { runPmStudioRestore } = await import("../src/pm-studio-setup.mjs");
148
+ await runPmStudioRestore({ commandName: CLI_NAME });
149
+ process.exit(0);
150
+ }
146
151
  const { runPmStudioSetup } = await import("../src/pm-studio-setup.mjs");
147
152
  await runPmStudioSetup({ commandName: CLI_NAME });
148
153
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.14",
3
+ "version": "0.6.18",
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
@@ -41,14 +41,18 @@ export {
41
41
  responseHistoryStats,
42
42
  } from "./response-history.mjs";
43
43
  export { forwardToChat, responsesToChat } from "./responses-bridge.mjs";
44
+ export {
45
+ openCopilotResponse,
46
+ } from "./copilot-responses-compat.mjs";
44
47
  export {
45
48
  isEncryptedContentVerificationError,
46
49
  isImageNamespaceCollisionError,
47
- openCopilotResponse,
50
+ sanitizeImageNamespaceCollisionRequest,
51
+ } from "./copilot-responses-policy.mjs";
52
+ export {
48
53
  prepareResponsesRequest,
49
54
  rememberResponseHistory,
50
55
  sanitizeEncryptedReasoningRequest,
51
- sanitizeImageNamespaceCollisionRequest,
52
56
  stripInternalResponsesInputFields,
53
57
  } from "./responses-request.mjs";
54
58
 
@@ -151,6 +155,7 @@ export function createAdapterHandler(options = {}) {
151
155
  chatCompletionsFn: codexChatCompletionsFn,
152
156
  getCachedModelEndpointsFn,
153
157
  imagePressure,
158
+ modelRegistry: codexModelRegistry,
154
159
  now: options.now,
155
160
  openAIModelEnv,
156
161
  responsesPayloadOptions: options.responsesPayloadOptions,
@@ -164,6 +169,7 @@ export function createAdapterHandler(options = {}) {
164
169
  acquireRequest,
165
170
  autoReviewModelResolver: options.autoReviewModelResolver,
166
171
  imagePressure,
172
+ modelRegistry: codexModelRegistry,
167
173
  now: options.now,
168
174
  openAIModelEnv,
169
175
  requestBodyTimeoutMs,
@@ -290,6 +296,9 @@ export function createAdapterHandler(options = {}) {
290
296
  res.writeHead(404, { "Content-Type": "application/json" });
291
297
  res.end(JSON.stringify({ error: "Not found" }));
292
298
  };
299
+ const dispatchRequest = typeof options.dispatchRequestForTests === "function"
300
+ ? options.dispatchRequestForTests
301
+ : dispatch;
293
302
 
294
303
  return (req, res) => {
295
304
  const requestId = createRequestId();
@@ -322,10 +331,28 @@ export function createAdapterHandler(options = {}) {
322
331
  const finishRequest = ({ statusCode = 0, aborted = false } = {}) => {
323
332
  if (requestFinished) return;
324
333
  requestFinished = true;
325
- complete({ statusCode, aborted });
326
- streamPerformance?.finish({ failed: aborted || statusCode >= 400 });
334
+ try { complete({ statusCode, aborted }); } catch {}
335
+ try { streamPerformance?.finish({ failed: aborted || statusCode >= 400 }); } catch {}
327
336
  try { finishTerminalActivity(); } catch {}
328
337
  };
338
+ const containUnexpectedError = (error) => {
339
+ try { finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 }); } catch {}
340
+ const safeCause = error instanceof Error ? error : new Error("Unexpected adapter failure");
341
+ try { logRequestFailure("Adapter", safeCause); } catch {}
342
+ try {
343
+ const safeError = httpError("Internal adapter error", 500);
344
+ safeError.jsonBody = {
345
+ error: {
346
+ message: safeError.message,
347
+ type: "server_error",
348
+ code: "ccdx_internal_error",
349
+ },
350
+ };
351
+ sendJsonError(res, safeError, 500);
352
+ } catch {
353
+ try { res.destroy?.(); } catch {}
354
+ }
355
+ };
329
356
  if (trackRequest && typeof res.once === "function") {
330
357
  res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
331
358
  res.once("close", () => finishRequest({
@@ -340,17 +367,15 @@ export function createAdapterHandler(options = {}) {
340
367
  pathname,
341
368
  showRequestId: options.showRequestId === true,
342
369
  streamPerformance,
343
- }, () => dispatch(req, res, pathname));
370
+ }, () => dispatchRequest(req, res, pathname));
344
371
  if (result && typeof result.then === "function") {
345
372
  return result.catch((error) => {
346
- finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
347
- throw error;
373
+ containUnexpectedError(error);
348
374
  });
349
375
  }
350
376
  return result;
351
377
  } catch (error) {
352
- finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
353
- throw error;
378
+ containUnexpectedError(error);
354
379
  }
355
380
  };
356
381
  }
@@ -7,7 +7,9 @@ import {
7
7
  import {
8
8
  createRequestAbort,
9
9
  logRequestFailure,
10
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
11
  readJsonBody,
12
+ readBoundedResponseText,
11
13
  sendJsonError,
12
14
  sendUpstreamError,
13
15
  writeOrDrain,
@@ -20,6 +22,43 @@ import { requireUpstreamEventStream, withChatStreamUsage } from "./stream-contra
20
22
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
21
23
  import { recordAnthropicUsage } from "./usage.mjs";
22
24
 
25
+ const PROMPT_TOO_LONG_MESSAGE = "prompt is too long: your prompt is too long. Please reduce the number of messages or use a model with a larger context window.";
26
+ const DOWNSTREAM_CLOSED = Symbol("downstream-closed");
27
+
28
+ function contextWindowErrorBody(text) {
29
+ let error;
30
+ try {
31
+ const parsed = JSON.parse(text);
32
+ error = parsed && typeof parsed === "object" ? parsed.error : null;
33
+ } catch {
34
+ error = { message: text };
35
+ }
36
+ if (!error || typeof error !== "object") return null;
37
+ const code = error.code;
38
+ const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
39
+ const matches = code === "context_length_exceeded"
40
+ || code === "model_max_prompt_tokens_exceeded"
41
+ || message.includes("exceeds the context window of this model")
42
+ || message.includes("maximum context length is")
43
+ || message.includes("request body is too large for model context window");
44
+ if (!matches) return null;
45
+ return JSON.stringify({
46
+ type: "error",
47
+ error: { type: "invalid_request_error", message: PROMPT_TOO_LONG_MESSAGE },
48
+ });
49
+ }
50
+
51
+ function sendAnthropicUpstreamError(res, response, text) {
52
+ const rewritten = contextWindowErrorBody(text);
53
+ if (!rewritten) {
54
+ sendUpstreamError(res, response, text);
55
+ return;
56
+ }
57
+ const headers = new Headers(response.headers);
58
+ headers.set("content-type", "application/json");
59
+ sendUpstreamError(res, { status: response.status, headers }, rewritten);
60
+ }
61
+
23
62
  export function createAnthropicCountTokensHandler(options) {
24
63
  const { acquireRequest, requestBodyTimeoutMs } = options;
25
64
 
@@ -76,7 +115,10 @@ export function createAnthropicMessagesHandler(options) {
76
115
  const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
77
116
  releaseRequest();
78
117
  if (!upstream.ok) {
79
- sendUpstreamError(res, upstream, await upstream.text());
118
+ sendAnthropicUpstreamError(res, upstream, await readBoundedResponseText(upstream, {
119
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
120
+ label: "Copilot Chat error body",
121
+ }));
80
122
  return;
81
123
  }
82
124
  await requireUpstreamEventStream(upstream);
@@ -96,12 +138,15 @@ export function createAnthropicMessagesHandler(options) {
96
138
  async (event, data) => {
97
139
  if (event === "message_start") messageId = data.message?.id;
98
140
  if (event === "message_delta") usage = data.usage;
99
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
141
+ if (!await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)) {
142
+ throw DOWNSTREAM_CLOSED;
143
+ }
100
144
  },
101
145
  requestedModel,
102
146
  { forceModel: forceRequestedModel },
103
147
  );
104
148
  } catch (error) {
149
+ if (error === DOWNSTREAM_CLOSED) return;
105
150
  logRequestFailure("Messages", error, abort);
106
151
  await endStreamWithError(res, "anthropic", error, abort);
107
152
  return;
@@ -111,9 +156,14 @@ export function createAnthropicMessagesHandler(options) {
111
156
  } else {
112
157
  const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
113
158
  releaseRequest();
114
- const data = await upstream.text();
159
+ const data = upstream.ok
160
+ ? await upstream.text()
161
+ : await readBoundedResponseText(upstream, {
162
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
163
+ label: "Copilot Chat error body",
164
+ });
115
165
  if (!upstream.ok) {
116
- sendUpstreamError(res, upstream, data);
166
+ sendAnthropicUpstreamError(res, upstream, data);
117
167
  return;
118
168
  }
119
169
  const anthropicMessage = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
package/src/anthropic.mjs CHANGED
@@ -44,26 +44,10 @@ function toolResultContent(content) {
44
44
  }
45
45
 
46
46
  // Convert one Anthropic message into zero or more OpenAI messages.
47
- function convertMessage(msg) {
48
- const out = [];
49
- const role = msg.role; // "user" | "assistant"
50
-
51
- if (typeof msg.content === "string") {
52
- out.push({ role, content: msg.content });
53
- return out;
54
- }
55
- if (!Array.isArray(msg.content)) return out;
56
-
57
- // tool_result blocks must become independent role:"tool" messages.
58
- const toolResults = msg.content.filter((b) => b.type === "tool_result");
59
- for (const tr of toolResults) {
60
- out.push({ role: "tool", tool_call_id: tr.tool_use_id, content: toolResultContent(tr.content) });
61
- }
62
-
63
- // Convert remaining text, image, and tool_use blocks.
47
+ function convertContentBlocks(role, blocks) {
64
48
  const textImageParts = [];
65
49
  const toolCalls = [];
66
- for (const b of msg.content) {
50
+ for (const b of blocks) {
67
51
  if (b.type === "text") {
68
52
  textImageParts.push({ type: "text", text: b.text });
69
53
  } else if (b.type === "image") {
@@ -80,17 +64,66 @@ function convertMessage(msg) {
80
64
  const m = { role, content: null, tool_calls: toolCalls };
81
65
  if (textImageParts.length === 1 && textImageParts[0].type === "text") m.content = textImageParts[0].text;
82
66
  else if (textImageParts.length > 0) m.content = textImageParts;
83
- out.push(m);
67
+ return m;
84
68
  } else if (textImageParts.length > 0) {
85
69
  if (textImageParts.length === 1 && textImageParts[0].type === "text") {
86
- out.push({ role, content: textImageParts[0].text });
70
+ return { role, content: textImageParts[0].text };
87
71
  } else {
88
- out.push({ role, content: textImageParts });
72
+ return { role, content: textImageParts };
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function convertMessage(msg) {
79
+ const out = [];
80
+ const role = msg.role; // "user" | "assistant"
81
+
82
+ if (typeof msg.content === "string") {
83
+ out.push({ role, content: msg.content });
84
+ return out;
85
+ }
86
+ if (!Array.isArray(msg.content)) return out;
87
+
88
+ if (!msg.content.some((block) => block.type === "tool_result")) {
89
+ const converted = convertContentBlocks(role, msg.content);
90
+ if (converted) out.push(converted);
91
+ return out;
92
+ }
93
+
94
+ let contentBlocks = [];
95
+ const flushContent = () => {
96
+ const converted = convertContentBlocks(role, contentBlocks);
97
+ if (converted) out.push(converted);
98
+ contentBlocks = [];
99
+ };
100
+ for (const block of msg.content) {
101
+ if (block.type !== "tool_result") {
102
+ contentBlocks.push(block);
103
+ continue;
89
104
  }
105
+ flushContent();
106
+ out.push({
107
+ role: "tool",
108
+ tool_call_id: block.tool_use_id,
109
+ content: toolResultContent(block.content),
110
+ });
90
111
  }
112
+ flushContent();
91
113
  return out;
92
114
  }
93
115
 
116
+ function toolInputSchema(inputSchema) {
117
+ if (inputSchema
118
+ && typeof inputSchema === "object"
119
+ && !Array.isArray(inputSchema)
120
+ && inputSchema.type === "object"
121
+ && Object.keys(inputSchema).length === 1) {
122
+ return { type: "object", properties: {} };
123
+ }
124
+ return inputSchema;
125
+ }
126
+
94
127
  function mapToolChoice(tc) {
95
128
  if (!tc) return undefined;
96
129
  switch (tc.type) {
@@ -119,7 +152,7 @@ export function anthropicToChat(body, options = {}) {
119
152
  if (Array.isArray(body.tools) && body.tools.length) {
120
153
  chatReq.tools = body.tools.map((t) => ({
121
154
  type: "function",
122
- function: { name: t.name, description: t.description, parameters: t.input_schema },
155
+ function: { name: t.name, description: t.description, parameters: toolInputSchema(t.input_schema) },
123
156
  }));
124
157
  }
125
158
  const tc = mapToolChoice(body.tool_choice);
@@ -130,6 +163,10 @@ export function anthropicToChat(body, options = {}) {
130
163
 
131
164
  function uid() { return randomUUID().replace(/-/g, ""); }
132
165
 
166
+ function nonNegativeTokenCount(value) {
167
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
168
+ }
169
+
133
170
  function invalidToolArguments(toolName) {
134
171
  const name = String(toolName || "unknown").slice(0, 100);
135
172
  return invalidUpstreamStream(
@@ -165,10 +202,11 @@ export function chatToAnthropic(openaiResp, model, options = {}) {
165
202
  }
166
203
 
167
204
  const u = openaiResp.usage || {};
168
- const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
205
+ const prompt = nonNegativeTokenCount(u.prompt_tokens);
206
+ const cached = Math.min(prompt, nonNegativeTokenCount(u.prompt_tokens_details?.cached_tokens));
169
207
  const usage = {
170
- input_tokens: (u.prompt_tokens ?? 0) - cached,
171
- output_tokens: u.completion_tokens ?? 0,
208
+ input_tokens: prompt - cached,
209
+ output_tokens: nonNegativeTokenCount(u.completion_tokens),
172
210
  };
173
211
  if (cached > 0) usage.cache_read_input_tokens = cached;
174
212
 
@@ -248,7 +286,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
248
286
  if (upstreamError) throw upstreamError;
249
287
  if (!options.forceModel && parsed.model) actualModel = parsed.model;
250
288
  if (parsed.usage?.completion_tokens != null) {
251
- outputTokens = parsed.usage.completion_tokens;
289
+ outputTokens = nonNegativeTokenCount(parsed.usage.completion_tokens);
252
290
  markOutputTokens(outputTokens);
253
291
  }
254
292
  const choice = parsed.choices?.[0];
@@ -24,9 +24,12 @@ function syncDirectory(directory) {
24
24
  }
25
25
  }
26
26
 
27
- export function atomicWriteFileSync(filePath, data, { mode = 0o600 } = {}) {
27
+ export function atomicWriteFileSync(filePath, data, {
28
+ mode = 0o600,
29
+ preserveMode = true,
30
+ } = {}) {
28
31
  const directory = path.dirname(filePath);
29
- const finalMode = existingMode(filePath, mode);
32
+ const finalMode = preserveMode ? existingMode(filePath, mode) : mode;
30
33
  const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
31
34
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
32
35
 
@@ -50,6 +53,77 @@ export function atomicWriteFileSync(filePath, data, { mode = 0o600 } = {}) {
50
53
  }
51
54
  }
52
55
 
56
+ function fileSnapshot(filePath) {
57
+ try {
58
+ return {
59
+ exists: true,
60
+ data: fs.readFileSync(filePath),
61
+ mode: fs.statSync(filePath).mode & 0o777,
62
+ };
63
+ } catch (error) {
64
+ if (error?.code === "ENOENT") return { exists: false, data: null, mode: 0o600 };
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ function removeFile(filePath, unlinkFile) {
70
+ try {
71
+ unlinkFile(filePath);
72
+ } catch (error) {
73
+ if (error?.code !== "ENOENT") throw error;
74
+ }
75
+ }
76
+
77
+ function writeExactMode(filePath, data, mode, writeFile) {
78
+ writeFile(filePath, data, { mode, preserveMode: false });
79
+ fs.chmodSync(filePath, mode);
80
+ }
81
+
82
+ function restoreFile(filePath, snapshot, { writeFile, unlinkFile }) {
83
+ if (snapshot.exists) {
84
+ writeExactMode(filePath, snapshot.data, snapshot.mode, writeFile);
85
+ return;
86
+ }
87
+ removeFile(filePath, unlinkFile);
88
+ }
89
+
90
+ // Commit a primary file first and its metadata/activation marker last. A null
91
+ // secondary value removes stale metadata as the final transaction step.
92
+ export function atomicWriteFilePairSync(
93
+ primaryPath,
94
+ primaryData,
95
+ secondaryPath,
96
+ secondaryData,
97
+ {
98
+ mode = 0o600,
99
+ writeFile = atomicWriteFileSync,
100
+ unlinkFile = fs.unlinkSync,
101
+ } = {},
102
+ ) {
103
+ const primarySnapshot = fileSnapshot(primaryPath);
104
+ const secondarySnapshot = fileSnapshot(secondaryPath);
105
+
106
+ try {
107
+ writeExactMode(primaryPath, primaryData, mode, writeFile);
108
+ if (secondaryData === null) removeFile(secondaryPath, unlinkFile);
109
+ else writeExactMode(secondaryPath, secondaryData, mode, writeFile);
110
+ } catch (error) {
111
+ let rollbackError;
112
+ try {
113
+ restoreFile(primaryPath, primarySnapshot, { writeFile, unlinkFile });
114
+ } catch (cause) {
115
+ rollbackError = cause;
116
+ }
117
+ try {
118
+ restoreFile(secondaryPath, secondarySnapshot, { writeFile, unlinkFile });
119
+ } catch (cause) {
120
+ rollbackError ||= cause;
121
+ }
122
+ if (rollbackError) error.rollbackError = rollbackError;
123
+ throw error;
124
+ }
125
+ }
126
+
53
127
  export function atomicWriteFileIfChangedSync(filePath, data, options = {}) {
54
128
  try {
55
129
  const current = fs.readFileSync(filePath);
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { atomicWriteFileSync } from "./atomic-file.mjs";
4
+ import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
5
5
  import {
6
6
  githubTokenLockPath,
7
7
  githubTokenMetadataPath,
@@ -131,27 +131,11 @@ export function withAuthProfileLock(profile, fn, {
131
131
  return withFileLock(lockPath, fn, { ...lockOptions(env), ...options });
132
132
  }
133
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
134
  export function writeClaudeAuthProfile(token, identity, {
152
135
  home = os.homedir(),
153
136
  now = () => new Date(),
154
137
  writeFile = atomicWriteFileSync,
138
+ unlinkFile = fs.unlinkSync,
155
139
  } = {}) {
156
140
  const cleanToken = String(token || "").trim();
157
141
  const normalizedIdentity = normalizeGithubIdentity(identity);
@@ -163,8 +147,6 @@ export function writeClaudeAuthProfile(token, identity, {
163
147
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
164
148
  fs.chmodSync(directory, 0o700);
165
149
 
166
- const previousToken = readOptionalFile(paths.tokenPath);
167
- const previousMetadata = readOptionalFile(paths.metadataPath);
168
150
  const timestamp = now();
169
151
  const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
170
152
  const metadata = `${JSON.stringify({
@@ -174,20 +156,15 @@ export function writeClaudeAuthProfile(token, identity, {
174
156
  updated_at: updatedAt,
175
157
  }, null, 2)}\n`;
176
158
 
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
- }
159
+ // Commit the activation marker last. Readers never observe a configured
160
+ // Claude profile unless the token and its fingerprint agree.
161
+ atomicWriteFilePairSync(
162
+ paths.tokenPath,
163
+ cleanToken,
164
+ paths.metadataPath,
165
+ metadata,
166
+ { mode: 0o600, writeFile, unlinkFile },
167
+ );
191
168
 
192
169
  return {
193
170
  profile: AUTH_PROFILE_CLAUDE,