codex-copilot-dx 0.6.15 → 0.6.19

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
@@ -143,7 +143,8 @@ integrity, isolated Claude profile, and live relay routing without changing file
143
143
  ccdx pms status
144
144
  ```
145
145
 
146
- `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.
147
148
 
148
149
  `ccdx pms setup` performs local preflight, structural verification, backup,
149
150
  staging, integrity, signing, and replacement work. It does not access a PM
@@ -183,37 +184,47 @@ require reinstalling PM Studio. Setup prints the verified backup and
183
184
  manifest paths plus the version-matched restore procedure. Never restore a
184
185
  backup over a different PM Studio version or build.
185
186
 
186
- To restore, first quit PM Studio and its updater. Use the exact verified backup
187
- path printed by setup as `BACKUP_APP`, then confirm that both the installed App
188
- 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:
189
189
 
190
190
  ```bash
191
- BACKUP_APP="<verified-backup-path-from-ccdx-pms-setup>"
192
- /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
193
- /usr/bin/plutil -extract CFBundleVersion raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
194
- /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$BACKUP_APP/Contents/Info.plist"
195
- /usr/bin/plutil -extract CFBundleVersion raw -o - "$BACKUP_APP/Contents/Info.plist"
191
+ ccdx pms restore
196
192
  ```
197
193
 
198
- Only when all four values match each other and the version/build recorded in the
199
- source-bound manifest, move the patched App aside, restore the complete local
200
- 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:
201
212
 
202
213
  ```bash
214
+ BACKUP_APP="<exact-verified-backup-path>"
203
215
  RECOVERY_DIR="$(/usr/bin/mktemp -d '/Applications/CCDX-PM-Studio-recovery.XXXXXX')"
204
216
  PATCHED_APP="$RECOVERY_DIR/PM Studio.app"
205
217
  mv "/Applications/PM Studio.app" "$PATCHED_APP"
206
218
  /usr/bin/ditto --rsrc --extattr --acl "$BACKUP_APP" "/Applications/PM Studio.app"
207
- /usr/bin/codesign --verify --deep --strict --verbose=2 "/Applications/PM Studio.app"
208
219
  ccdx pms status
209
220
  ```
210
221
 
211
222
  The App-patch row from `ccdx pms status` must classify the restored bundle as
212
223
  the recorded clean local content rather than integrity drift. If the version
213
- check, copy, or either verification path fails,
224
+ check, copy, or content verification fails,
214
225
  do not launch the restored App. Keep the moved patched bundle at `$PATCHED_APP`
215
226
  and reinstall PM Studio instead of applying a version-mismatched
216
- backup. CCDX never runs these restore commands or `sudo` automatically.
227
+ backup. CCDX never uses `sudo` automatically.
217
228
 
218
229
  To change the model used by Codex Auto-review, run:
219
230
 
@@ -367,6 +378,8 @@ Environment variables:
367
378
  | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
