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/README.md +26 -15
- package/bin/cli.mjs +5 -0
- package/package.json +1 -1
- package/src/adapter.mjs +28 -7
- package/src/anthropic-handler.mjs +17 -3
- package/src/anthropic.mjs +9 -4
- package/src/atomic-file.mjs +76 -2
- package/src/auth-profile.mjs +11 -34
- package/src/auth.mjs +265 -52
- package/src/cli-auth.mjs +28 -71
- package/src/cli-options.mjs +8 -5
- package/src/copilot-client.mjs +5 -1
- package/src/copilot-responses-compat.mjs +9 -2
- package/src/copilot.mjs +2 -1
- package/src/http-transport.mjs +91 -0
- package/src/lock.mjs +179 -17
- package/src/models.mjs +17 -0
- package/src/pm-studio-asar.mjs +399 -19
- package/src/pm-studio-setup.mjs +319 -14
- package/src/pm-studio-status.mjs +5 -1
- package/src/response-history.mjs +122 -13
- package/src/responses-bridge.mjs +22 -4
- package/src/responses-handler.mjs +117 -35
- package/src/responses-image-pressure.mjs +30 -0
- package/src/responses-proxy.mjs +8 -1
- package/src/responses-request.mjs +17 -5
- package/src/usage.mjs +25 -7
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
|
|
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
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
733
|
+
function githubTokenMetadataData(identity, token, now = () => new Date()) {
|
|
540
734
|
const normalized = normalizeGithubIdentity(identity);
|
|
541
|
-
if (!normalized) return
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
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:
|
|
548
|
-
}, null, 2)}\n
|
|
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
|
-
|
|
556
|
-
fs.
|
|
557
|
-
fs.chmodSync(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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)
|
|
85
|
-
|
|
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
|
-
|
|
66
|
+
deviceCodeTimeoutMs,
|
|
67
|
+
sleepImpl,
|
|
98
68
|
now = Date.now,
|
|
99
69
|
} = {}) {
|
|
100
|
-
const
|
|
101
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/cli-options.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
package/src/copilot-client.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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 });
|