codex-copilot-dx 0.6.15 → 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/src/auth.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn } from "node:child_process";
5
+ import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
5
6
  import {
6
7
  githubIdentityMatchesExpected,
7
8
  githubTokenFingerprint,
@@ -15,6 +16,11 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Public GitHub Copilot client ID.
15
16
  const SCOPE = "read:user";
16
17
  const GITHUB_API = "https://api.github.com";
17
18
  const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
19
+ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
20
+ const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
21
+ const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
22
+ const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 30 * 1000;
23
+ const MAX_DEVICE_FLOW_JSON_BYTES = 64 * 1024;
18
24
  const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
19
25
  const MAX_AUTH_JSON_BYTES = 1024 * 1024;
20
26
 
@@ -48,7 +54,196 @@ export function interpretPoll(data) {
48
54
  }
49
55
  }
50
56
 
51
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
57
+ function abortError(signal) {
58
+ if (signal?.reason instanceof Error) return signal.reason;
59
+ const error = new Error("The operation was aborted");
60
+ error.name = "AbortError";
61
+ return error;
62
+ }
63
+
64
+ function sleep(ms, { signal } = {}) {
65
+ if (signal?.aborted) return Promise.reject(abortError(signal));
66
+ return new Promise((resolve, reject) => {
67
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
68
+ const onAbort = () => {
69
+ clearTimeout(timer);
70
+ cleanup();
71
+ reject(abortError(signal));
72
+ };
73
+ const timer = setTimeout(() => {
74
+ cleanup();
75
+ resolve();
76
+ }, ms);
77
+ signal?.addEventListener("abort", onAbort, { once: true });
78
+ });
79
+ }
80
+
81
+ function awaitWithSignal(operation, signal) {
82
+ if (signal?.aborted) return Promise.reject(abortError(signal));
83
+ return new Promise((resolve, reject) => {
84
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
85
+ const finish = (callback, value) => {
86
+ cleanup();
87
+ callback(value);
88
+ };
89
+ const onAbort = () => finish(reject, abortError(signal));
90
+ signal?.addEventListener("abort", onAbort, { once: true });
91
+ Promise.resolve()
92
+ .then(operation)
93
+ .then((value) => finish(resolve, value), (error) => finish(reject, error));
94
+ });
95
+ }
96
+
97
+ async function discardBoundedResponseBody(response) {
98
+ const transport = await import("./http-transport.mjs");
99
+ return transport.discardBoundedResponseBody(response);
100
+ }
101
+
102
+ async function readDeviceFlowJson(response, signal, label) {
103
+ if (typeof response?.body?.getReader === "function") {
104
+ const transport = await import("./http-transport.mjs");
105
+ const text = await awaitWithSignal(() => transport.readBoundedResponseText(response, {
106
+ maxBytes: MAX_DEVICE_FLOW_JSON_BYTES,
107
+ label,
108
+ }), signal);
109
+ return JSON.parse(text);
110
+ }
111
+ return awaitWithSignal(() => response.json(), signal);
112
+ }
113
+
114
+ function cancelResponseBody(response) {
115
+ try { Promise.resolve(response?.body?.cancel?.()).catch(() => {}); } catch {}
116
+ }
117
+
118
+ function deviceCodeTimeoutError(timeoutMs) {
119
+ const error = new Error(`Device code request timed out after ${timeoutMs}ms`);
120
+ error.code = "CCDX_DEVICE_CODE_TIMEOUT";
121
+ return error;
122
+ }
123
+
124
+ export async function requestGithubDeviceCode({
125
+ fetchImpl = fetch,
126
+ signal,
127
+ timeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
128
+ } = {}) {
129
+ const parsedTimeout = Number(timeoutMs);
130
+ const deadlineMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0
131
+ ? parsedTimeout
132
+ : DEFAULT_DEVICE_CODE_TIMEOUT_MS;
133
+ const controller = new AbortController();
134
+ let response;
135
+ const onCallerAbort = () => controller.abort(signal.reason);
136
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
137
+ if (signal?.aborted) onCallerAbort();
138
+ const timer = setTimeout(() => controller.abort(deviceCodeTimeoutError(deadlineMs)), deadlineMs);
139
+ try {
140
+ response = await awaitWithSignal(() => fetchImpl("https://github.com/login/device/code", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
143
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
144
+ signal: controller.signal,
145
+ }), controller.signal);
146
+ if (!response.ok) {
147
+ await awaitWithSignal(() => discardBoundedResponseBody(response), controller.signal);
148
+ return { response, data: null, jsonError: null };
149
+ }
150
+ try {
151
+ const data = await readDeviceFlowJson(response, controller.signal, "Device code response");
152
+ return { response, data, jsonError: null };
153
+ } catch (error) {
154
+ if (controller.signal.aborted) throw error;
155
+ return { response, data: null, jsonError: error };
156
+ }
157
+ } catch (error) {
158
+ cancelResponseBody(response);
159
+ if (signal?.aborted) throw abortError(signal);
160
+ if (controller.signal.aborted) throw abortError(controller.signal);
161
+ throw error;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ signal?.removeEventListener("abort", onCallerAbort);
165
+ }
166
+ }
167
+
168
+ export async function pollGithubDeviceFlow({
169
+ deviceCode,
170
+ interval,
171
+ expiresIn,
172
+ fetchImpl = fetch,
173
+ signal,
174
+ sleepImpl = sleep,
175
+ now = Date.now,
176
+ } = {}) {
177
+ const parsedInterval = Number(interval);
178
+ const parsedExpires = Number(expiresIn);
179
+ let waitMs = (Number.isFinite(parsedInterval) && parsedInterval > 0
180
+ ? parsedInterval
181
+ : DEFAULT_DEVICE_INTERVAL_SECONDS) * 1000;
182
+ const expiresMs = (Number.isFinite(parsedExpires) && parsedExpires > 0
183
+ ? parsedExpires
184
+ : DEFAULT_DEVICE_EXPIRES_SECONDS) * 1000;
185
+ const deadline = now() + expiresMs;
186
+
187
+ while (true) {
188
+ const remainingMs = deadline - now();
189
+ if (remainingMs <= 0) return { state: "expired" };
190
+ await sleepImpl(Math.min(waitMs, remainingMs), { signal });
191
+ if (signal?.aborted) throw abortError(signal);
192
+ if (now() >= deadline) return { state: "expired" };
193
+
194
+ const pollController = new AbortController();
195
+ let deadlineExpired = false;
196
+ const onCallerAbort = () => pollController.abort(signal.reason);
197
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
198
+ if (signal?.aborted) onCallerAbort();
199
+ const deadlineTimer = setTimeout(() => {
200
+ deadlineExpired = true;
201
+ pollController.abort();
202
+ }, Math.max(1, deadline - now()));
203
+ let result;
204
+ let pollResponse;
205
+ try {
206
+ pollResponse = await fetchImpl(ACCESS_TOKEN_URL, {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
209
+ body: JSON.stringify({
210
+ client_id: CLIENT_ID,
211
+ device_code: deviceCode,
212
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
213
+ }),
214
+ signal: pollController.signal,
215
+ });
216
+ if (now() >= deadline) {
217
+ await awaitWithSignal(() => discardBoundedResponseBody(pollResponse), pollController.signal);
218
+ return { state: "expired" };
219
+ }
220
+ if (!pollResponse.ok) {
221
+ await awaitWithSignal(() => discardBoundedResponseBody(pollResponse), pollController.signal);
222
+ if (now() >= deadline) return { state: "expired" };
223
+ continue;
224
+ }
225
+ result = interpretPoll(await readDeviceFlowJson(
226
+ pollResponse,
227
+ pollController.signal,
228
+ "Device Flow poll response",
229
+ ));
230
+ if (now() >= deadline) return { state: "expired" };
231
+ } catch (error) {
232
+ cancelResponseBody(pollResponse);
233
+ if (signal?.aborted) throw abortError(signal);
234
+ if (deadlineExpired || now() >= deadline) return { state: "expired" };
235
+ throw error;
236
+ } finally {
237
+ clearTimeout(deadlineTimer);
238
+ signal?.removeEventListener("abort", onCallerAbort);
239
+ }
240
+ if (result.state === "slow") {
241
+ waitMs += 5000;
242
+ continue;
243
+ }
244
+ if (result.state !== "wait") return result;
245
+ }
246
+ }
52
247
 