368
379
  | `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
369
380
  | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Per-pool byte budget for raw bodies, decoded bodies, and materialized response history; a larger item runs exclusively in its pool |
381
+ | `CCDX_MAX_UPSTREAM_CHAT_RESPONSE_BYTES` | `67108864` | Maximum buffered successful Chat Completions response used by Responses, Anthropic, and PM Studio conversion paths |
382
+ | `CCDX_MAX_UPSTREAM_RESPONSES_RESPONSE_BYTES` | `268435456` | Maximum buffered successful native Responses or compact response; streaming Responses are forwarded with bounded backpressure instead |
370
383
  | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
371
384
  | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
372
385
  | `CCDX_REQUEST_BODY_TIMEOUT_MS` | `120000` | Maximum time to receive and decode one request body before returning `408` |
package/bin/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
17
  import { runInBackground } from "../src/startup.mjs";
18
18
  import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
19
19
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
20
+ import { formatCliError } from "../src/cli-error.mjs";
20
21
  import { closeHttpServer } from "../src/shutdown.mjs";
21
22
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
22
23
  import { autoReviewModelPreference } from "../src/user-settings.mjs";
@@ -143,11 +144,16 @@ if (CLI.command === "pms") {
143
144
  });
144
145
  process.exit(result.ok ? 0 : 1);
145
146
  }
147
+ if (CLI.action === "restore") {
148
+ const { runPmStudioRestore } = await import("../src/pm-studio-setup.mjs");
149
+ await runPmStudioRestore({ commandName: CLI_NAME });
150
+ process.exit(0);
151
+ }
146
152
  const { runPmStudioSetup } = await import("../src/pm-studio-setup.mjs");
147
153
  await runPmStudioSetup({ commandName: CLI_NAME });
148
154
  process.exit(0);
149
155
  } catch (e) {
150
- console.error(status("err", e.message));
156
+ console.error(formatCliError(e));
151
157
  process.exit(1);
152
158
  }
153
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.15",
3
+ "version": "0.6.19",
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
@@ -15,6 +15,7 @@ import { createPmStudioRelayHandler, PM_STUDIO_RELAY_PREFIX } from "./pm-studio-
15
15
  import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
16
16
  import { status } from "./status.mjs";
17
17
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
18
+ import { createTokenCounterService } from "./token-counter-service.mjs";
18
19
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
19
20
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
20
21
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
@@ -145,6 +146,11 @@ export function createAdapterHandler(options = {}) {
145
146
  const requestMetrics = options.requestMetrics || createRequestMetrics();
146
147
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
147
148
  const imagePressure = options.imagePressure || createResponsesImagePressureController();
149
+ const ownsTokenCounterService = !options.tokenCounterService;
150
+ const tokenCounterService = options.tokenCounterService || createTokenCounterService({
151
+ timeoutMs: options.countTokensTimeoutMs,
152
+ maxQueued: options.countTokensMaxQueued,
153
+ });
148
154
  const claudeDesktopModelOptions = () => {
149
155
  const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
150
156
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -155,6 +161,7 @@ export function createAdapterHandler(options = {}) {
155
161
  chatCompletionsFn: codexChatCompletionsFn,
156
162
  getCachedModelEndpointsFn,
157
163
  imagePressure,
164
+ modelRegistry: codexModelRegistry,
158
165
  now: options.now,
159
166
  openAIModelEnv,
160
167
  responsesPayloadOptions: options.responsesPayloadOptions,
@@ -168,6 +175,7 @@ export function createAdapterHandler(options = {}) {
168
175
  acquireRequest,
169
176
  autoReviewModelResolver: options.autoReviewModelResolver,
170
177
  imagePressure,
178
+ modelRegistry: codexModelRegistry,
171
179
  now: options.now,
172
180
  openAIModelEnv,
173
181
  requestBodyTimeoutMs,
@@ -176,7 +184,11 @@ export function createAdapterHandler(options = {}) {
176
184
  streamIdleTimeoutMs,
177
185
  upstreamTimeoutMs,
178
186
  });
179
- const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest, requestBodyTimeoutMs });
187
+ const anthropicCountTokensHandler = createAnthropicCountTokensHandler({
188
+ acquireRequest,
189
+ requestBodyTimeoutMs,
190
+ countTokensFn: (body, countOptions) => tokenCounterService.count(body, countOptions),
191
+ });
180
192
  const anthropicMessagesHandler = createAnthropicMessagesHandler({
181
193
  acquireRequest,
182
194
  chatCompletionsFn: claudeChatCompletionsFn,
@@ -294,8 +306,11 @@ export function createAdapterHandler(options = {}) {
294
306
  res.writeHead(404, { "Content-Type": "application/json" });
295
307
  res.end(JSON.stringify({ error: "Not found" }));
296
308
  };
309
+ const dispatchRequest = typeof options.dispatchRequestForTests === "function"
310
+ ? options.dispatchRequestForTests
311
+ : dispatch;
297
312
 
298
- return (req, res) => {
313
+ const handler = (req, res) => {
299
314
  const requestId = createRequestId();
300
315
  if (typeof res.setHeader === "function") {
301
316
  res.setHeader("X-Request-Id", requestId);
@@ -326,10 +341,28 @@ export function createAdapterHandler(options = {}) {
326
341
  const finishRequest = ({ statusCode = 0, aborted = false } = {}) => {
327
342
  if (requestFinished) return;
328
343
  requestFinished = true;
329
- complete({ statusCode, aborted });
330
- streamPerformance?.finish({ failed: aborted || statusCode >= 400 });
344
+ try { complete({ statusCode, aborted }); } catch {}
345
+ try { streamPerformance?.finish({ failed: aborted || statusCode >= 400 }); } catch {}
331
346
  try { finishTerminalActivity(); } catch {}
332
347
  };
348
+ const containUnexpectedError = (error) => {
349
+ try { finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 }); } catch {}
350
+ const safeCause = error instanceof Error ? error : new Error("Unexpected adapter failure");
351
+ try { logRequestFailure("Adapter", safeCause); } catch {}
352
+ try {
353
+ const safeError = httpError("Internal adapter error", 500);
354
+ safeError.jsonBody = {
355
+ error: {
356
+ message: safeError.message,
357
+ type: "server_error",
358
+ code: "ccdx_internal_error",
359
+ },
360
+ };
361
+ sendJsonError(res, safeError, 500);
362
+ } catch {
363
+ try { res.destroy?.(); } catch {}
364
+ }
365
+ };
333
366
  if (trackRequest && typeof res.once === "function") {
334
367
  res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
335
368
  res.once("close", () => finishRequest({
@@ -344,19 +377,21 @@ export function createAdapterHandler(options = {}) {
344
377
  pathname,
345
378
  showRequestId: options.showRequestId === true,
346
379
  streamPerformance,
347
- }, () => dispatch(req, res, pathname));
380
+ }, () => dispatchRequest(req, res, pathname));
348
381
  if (result && typeof result.then === "function") {
349
382
  return result.catch((error) => {
350
- finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
351
- throw error;
383
+ containUnexpectedError(error);
352
384
  });
353
385
  }
354
386
  return result;
355
387
  } catch (error) {
356
- finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
357
- throw error;
388
+ containUnexpectedError(error);
358
389
  }
359
390
  };
391
+ handler.cleanup = () => {
392
+ if (ownsTokenCounterService) tokenCounterService.close();
393
+ };
394
+ return handler;
360
395
  }
361
396
 
362
397
  // Public server entry point.
@@ -364,9 +399,11 @@ export function createAdapterHandler(options = {}) {
364
399
  export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
365
400
  const ownsTerminalActivity = !Object.hasOwn(options, "terminalActivity");
366
401
  const terminalActivity = ownsTerminalActivity ? createTerminalActivityIndicator() : options.terminalActivity;
367
- const server = http.createServer(createAdapterHandler({ ...options, terminalActivity }));
402
+ const handler = createAdapterHandler({ ...options, terminalActivity });
403
+ const server = http.createServer(handler);
368
404
  const cleanupTerminalActivity = () => {
369
405
  if (ownsTerminalActivity) terminalActivity?.cleanup?.();
406
+ handler.cleanup?.();
370
407
  };
371
408
  server.once("close", cleanupTerminalActivity);
372
409
 
@@ -7,7 +7,10 @@ import {
7
7
  import {
8
8
  createRequestAbort,
9
9
  logRequestFailure,
10
+ MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
11
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
12
  readJsonBody,
13
+ readBoundedResponseText,
11
14
  sendJsonError,
12
15
  sendUpstreamError,
13
16
  writeOrDrain,
@@ -21,6 +24,7 @@ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
21
24
  import { recordAnthropicUsage } from "./usage.mjs";
22
25
 
23
26
  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.";
27
+ const DOWNSTREAM_CLOSED = Symbol("downstream-closed");
24
28
 
25
29
  function contextWindowErrorBody(text) {
26
30
  let error;
@@ -57,7 +61,7 @@ function sendAnthropicUpstreamError(res, response, text) {
57
61
  }
58
62
 
59
63
  export function createAnthropicCountTokensHandler(options) {
60
- const { acquireRequest, requestBodyTimeoutMs } = options;
64
+ const { acquireRequest, requestBodyTimeoutMs, countTokensFn = countTokens } = options;
61
65
 
62
66
  return async function handleAnthropicCountTokens(req, res) {
63
67
  const abort = createRequestAbort(req, res);
@@ -67,12 +71,12 @@ export function createAnthropicCountTokensHandler(options) {
67
71
  abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
68
72
  const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
69
73
  abort.clearTimeout();
70
- const result = await countTokens(parsed);
74
+ const result = await countTokensFn(parsed, { signal: abort.signal });
71
75
  releaseRequest();
72
76
  res.writeHead(200, { "Content-Type": "application/json" });
73
77
  res.end(JSON.stringify(result));
74
78
  } catch (error) {
75
- sendJsonError(res, error, abort.reason === "request_body_timeout" ? 408 : 400);
79
+ sendJsonError(res, error, abort.reason === "request_body_timeout" ? 408 : 500);
76
80
  } finally {
77
81
  releaseRequest();
78
82
  abort.cleanup();
@@ -112,7 +116,10 @@ export function createAnthropicMessagesHandler(options) {
112
116
  const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
113
117
  releaseRequest();
114
118
  if (!upstream.ok) {
115
- sendAnthropicUpstreamError(res, upstream, await upstream.text());
119
+ sendAnthropicUpstreamError(res, upstream, await readBoundedResponseText(upstream, {
120
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
121
+ label: "Copilot Chat error body",
122
+ }));
116
123
  return;
117
124
  }
118
125
  await requireUpstreamEventStream(upstream);
@@ -132,12 +139,15 @@ export function createAnthropicMessagesHandler(options) {
132
139
  async (event, data) => {
133
140
  if (event === "message_start") messageId = data.message?.id;
134
141
  if (event === "message_delta") usage = data.usage;
135
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
142
+ if (!await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)) {
143
+ throw DOWNSTREAM_CLOSED;
144
+ }
136
145
  },
137
146
  requestedModel,
138
147
  { forceModel: forceRequestedModel },
139
148
  );
140
149
  } catch (error) {
150
+ if (error === DOWNSTREAM_CLOSED) return;
141
151
  logRequestFailure("Messages", error, abort);
142
152
  await endStreamWithError(res, "anthropic", error, abort);
143
153
  return;
@@ -147,7 +157,16 @@ export function createAnthropicMessagesHandler(options) {
147
157
  } else {
148
158
  const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
149
159
  releaseRequest();
150
- const data = await upstream.text();
160
+ const data = upstream.ok
161
+ ? await readBoundedResponseText(upstream, {
162
+ maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
163
+ label: "Copilot Chat success body",
164
+ signal: abort.signal,
165
+ })
166
+ : await readBoundedResponseText(upstream, {
167
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
168
+ label: "Copilot Chat error body",
169
+ });
151
170
  if (!upstream.ok) {
152
171
  sendAnthropicUpstreamError(res, upstream, data);
153
172
  return;
package/src/anthropic.mjs CHANGED
@@ -163,6 +163,10 @@ export function anthropicToChat(body, options = {}) {
163
163
 
164
164
  function uid() { return randomUUID().replace(/-/g, ""); }
165
165
 
166
+ function nonNegativeTokenCount(value) {
167
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
168
+ }
169
+
166
170
  function invalidToolArguments(toolName) {
167
171
  const name = String(toolName || "unknown").slice(0, 100);
168
172
  return invalidUpstreamStream(
@@ -198,10 +202,11 @@ export function chatToAnthropic(openaiResp, model, options = {}) {
198
202
  }
199
203
 
200
204
  const u = openaiResp.usage || {};
201
- 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));
202
207
  const usage = {
203
- input_tokens: (u.prompt_tokens ?? 0) - cached,
204
- output_tokens: u.completion_tokens ?? 0,
208
+ input_tokens: prompt - cached,
209
+ output_tokens: nonNegativeTokenCount(u.completion_tokens),
205
210
  };
206
211
  if (cached > 0) usage.cache_read_input_tokens = cached;
207
212
 
@@ -281,7 +286,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
281
286
  if (upstreamError) throw upstreamError;
282
287
  if (!options.forceModel && parsed.model) actualModel = parsed.model;
283
288
  if (parsed.usage?.completion_tokens != null) {
284
- outputTokens = parsed.usage.completion_tokens;
289
+ outputTokens = nonNegativeTokenCount(parsed.usage.completion_tokens);
285
290
  markOutputTokens(outputTokens);
286
291
  }
287
292
  const choice = parsed.choices?.[0];
@@ -345,8 +350,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
345
350
  await emit("message_stop", { type: "message_stop" });
346
351
  }
347
352
 
348
- // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
349
- export async function countTokens(body) {
353
+ export function anthropicTokenText(body) {
350
354
  const parts = [];
351
355
  const sys = systemToText(body.system);
352
356
  if (sys) parts.push(sys);
@@ -369,7 +373,15 @@ export async function countTokens(body) {
369
373
  }
370
374
  }
371
375
 
372
- const text = parts.join("\n");
376
+ return parts.join("\n");
377
+ }
378
+
379
+ export async function countTokenText(text) {
373
380
  const count = await tokenCounter();
374
381
  return { input_tokens: count(text) };
375
382
  }
383
+
384
+ // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
385
+ export async function countTokens(body) {
386
+ return countTokenText(anthropicTokenText(body));
387
+ }
@@ -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,