codex-copilot-dx 0.4.23 → 0.4.26
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 +35 -3
- package/bin/cli.mjs +137 -44
- package/package.json +9 -2
- package/src/adapter.mjs +400 -282
- package/src/atomic-file.mjs +63 -0
- package/src/auth.mjs +148 -7
- package/src/claude-config.mjs +7 -7
- package/src/claude-desktop-config.mjs +3 -4
- package/src/cli-options.mjs +69 -0
- package/src/config.mjs +10 -6
- package/src/copilot.mjs +29 -165
- package/src/doctor.mjs +82 -7
- package/src/file-rotation.mjs +30 -0
- package/src/http-transport.mjs +182 -0
- package/src/image-optimization.mjs +151 -0
- package/src/log.mjs +9 -1
- package/src/model-cache.mjs +45 -0
- package/src/response-history.mjs +195 -0
- package/src/running-adapter.mjs +22 -0
- package/src/shutdown.mjs +29 -0
- package/src/startup.mjs +19 -0
- package/src/stream.mjs +2 -1
- package/src/usage.mjs +57 -14
package/src/copilot.mjs
CHANGED
|
@@ -3,24 +3,43 @@ import os from "node:os";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { debugLog } from "./log.mjs";
|
|
6
|
-
import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
|
|
6
|
+
import { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
|
|
7
|
+
import {
|
|
8
|
+
optimizeImageDataUrl,
|
|
9
|
+
optimizeImagesInBody,
|
|
10
|
+
parseImageConcurrency,
|
|
11
|
+
runWithConcurrency,
|
|
12
|
+
summarizeReqBody,
|
|
13
|
+
} from "./image-optimization.mjs";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
optimizeImageDataUrl,
|
|
17
|
+
optimizeImagesInBody,
|
|
18
|
+
parseImageConcurrency,
|
|
19
|
+
runWithConcurrency,
|
|
20
|
+
summarizeReqBody,
|
|
21
|
+
} from "./image-optimization.mjs";
|
|
7
22
|
|
|
8
23
|
export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
|
|
9
24
|
let apiBase = DEFAULT_API_BASE;
|
|
10
25
|
|
|
11
26
|
// Cache of model id -> supported_endpoints, populated from listModels(). Used to
|
|
12
27
|
// route requests to /responses vs /chat/completions based on real model metadata.
|
|
13
|
-
|
|
28
|
+
let modelEndpointCache = new Map();
|
|
14
29
|
|
|
15
30
|
export function cacheModelEndpoints(models) {
|
|
16
31
|
const data = Array.isArray(models) ? models : models?.data;
|
|
17
|
-
if (!Array.isArray(data)) return;
|
|
32
|
+
if (!Array.isArray(data)) return false;
|
|
33
|
+
const next = new Map();
|
|
18
34
|
for (const model of data) {
|
|
19
35
|
const id = String(model?.id || "").trim();
|
|
20
36
|
if (id && Array.isArray(model?.supported_endpoints)) {
|
|
21
|
-
|
|
37
|
+
next.set(id, [...model.supported_endpoints]);
|
|
22
38
|
}
|
|
23
39
|
}
|
|
40
|
+
if (!next.size) return false;
|
|
41
|
+
modelEndpointCache = next;
|
|
42
|
+
return true;
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
// Returns the cached supported_endpoints for a model id, or null if unknown.
|
|
@@ -31,25 +50,12 @@ export function getCachedModelEndpoints(modelId) {
|
|
|
31
50
|
export function resetModelEndpointCacheForTests() {
|
|
32
51
|
modelEndpointCache.clear();
|
|
33
52
|
}
|
|
34
|
-
const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
|
|
35
|
-
const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
|
|
36
|
-
const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
|
|
37
|
-
const DEFAULT_IMG_CONCURRENCY = 4;
|
|
38
|
-
const IMG_MAX_CONCURRENCY = 12;
|
|
39
53
|
const DEFAULT_UPSTREAM_RETRIES = 2;
|
|
40
54
|
const MAX_UPSTREAM_RETRIES = 5;
|
|
41
55
|
const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
|
|
42
56
|
const MAX_UPSTREAM_RETRY_DELAY_MS = 5000;
|
|
43
|
-
const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
|
|
44
|
-
const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
|
|
45
57
|
const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
|
|
46
58
|
const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
|
|
47
|
-
let sharpImport = null;
|
|
48
|
-
|
|
49
|
-
export function parseImageConcurrency(value) {
|
|
50
|
-
const n = Number.parseInt(value, 10);
|
|
51
|
-
return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
|
|
52
|
-
}
|
|
53
59
|
|
|
54
60
|
export function parseUpstreamRetries(value) {
|
|
55
61
|
const n = Number.parseInt(value, 10);
|
|
@@ -196,149 +202,6 @@ export async function fetchCopilotUpstream(
|
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
async function sharp() {
|
|
200
|
-
sharpImport ||= import("sharp");
|
|
201
|
-
const mod = await sharpImport;
|
|
202
|
-
return mod.default || mod;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export async function optimizeImageDataUrl(dataUrl) {
|
|
206
|
-
if (IMG_OPT_DISABLED) return dataUrl;
|
|
207
|
-
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
|
|
208
|
-
const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
|
|
209
|
-
if (!match) return dataUrl;
|
|
210
|
-
const mime = match[1].toLowerCase();
|
|
211
|
-
const raw = Buffer.from(match[2], "base64");
|
|
212
|
-
if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
const resize = await sharp();
|
|
216
|
-
const out = await resize(raw, { failOn: "none" })
|
|
217
|
-
.rotate()
|
|
218
|
-
.resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
|
|
219
|
-
.webp({ quality: IMG_QUALITY, effort: 4 })
|
|
220
|
-
.toBuffer();
|
|
221
|
-
const ratio = ((out.length / raw.length) * 100).toFixed(1);
|
|
222
|
-
console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
|
|
223
|
-
return `data:image/webp;base64,${out.toString("base64")}`;
|
|
224
|
-
} catch (e) {
|
|
225
|
-
console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
|
|
226
|
-
return dataUrl;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export async function runWithConcurrency(taskFns, concurrency) {
|
|
231
|
-
if (!Array.isArray(taskFns) || taskFns.length === 0) return;
|
|
232
|
-
const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
|
|
233
|
-
let next = 0;
|
|
234
|
-
await Promise.all(Array.from({ length: limit }, async () => {
|
|
235
|
-
while (next < taskFns.length) {
|
|
236
|
-
const task = taskFns[next++];
|
|
237
|
-
await task();
|
|
238
|
-
}
|
|
239
|
-
}));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function visitImageParts(parts, tasks) {
|
|
243
|
-
if (!Array.isArray(parts)) return;
|
|
244
|
-
for (const part of parts) {
|
|
245
|
-
if (!part) continue;
|
|
246
|
-
if (part.type === "input_image" && typeof part.image_url === "string") {
|
|
247
|
-
tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
|
|
248
|
-
} else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
|
|
249
|
-
tasks.push(async () => {
|
|
250
|
-
const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
|
|
251
|
-
const opt = await optimizeImageDataUrl(dataUrl);
|
|
252
|
-
const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
|
|
253
|
-
if (match) {
|
|
254
|
-
part.source.media_type = match[1];
|
|
255
|
-
part.source.data = match[2];
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export async function optimizeImagesInBody(reqBody) {
|
|
263
|
-
if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
|
|
264
|
-
const tasks = [];
|
|
265
|
-
|
|
266
|
-
for (const item of reqBody.input) {
|
|
267
|
-
if (!item) continue;
|
|
268
|
-
if (item.type === "message" && Array.isArray(item.content)) {
|
|
269
|
-
visitImageParts(item.content, tasks);
|
|
270
|
-
}
|
|
271
|
-
if (item.type === "function_call_output") {
|
|
272
|
-
if (Array.isArray(item.output)) {
|
|
273
|
-
visitImageParts(item.output, tasks);
|
|
274
|
-
} else if (typeof item.output === "string") {
|
|
275
|
-
const trimmed = item.output.trim();
|
|
276
|
-
if (trimmed.startsWith("[")) {
|
|
277
|
-
try {
|
|
278
|
-
const parsed = JSON.parse(trimmed);
|
|
279
|
-
if (Array.isArray(parsed)) {
|
|
280
|
-
const localTasks = [];
|
|
281
|
-
visitImageParts(parsed, localTasks);
|
|
282
|
-
if (localTasks.length) {
|
|
283
|
-
tasks.push(async () => {
|
|
284
|
-
await runWithConcurrency(localTasks, IMG_CONCURRENCY);
|
|
285
|
-
item.output = JSON.stringify(parsed);
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
} catch {
|
|
290
|
-
// Leave non-JSON tool output untouched.
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
|
|
298
|
-
return reqBody;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function summarizeReqBody(reqBody) {
|
|
302
|
-
try {
|
|
303
|
-
const input = reqBody.input;
|
|
304
|
-
if (!Array.isArray(input)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
|
|
305
|
-
let biggest = 0;
|
|
306
|
-
let biggestKind = "";
|
|
307
|
-
let images = 0;
|
|
308
|
-
const countImages = (parts) => {
|
|
309
|
-
if (!Array.isArray(parts)) return;
|
|
310
|
-
for (const part of parts) {
|
|
311
|
-
if (part?.type === "input_image" || part?.type === "image") images++;
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
for (const item of input) {
|
|
316
|
-
const size = JSON.stringify(item).length;
|
|
317
|
-
if (size > biggest) {
|
|
318
|
-
biggest = size;
|
|
319
|
-
biggestKind = item?.type || "?";
|
|
320
|
-
}
|
|
321
|
-
if (item?.type === "message") countImages(item.content);
|
|
322
|
-
if (item?.type === "function_call_output") {
|
|
323
|
-
if (Array.isArray(item.output)) {
|
|
324
|
-
countImages(item.output);
|
|
325
|
-
} else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
|
|
326
|
-
try {
|
|
327
|
-
const parsed = JSON.parse(item.output);
|
|
328
|
-
if (Array.isArray(parsed)) countImages(parsed);
|
|
329
|
-
} catch {
|
|
330
|
-
// Ignore non-JSON tool output.
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return { items: input.length, images, biggest, biggestKind };
|
|
337
|
-
} catch {
|
|
338
|
-
return { items: -1, images: -1, biggest: -1, biggestKind: "err" };
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
205
|
export function computeInitiator(messages) {
|
|
343
206
|
const isAgent = Array.isArray(messages)
|
|
344
207
|
&& messages.some((m) => m && ["assistant", "tool"].includes(m.role));
|
|
@@ -463,6 +326,7 @@ async function refreshCopilotToken({
|
|
|
463
326
|
throw new Error(`Failed to get Copilot token: ${resp.status}`);
|
|
464
327
|
}
|
|
465
328
|
const data = await resp.json();
|
|
329
|
+
await ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
|
|
466
330
|
return cacheCopilotTokenData(data);
|
|
467
331
|
}
|
|
468
332
|
|
|
@@ -520,11 +384,11 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
|
|
|
520
384
|
// Responses-only models go directly to Copilot's /responses endpoint.
|
|
521
385
|
export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
|
|
522
386
|
const token = await getCopilotToken({ signal });
|
|
523
|
-
const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
|
|
524
387
|
await optimizeImagesInBody(reqBody);
|
|
525
|
-
const
|
|
388
|
+
const bodyText = JSON.stringify(reqBody);
|
|
389
|
+
const bodyBytes = Buffer.byteLength(bodyText);
|
|
526
390
|
const summary = summarizeReqBody(reqBody);
|
|
527
|
-
console.log(status("info", `responses payload bytes=${
|
|
391
|
+
console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
|
|
528
392
|
const headers = buildHeaders({
|
|
529
393
|
token,
|
|
530
394
|
version: getVSCodeVersion(),
|
|
@@ -532,14 +396,14 @@ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {
|
|
|
532
396
|
vision: false,
|
|
533
397
|
});
|
|
534
398
|
headers["Content-Type"] = "application/json; charset=utf-8";
|
|
535
|
-
headers["Content-Length"] = String(
|
|
399
|
+
headers["Content-Length"] = String(bodyBytes);
|
|
536
400
|
headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
|
|
537
401
|
|
|
538
402
|
try {
|
|
539
403
|
return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
|
|
540
404
|
method: "POST",
|
|
541
405
|
headers,
|
|
542
|
-
body:
|
|
406
|
+
body: bodyText,
|
|
543
407
|
signal,
|
|
544
408
|
}, { fetchImpl, ...retryOptions });
|
|
545
409
|
} catch (e) {
|
package/src/doctor.mjs
CHANGED
|
@@ -2,9 +2,11 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import net from "node:net";
|
|
5
|
-
import { githubTokenPath } from "./auth.mjs";
|
|
5
|
+
import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from "./auth.mjs";
|
|
6
6
|
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
7
7
|
import { status } from "./status.mjs";
|
|
8
|
+
import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
|
|
9
|
+
import { checkRunningAdapter } from "./running-adapter.mjs";
|
|
8
10
|
|
|
9
11
|
function localGatewayBaseUrl(host, port) {
|
|
10
12
|
const safeHost = String(host || "127.0.0.1");
|
|
@@ -55,7 +57,60 @@ export function inspectGitHubToken({ home = os.homedir() } = {}) {
|
|
|
55
57
|
if (!token.text.trim()) {
|
|
56
58
|
return [{ kind: "err", message: `GitHub token file is empty at ${displayPath(home, filePath)}` }];
|
|
57
59
|
}
|
|
58
|
-
|
|
60
|
+
const metadata = readGithubTokenMetadata(home, token.text.trim());
|
|
61
|
+
const account = metadata?.login ? ` for ${metadata.login}` : "";
|
|
62
|
+
return [{ kind: "ok", message: `GitHub token found${account}` }];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function inspectGitHubTokenOnline({
|
|
66
|
+
home = os.homedir(),
|
|
67
|
+
fetchImpl = fetch,
|
|
68
|
+
timeoutMs = 10000,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const token = readText(githubTokenPath(home));
|
|
71
|
+
if (!token.ok || !token.text.trim()) {
|
|
72
|
+
return [{ kind: "warn", message: "Online Copilot check skipped because the GitHub token is missing" }];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
|
+
try {
|
|
78
|
+
const validation = await validateGithubToken(token.text.trim(), {
|
|
79
|
+
fetchImpl,
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!validation.ok) {
|
|
83
|
+
const statusText = validation.status ? ` (HTTP ${validation.status})` : "";
|
|
84
|
+
return [{ kind: validation.transient ? "warn" : "err", message: `GitHub Copilot authentication failed: ${validation.reason}${statusText}` }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokenData = validation.copilotTokenData;
|
|
88
|
+
const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
|
|
89
|
+
const headers = buildHeaders({
|
|
90
|
+
token: tokenData.token,
|
|
91
|
+
version: FALLBACK_VSCODE_VERSION,
|
|
92
|
+
initiator: "user",
|
|
93
|
+
vision: false,
|
|
94
|
+
});
|
|
95
|
+
const modelResp = await fetchImpl(`${apiBase}/models`, { headers, signal: controller.signal });
|
|
96
|
+
if (!modelResp.ok) {
|
|
97
|
+
return [
|
|
98
|
+
{ kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
|
|
99
|
+
{ kind: "err", message: `Copilot models endpoint returned HTTP ${modelResp.status}` },
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
const models = await modelResp.json();
|
|
103
|
+
const data = Array.isArray(models) ? models : models?.data;
|
|
104
|
+
return [
|
|
105
|
+
{ kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
|
|
106
|
+
{ kind: "ok", message: `Copilot models endpoint returned ${Array.isArray(data) ? data.length : 0} models` },
|
|
107
|
+
];
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const reason = e?.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e.message;
|
|
110
|
+
return [{ kind: "warn", message: `Online Copilot check failed: ${reason}` }];
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
59
114
|
}
|
|
60
115
|
|
|
61
116
|
export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
@@ -180,6 +235,10 @@ export async function collectDoctorChecks({
|
|
|
180
235
|
port = 2026,
|
|
181
236
|
checkAdapter = true,
|
|
182
237
|
checkAdapterListeningFn = checkAdapterListening,
|
|
238
|
+
checkRunningAdapterFn = checkRunningAdapter,
|
|
239
|
+
online = false,
|
|
240
|
+
fetchImpl = fetch,
|
|
241
|
+
onlineTimeoutMs = 10000,
|
|
183
242
|
} = {}) {
|
|
184
243
|
const checks = [
|
|
185
244
|
...inspectGitHubToken({ home }),
|
|
@@ -188,11 +247,27 @@ export async function collectDoctorChecks({
|
|
|
188
247
|
...inspectClaudeAppConfig({ home, platform, env, host, port }),
|
|
189
248
|
];
|
|
190
249
|
|
|
250
|
+
if (online) {
|
|
251
|
+
checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
|
|
252
|
+
}
|
|
253
|
+
|
|
191
254
|
if (checkAdapter) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
255
|
+
let running;
|
|
256
|
+
try {
|
|
257
|
+
running = await checkRunningAdapterFn({ host, port, fetchImpl });
|
|
258
|
+
} catch {
|
|
259
|
+
running = null;
|
|
260
|
+
}
|
|
261
|
+
if (running?.ok) {
|
|
262
|
+
checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
|
|
263
|
+
} else if (running?.incompatible) {
|
|
264
|
+
checks.push({ kind: "warn", message: `Adapter ${running.data?.version || "legacy"} is running at ${running.baseUrl}, but it is incompatible with this CLI` });
|
|
265
|
+
} else {
|
|
266
|
+
const listening = await checkAdapterListeningFn({ host, port });
|
|
267
|
+
checks.push(listening
|
|
268
|
+
? { kind: "warn", message: `A service is listening on ${localGatewayBaseUrl(host, port)}, but it is not a compatible codex-copilot-dx adapter` }
|
|
269
|
+
: { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
|
|
270
|
+
}
|
|
196
271
|
}
|
|
197
272
|
|
|
198
273
|
return checks;
|
|
@@ -200,7 +275,7 @@ export async function collectDoctorChecks({
|
|
|
200
275
|
|
|
201
276
|
export async function runDoctor(options = {}) {
|
|
202
277
|
const log = options.log || console.log;
|
|
203
|
-
log(
|
|
278
|
+
log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
|
|
204
279
|
const checks = await collectDoctorChecks(options);
|
|
205
280
|
for (const check of checks) log(status(check.kind, check.message));
|
|
206
281
|
return checks;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function parseByteLimit(value, fallback) {
|
|
4
|
+
if (value === undefined || value === null || String(value).trim() === "") return fallback;
|
|
5
|
+
const text = String(value).trim();
|
|
6
|
+
if (!/^\d+$/.test(text)) return fallback;
|
|
7
|
+
const parsed = Number(text);
|
|
8
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function rotatedFilePath(filePath) {
|
|
12
|
+
return `${filePath}.1`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function rotateFileIfNeededSync(filePath, incomingBytes, maxBytes) {
|
|
16
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return false;
|
|
17
|
+
let stat;
|
|
18
|
+
try {
|
|
19
|
+
stat = fs.statSync(filePath);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e?.code === "ENOENT") return false;
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
if (!stat.isFile() || stat.size === 0 || stat.size + incomingBytes <= maxBytes) return false;
|
|
25
|
+
|
|
26
|
+
const backupPath = rotatedFilePath(filePath);
|
|
27
|
+
fs.rmSync(backupPath, { force: true });
|
|
28
|
+
fs.renameSync(filePath, backupPath);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { promisify } from "node:util";
|
|
2
|
+
import * as zlib from "node:zlib";
|
|
3
|
+
import { status } from "./status.mjs";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
const gunzipAsync = promisify(zlib.gunzip);
|
|
8
|
+
const inflateAsync = promisify(zlib.inflate);
|
|
9
|
+
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
10
|
+
const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
|
|
11
|
+
|
|
12
|
+
function positiveInt(value, fallback) {
|
|
13
|
+
const n = Number.parseInt(value, 10);
|
|
14
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
18
|
+
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
19
|
+
|
|
20
|
+
export function httpError(message, statusCode) {
|
|
21
|
+
const err = new Error(message);
|
|
22
|
+
err.statusCode = statusCode;
|
|
23
|
+
return err;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function payloadTooLarge(kind, maxBytes) {
|
|
27
|
+
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readRequestBuffer(req, maxBytes) {
|
|
31
|
+
const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
32
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
33
|
+
throw payloadTooLarge("Raw", maxBytes);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
for await (const chunk of req) {
|
|
39
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
40
|
+
total += buffer.length;
|
|
41
|
+
if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
|
|
42
|
+
chunks.push(buffer);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function decompressBody(decompress, buffer, maxBytes) {
|
|
48
|
+
try {
|
|
49
|
+
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
|
|
52
|
+
throw payloadTooLarge("Decoded", maxBytes);
|
|
53
|
+
}
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
59
|
+
const encodings = String(contentEncoding || "identity")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((value) => value.trim().toLowerCase())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
|
|
64
|
+
let decoded = buffer;
|
|
65
|
+
for (const encoding of encodings.reverse()) {
|
|
66
|
+
if (encoding === "identity") continue;
|
|
67
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
68
|
+
decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
|
|
69
|
+
} else if (encoding === "deflate") {
|
|
70
|
+
decoded = await decompressBody(inflateAsync, decoded, maxBytes);
|
|
71
|
+
} else if (encoding === "br") {
|
|
72
|
+
decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
|
|
73
|
+
} else if (encoding === "zstd") {
|
|
74
|
+
if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
|
|
75
|
+
decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error(`Unsupported Content-Encoding: ${encoding}`);
|
|
78
|
+
}
|
|
79
|
+
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
80
|
+
}
|
|
81
|
+
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
82
|
+
return decoded;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function readJsonBody(req, {
|
|
86
|
+
maxBodyBytes = MAX_BODY_BYTES,
|
|
87
|
+
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
90
|
+
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
91
|
+
return JSON.parse(decoded.toString("utf8"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function sendJsonError(res, err, fallbackStatus = 400) {
|
|
95
|
+
if (res.destroyed || res.writableEnded) return;
|
|
96
|
+
if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sendUpstreamError(res, response, text) {
|
|
101
|
+
if (!res.headersSent) {
|
|
102
|
+
res.writeHead(response.status || 502, { "Content-Type": response.headers?.get("content-type") || "application/json" });
|
|
103
|
+
}
|
|
104
|
+
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeOrDrain(res, chunk) {
|
|
108
|
+
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
109
|
+
if (res.write(chunk)) return Promise.resolve(true);
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
res.off("drain", onDrain);
|
|
113
|
+
res.off("error", onError);
|
|
114
|
+
res.off("close", onClose);
|
|
115
|
+
};
|
|
116
|
+
const onDrain = () => { cleanup(); resolve(true); };
|
|
117
|
+
const onClose = () => { cleanup(); resolve(false); };
|
|
118
|
+
const onError = (err) => { cleanup(); reject(err); };
|
|
119
|
+
res.once("drain", onDrain);
|
|
120
|
+
res.once("close", onClose);
|
|
121
|
+
res.once("error", onError);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isAbortLikeError(err) {
|
|
126
|
+
return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function abortErrorStatusCode(reason) {
|
|
130
|
+
if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
|
|
131
|
+
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
132
|
+
return 400;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createRequestAbort(req, res) {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
let timer = null;
|
|
138
|
+
let cleaned = false;
|
|
139
|
+
let reason = null;
|
|
140
|
+
const abort = (nextReason = "aborted") => {
|
|
141
|
+
if (!cleaned && !controller.signal.aborted) {
|
|
142
|
+
reason = nextReason;
|
|
143
|
+
controller.abort();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const onReqAborted = () => abort("client_aborted");
|
|
147
|
+
const onResClose = () => {
|
|
148
|
+
if (!res.writableEnded) abort("client_closed");
|
|
149
|
+
};
|
|
150
|
+
req.on("aborted", onReqAborted);
|
|
151
|
+
res.on("close", onResClose);
|
|
152
|
+
return {
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
get reason() { return reason; },
|
|
155
|
+
setTimeout(ms, nextReason = "upstream_timeout") {
|
|
156
|
+
if (timer) clearTimeout(timer);
|
|
157
|
+
timer = null;
|
|
158
|
+
if (ms > 0) timer = setTimeout(() => abort(nextReason), ms);
|
|
159
|
+
},
|
|
160
|
+
clearTimeout() {
|
|
161
|
+
if (timer) clearTimeout(timer);
|
|
162
|
+
timer = null;
|
|
163
|
+
},
|
|
164
|
+
cleanup() {
|
|
165
|
+
cleaned = true;
|
|
166
|
+
if (timer) clearTimeout(timer);
|
|
167
|
+
req.off("aborted", onReqAborted);
|
|
168
|
+
res.off("close", onResClose);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function logRequestFailure(label, err, abort) {
|
|
174
|
+
if (!isAbortLikeError(err)) {
|
|
175
|
+
console.error(status("err", `${label} request failed: ${err.message}`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const reason = abort?.reason || "aborted";
|
|
180
|
+
err.statusCode ||= abortErrorStatusCode(reason);
|
|
181
|
+
console.warn(status("warn", `${label} request aborted: ${reason}`));
|
|
182
|
+
}
|