53
248
  function expandHome(filePath, home = os.homedir()) {
54
249
  if (!filePath) return "";
@@ -465,6 +660,9 @@ export async function ensureAuth({
465
660
  signal,
466
661
  log = console.log,
467
662
  openAndCopyFn = openAndCopy,
663
+ deviceCodeTimeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
664
+ sleepImpl = sleep,
665
+ now = Date.now,
468
666
  } = {}) {
469
667
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
470
668
  if (fs.existsSync(GITHUB_TOKEN_PATH)) {
@@ -490,74 +688,89 @@ export async function ensureAuth({
490
688
 
491
689
  // Request a device code while holding the auth lock so concurrent starts do not
492
690
  // trigger multiple browser/device-flow sessions for the same local token.
493
- const codeResp = await fetchImpl("https://github.com/login/device/code", {
494
- method: "POST",
495
- headers: { "Content-Type": "application/json", Accept: "application/json" },
496
- body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
691
+ const codeResult = await requestGithubDeviceCode({
692
+ fetchImpl,
497
693
  signal,
694
+ timeoutMs: deviceCodeTimeoutMs,
498
695
  });
499
- if (!codeResp.ok) throw new Error(`device code request failed: ${codeResp.status}`);
500
- const { device_code, user_code, verification_uri, interval } = await codeResp.json();
696
+ const codeResp = codeResult.response;
697
+ if (!codeResp.ok) {
698
+ throw new Error(`device code request failed: ${codeResp.status}`);
699
+ }
700
+ if (codeResult.jsonError) throw codeResult.jsonError;
701
+ const {
702
+ device_code,
703
+ user_code,
704
+ verification_uri,
705
+ interval,
706
+ expires_in,
707
+ } = codeResult.data;
501
708
 
502
709
  // Prompt the user.
503
710
  log(`\n${status("info", `Open ${verification_uri}`)}\n${status("info", `Enter code: ${user_code}`)}\n`);
504
711
  openAndCopyFn(user_code, verification_uri);
505
712
 
506
- // Poll until GitHub completes the device flow.
507
- let waitMs = (interval || 5) * 1000;
508
- while (true) {
509
- await sleep(waitMs);
510
- const pollResp = await fetchImpl("https://github.com/login/oauth/access_token", {
511
- method: "POST",
512
- headers: { "Content-Type": "application/json", Accept: "application/json" },
513
- body: JSON.stringify({
514
- client_id: CLIENT_ID,
515
- device_code,
516
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
517
- }),
518
- signal,
519
- });
520
- if (!pollResp.ok) {
521
- // Treat transient network/server errors as pending and retry.
522
- continue;
523
- }
524
- const data = await pollResp.json();
525
- const r = interpretPoll(data);
526
- if (r.state === "done") {
527
- const identity = await fetchGithubIdentity(r.token, { fetchImpl, signal });
528
- writeToken(r.token, home, identity.ok ? identity : null);
529
- log(status("ok", "Login successful"));
530
- return;
531
- }
532
- if (r.state === "slow") { waitMs += 5000; continue; }
533
- if (r.state === "fail") throw new Error(`Login failed: ${r.error}`);
534
- // wait: continue polling.
713
+ const result = await pollGithubDeviceFlow({
714
+ deviceCode: device_code,
715
+ interval,
716
+ expiresIn: expires_in,
717
+ fetchImpl,
718
+ signal,
719
+ sleepImpl,
720
+ now,
721
+ });
722
+ if (result.state === "done") {
723
+ const identity = await fetchGithubIdentity(result.token, { fetchImpl, signal });
724
+ writeToken(result.token, home, identity.ok ? identity : null);
725
+ log(status("ok", "Login successful"));
726
+ return;
535
727
  }
728
+ if (result.state === "expired") throw new Error("Login failed: device code expired");
729
+ throw new Error(`Login failed: ${result.error}`);
536
730
  }, tokenLockOptions(env));
537
731
  }
538
732
 
539
- export function writeGithubTokenMetadata(identity, home = os.homedir(), token = "") {
733
+ function githubTokenMetadataData(identity, token, now = () => new Date()) {
540
734
  const normalized = normalizeGithubIdentity(identity);
541
- if (!normalized) return false;
542
- const filePath = githubTokenMetadataPath(home);
543
- fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
544
- fs.writeFileSync(filePath, `${JSON.stringify({
735
+ if (!normalized) return null;
736
+ const timestamp = now();
737
+ const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
738
+ return `${JSON.stringify({
545
739
  ...normalized,
546
740
  token_fingerprint: githubTokenFingerprint(token),
547
- updated_at: new Date().toISOString(),
548
- }, null, 2)}\n`, { mode: 0o600 });
741
+ updated_at: updatedAt,
742
+ }, null, 2)}\n`;
743
+ }
744
+
745
+ export function writeGithubTokenMetadata(identity, home = os.homedir(), token = "", {
746
+ now = () => new Date(),
747
+ writeFile = atomicWriteFileSync,
748
+ } = {}) {
749
+ const metadata = githubTokenMetadataData(identity, token, now);
750
+ if (metadata === null) return false;
751
+ const filePath = githubTokenMetadataPath(home);
752
+ const directory = path.dirname(filePath);
753
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
754
+ fs.chmodSync(directory, 0o700);
755
+ writeFile(filePath, metadata, { mode: 0o600, preserveMode: false });
549
756
  fs.chmodSync(filePath, 0o600);
550
757
  return true;
551
758
  }
552
759
 
553
- export function writeToken(token, home = os.homedir(), identity = null) {
760
+ export function writeToken(token, home = os.homedir(), identity = null, {
761
+ now = () => new Date(),
762
+ writeFile = atomicWriteFileSync,
763
+ unlinkFile = fs.unlinkSync,
764
+ } = {}) {
554
765
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
555
- fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
556
- fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
557
- fs.chmodSync(GITHUB_TOKEN_PATH, 0o600);
558
- if (normalizeGithubIdentity(identity)) {
559
- writeGithubTokenMetadata(identity, home, token);
560
- } else {
561
- try { fs.unlinkSync(githubTokenMetadataPath(home)); } catch (e) { if (e?.code !== "ENOENT") throw e; }
562
- }
766
+ const directory = path.dirname(GITHUB_TOKEN_PATH);
767
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
768
+ fs.chmodSync(directory, 0o700);
769
+ atomicWriteFilePairSync(
770
+ GITHUB_TOKEN_PATH,
771
+ token,
772
+ githubTokenMetadataPath(home),
773
+ githubTokenMetadataData(identity, token, now),
774
+ { mode: 0o600, writeFile, unlinkFile },
775
+ );
563
776
  }
package/src/cli-auth.mjs CHANGED
@@ -3,7 +3,8 @@ import { spawn } from "node:child_process";
3
3
  import {
4
4
  discoverGithubToken,
5
5
  fetchGithubIdentity,
6
- interpretPoll,
6
+ pollGithubDeviceFlow,
7
+ requestGithubDeviceCode,
7
8
  sourceDescription,
8
9
  validateGithubToken,
9
10
  } from "./auth.mjs";
@@ -31,37 +32,6 @@ import {
31
32
  terminalCell,
32
33
  } from "./cli-table.mjs";
33
34
 
34
- const CLIENT_ID = "Iv1.b507a08c87ecfe98";
35
- const SCOPE = "read:user";
36
- const DEVICE_CODE_URL = "https://github.com/login/device/code";
37
- const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
38
- const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
39
- const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
40
-
41
- function abortError(signal) {
42
- if (signal?.reason instanceof Error) return signal.reason;
43
- const error = new Error("The operation was aborted");
44
- error.name = "AbortError";
45
- return error;
46
- }
47
-
48
- function sleep(ms, { signal } = {}) {
49
- if (signal?.aborted) return Promise.reject(abortError(signal));
50
- return new Promise((resolve, reject) => {
51
- const cleanup = () => signal?.removeEventListener("abort", onAbort);
52
- const onAbort = () => {
53
- clearTimeout(timer);
54
- cleanup();
55
- reject(abortError(signal));
56
- };
57
- const timer = setTimeout(() => {
58
- cleanup();
59
- resolve();
60
- }, ms);
61
- signal?.addEventListener("abort", onAbort, { once: true });
62
- });
63
- }
64
-
65
35
  function openAndCopy(userCode, verificationUri) {
66
36
  if (process.platform !== "darwin") return;
67
37
  try {
@@ -80,13 +50,12 @@ function responseDetail(value) {
80
50
  return String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
81
51
  }
82
52
 
83
- async function jsonResponse(response, label) {
84
- if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}`);
85
- try {
86
- return await response.json();
87
- } catch {
88
- throw new Error(`${label} returned invalid JSON`);
53
+ async function jsonResponse({ response, data, jsonError }, label) {
54
+ if (!response.ok) {
55
+ throw new Error(`${label} failed with HTTP ${response.status}`);
89
56
  }
57
+ if (jsonError) throw new Error(`${label} returned invalid JSON`);
58
+ return data;
90
59
  }
91
60
 
92
61
  export async function requestDeviceFlowToken({
@@ -94,16 +63,16 @@ export async function requestDeviceFlowToken({
94
63
  signal,
95
64
  log = console.log,
96
65
  openAndCopyFn = openAndCopy,
97
- sleepImpl = sleep,
66
+ deviceCodeTimeoutMs,
67
+ sleepImpl,
98
68
  now = Date.now,
99
69
  } = {}) {
100
- const codeResponse = await fetchImpl(DEVICE_CODE_URL, {
101
- method: "POST",
102
- headers: { "Content-Type": "application/json", Accept: "application/json" },
103
- body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
70
+ const codeResult = await requestGithubDeviceCode({
71
+ fetchImpl,
104
72
  signal,
73
+ timeoutMs: deviceCodeTimeoutMs,
105
74
  });
106
- const code = await jsonResponse(codeResponse, "Device code request");
75
+ const code = await jsonResponse(codeResult, "Device code request");
107
76
  if (!code.device_code || !code.user_code || !code.verification_uri) {
108
77
  throw new Error("Device code response is missing required fields");
109
78
  }
@@ -111,32 +80,18 @@ export async function requestDeviceFlowToken({
111
80
  log(`\n${status("info", `Open ${code.verification_uri}`)}\n${status("info", `Enter Claude account code: ${code.user_code}`)}\n`);
112
81
  openAndCopyFn(code.user_code, code.verification_uri);
113
82
 
114
- let waitMs = (Number(code.interval) || DEFAULT_DEVICE_INTERVAL_SECONDS) * 1000;
115
- const expiresMs = (Number(code.expires_in) || DEFAULT_DEVICE_EXPIRES_SECONDS) * 1000;
116
- const deadline = now() + expiresMs;
117
-
118
- while (now() < deadline) {
119
- await sleepImpl(waitMs, { signal });
120
- const pollResponse = await fetchImpl(ACCESS_TOKEN_URL, {
121
- method: "POST",
122
- headers: { "Content-Type": "application/json", Accept: "application/json" },
123
- body: JSON.stringify({
124
- client_id: CLIENT_ID,
125
- device_code: code.device_code,
126
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
127
- }),
128
- signal,
129
- });
130
- if (!pollResponse.ok) continue;
131
- const result = interpretPoll(await pollResponse.json());
132
- if (result.state === "done") return result.token;
133
- if (result.state === "slow") {
134
- waitMs += 5000;
135
- continue;
136
- }
137
- if (result.state === "fail") throw new Error(`Claude login failed: ${result.error}`);
138
- }
139
- throw new Error("Claude login failed: device code expired");
83
+ const result = await pollGithubDeviceFlow({
84
+ deviceCode: code.device_code,
85
+ interval: code.interval,
86
+ expiresIn: code.expires_in,
87
+ fetchImpl,
88
+ signal,
89
+ sleepImpl,
90
+ now,
91
+ });
92
+ if (result.state === "done") return result.token;
93
+ if (result.state === "expired") throw new Error("Claude login failed: device code expired");
94
+ throw new Error(`Claude login failed: ${result.error}`);
140
95
  }
141
96
 
142
97
  async function codexIdentity({ home, fetchImpl, signal }) {
@@ -249,7 +204,8 @@ export async function authorizeClaudeProfile({
249
204
  signal,
250
205
  log = console.log,
251
206
  openAndCopyFn = openAndCopy,
252
- sleepImpl = sleep,
207
+ deviceCodeTimeoutMs,
208
+ sleepImpl,
253
209
  now = Date.now,
254
210
  saveModelCacheFn = saveModelCache,
255
211
  } = {}) {
@@ -302,6 +258,7 @@ export async function authorizeClaudeProfile({
302
258
  signal,
303
259
  log,
304
260
  openAndCopyFn,
261
+ deviceCodeTimeoutMs,
305
262
  sleepImpl,
306
263
  now,
307
264
  });
@@ -18,6 +18,7 @@ const HELP_TOPICS = new Set([
18
18
  "pms",
19
19
  "pms setup",
20
20
  "pms status",
21
+ "pms restore",
21
22
  "usage",
22
23
  "auto-review-model",
23
24
  "update",
@@ -154,9 +155,9 @@ export function parseCliArgs(args = []) {
154
155
  }
155
156
  if (PM_COMMANDS.has(command)) {
156
157
  const [action, ...pmsArgs] = rest;
157
- if (!action) throw new Error("Missing pms action: expected setup or status");
158
- if (!new Set(["setup", "status"]).has(action)) throw new Error(`Unknown pms action: ${action}`);
159
- if (action === "setup") {
158
+ if (!action) throw new Error("Missing pms action: expected setup, status, or restore");
159
+ if (!new Set(["setup", "status", "restore"]).has(action)) throw new Error(`Unknown pms action: ${action}`);
160
+ if (action === "setup" || action === "restore") {
160
161
  if (pmsArgs.length) unexpectedArgs(pmsArgs);
161
162
  return baseCommand("pms", { action });
162
163
  }
@@ -292,9 +293,10 @@ function topicHelp(name, topic) {
292
293
  doctor: `Usage:\n ${name} doctor [--online] [--compat] [--profile codex|claude|all]\n\nChecks local credentials, client configuration, PM Studio state, and the adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage.`,
293
294
  status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, cache, queue, and PM relay metrics from a running adapter.`,
294
295
  models: `Usage:\n ${name} models [--profile codex|claude] [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for one saved profile. Interactive terminals use a table by default.`,
295
- pms: `Usage:\n ${name} pms status [--format table|plain]\n ${name} pms setup\n ${name} pm-studio status [--format table|plain]\n ${name} pm-studio setup\n\nInspects or installs the fail-closed PM Studio split-origin patch. setup modifies the installed app only after exact or semantic compatibility and source checks pass.`,
296
+ pms: `Usage:\n ${name} pms status [--format table|plain]\n ${name} pms setup\n ${name} pms restore\n ${name} pm-studio status [--format table|plain]\n ${name} pm-studio setup\n ${name} pm-studio restore\n\nInspects, installs, or restores the fail-closed PM Studio split-origin patch. setup and restore modify the installed app only after source-bound compatibility and backup checks pass.`,
296
297
  "pms setup": `Usage:\n ${name} pms setup\n\nBacks up, patches, verifies, and ad-hoc signs an exact or structurally compatible PM Studio bundle. Unverifiable versions and drift are never modified.`,
297
298
  "pms status": `Usage:\n ${name} pms status [--format table|plain]\n\nRead-only inspection of the installed PM Studio version, patch integrity, Claude profile, and relay availability. Interactive terminals use a table by default.`,
299
+ "pms restore": `Usage:\n ${name} pms restore\n\nRestores the installed PM Studio bundle from its exact source-bound verified backup. The explicit command authorizes the change; no confirmation prompt is shown. Missing, ambiguous, version-mismatched, or drifted records are never restored.`,
298
300
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
299
301
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an enabled Responses model for Codex Auto-review.`,
300
302
  update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
@@ -316,6 +318,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
316
318
  ${name} models [--profile codex|claude] [--format table|plain]
317
319
  ${name} pms status [--format table|plain]
318
320
  ${name} pms setup
321
+ ${name} pms restore
319
322
  ${name} usage [--format table|plain]
320
323
  ${name} auto-review-model
321
324
  ${name} update [npm|github]
@@ -328,7 +331,7 @@ Commands:
328
331
  doctor Diagnose local config; optional live and inference checks
329
332
  status Show runtime routing, performance, queue, and cache health
330
333
  models Query a saved account's live Copilot model catalog
331
- pms, pm-studio Inspect or install the PM Studio patch
334
+ pms, pm-studio Inspect, install, or restore the PM Studio patch
332
335
  usage Summarize locally recorded token usage
333
336
  auto-review-model Select the Codex Auto-review Responses model
334
337
  update Update the global package from npm or GitHub
@@ -1,4 +1,5 @@
1
1
  import { createCopilotTokenSession } from "./copilot-token-session.mjs";
2
+ import { MAX_UPSTREAM_MODEL_CATALOG_BYTES, readBoundedResponseText } from "./http-transport.mjs";
2
3
  import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
3
4
  import { debugLog } from "./log.mjs";
4
5
  import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
@@ -117,7 +118,10 @@ export function createCopilotClientRuntime({
117
118
  headers,
118
119
  signal,
119
120
  }, { fetchImpl, ...retryOptions });
120
- const body = await response.text();
121
+ const body = await readBoundedResponseText(response, {
122
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
123
+ label: "Copilot model catalog",
124
+ });
121
125
  if (response.ok) {
122
126
  try { cacheModelEndpoints(JSON.parse(body)); } catch {}
123
127
  }
@@ -4,7 +4,11 @@ import {
4
4
  isImageNamespaceCollisionError,
5
5
  sanitizeImageNamespaceCollisionRequest,
6
6
  } from "./copilot-responses-policy.mjs";
7
- import { httpError } from "./http-transport.mjs";
7
+ import {
8
+ httpError,
9
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
+ readBoundedResponseText,
11
+ } from "./http-transport.mjs";
8
12
  import {
9
13
  finalizeEncryptedHistoryRebase,
10
14
  sanitizeEncryptedReasoningRequest,
@@ -48,7 +52,10 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
48
52
  return { resp, reqContext };
49
53
  }
50
54
 
51
- const errorText = await resp.text();
55
+ const errorText = await readBoundedResponseText(resp, {
56
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
57
+ label: "Copilot Responses error body",
58
+ });
52
59
  options.assertPrepareActive?.();
53
60
  let retryPolicy = null;
54
61
  let retryContext = null;
package/src/copilot.mjs CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { status } from "./status.mjs";
3
3
  import { debugLog } from "./log.mjs";
4
4
  import { createCopilotClientRuntime } from "./copilot-client.mjs";
5
+ import { discardBoundedResponseBody } from "./http-transport.mjs";
5
6
 
6
7
  export {
7
8
  optimizeImageDataUrl,
@@ -156,7 +157,7 @@ export async function fetchCopilotUpstream(
156
157
  const resp = await fetchImpl(url, init);
157
158
  debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
158
159
  if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
159
- await resp.arrayBuffer?.().catch(() => {});
160
+ await discardBoundedResponseBody(resp);
160
161
  const delay = upstreamRetryDelay(attempt, baseDelay);
161
162
  console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
162
163
  await sleep(delay, { signal });