codex-copilot-dx 0.6.18 → 0.6.20

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/copilot.mjs CHANGED
@@ -33,9 +33,28 @@ export function parseUpstreamRetryDelayMs(value) {
33
33
  }
34
34
 
35
35
  export function parseApiBase(data) {
36
- return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
37
- ? data.endpoints.api
38
- : DEFAULT_API_BASE;
36
+ const configured = data?.endpoints?.api;
37
+ if (typeof configured !== "string" || !configured.trim()) return DEFAULT_API_BASE;
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(configured.trim());
41
+ } catch {
42
+ throw Object.assign(new Error("Copilot API endpoint is invalid"), {
43
+ code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
44
+ });
45
+ }
46
+ if (parsed.protocol !== "https:"
47
+ || !parsed.hostname
48
+ || parsed.username
49
+ || parsed.password
50
+ || parsed.search
51
+ || parsed.hash) {
52
+ throw Object.assign(new Error("Copilot API endpoint is invalid"), {
53
+ code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
54
+ });
55
+ }
56
+ const pathname = parsed.pathname.replace(/\/+$/, "");
57
+ return `${parsed.origin}${pathname && pathname !== "/" ? pathname : ""}`;
39
58
  }
40
59
 
41
60
  export function responsesEndpointPath() {
package/src/doctor.mjs CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "./auth-profile.mjs";
11
11
  import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
12
12
  import { status } from "./status.mjs";
13
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
13
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
14
14
  import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
15
15
  import { CODEX_AUTO_REVIEW_MODEL, isClaudeCopilotModel } from "./models.mjs";
16
16
  import { loadModelCache } from "./model-cache.mjs";
@@ -322,7 +322,7 @@ export async function inspectGitHubTokenOnline({
322
322
  }
323
323
 
324
324
  const tokenData = validation.copilotTokenData;
325
- const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
325
+ const apiBase = parseApiBase(tokenData);
326
326
  const headers = buildHeaders({
327
327
  token: tokenData.token,
328
328
  version: FALLBACK_VSCODE_VERSION,
@@ -20,6 +20,8 @@ const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
20
20
  export const MAX_UPSTREAM_ERROR_BODY_BYTES = 1024 * 1024;
21
21
  export const MAX_UPSTREAM_MODEL_CATALOG_BYTES = 8 * 1024 * 1024;
22
22
  export const MAX_UPSTREAM_RETRY_DRAIN_BYTES = 64 * 1024;
23
+ export const MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamChatResponseBytes;
24
+ export const MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamResponsesResponseBytes;
23
25
 
24
26
  export function httpError(message, statusCode) {
25
27
  const err = new Error(message);
@@ -57,6 +59,37 @@ async function cancelResponseBody(response) {
57
59
  try { await response?.body?.cancel?.(); } catch {}
58
60
  }
59
61
 
62
+ function responseAbortError(signal) {
63
+ if (signal?.reason instanceof Error) return signal.reason;
64
+ const error = new Error("The operation was aborted");
65
+ error.name = "AbortError";
66
+ return error;
67
+ }
68
+
69
+ function readResponseChunk(reader, signal) {
70
+ if (!signal) return reader.read();
71
+ if (signal.aborted) return Promise.reject(responseAbortError(signal));
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const finish = (callback, value) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ signal.removeEventListener("abort", onAbort);
78
+ callback(value);
79
+ };
80
+ const onAbort = () => {
81
+ const error = responseAbortError(signal);
82
+ try { Promise.resolve(reader.cancel(error)).catch(() => {}); } catch {}
83
+ finish(reject, error);
84
+ };
85
+ signal.addEventListener("abort", onAbort, { once: true });
86
+ reader.read().then(
87
+ (value) => finish(resolve, value),
88
+ (error) => finish(reject, error),
89
+ );
90
+ });
91
+ }
92
+
60
93
  function responseContentLength(response) {
61
94
  const raw = response?.headers?.get?.("content-length")
62
95
  ?? response?.headers?.["content-length"]
@@ -68,13 +101,23 @@ function responseContentLength(response) {
68
101
  export async function readBoundedResponseBuffer(response, {
69
102
  maxBytes,
70
103
  label = "Upstream response body",
104
+ signal,
71
105
  } = {}) {
72
106
  const limit = Number.isFinite(maxBytes) && maxBytes >= 0 ? Math.floor(maxBytes) : 0;
107
+ if (signal?.aborted) {
108
+ cancelResponseBody(response);
109
+ throw responseAbortError(signal);
110
+ }
73
111
  if (responseContentLength(response) > limit) {
74
112
  await cancelResponseBody(response);
75
113
  throw upstreamBodyTooLarge(label, limit);
76
114
  }
77
115
  if (!response?.body) {
116
+ if (typeof response?.text === "function") {
117
+ const buffer = Buffer.from(await response.text());
118
+ if (buffer.length > limit) throw upstreamBodyTooLarge(label, limit);
119
+ return buffer;
120
+ }
78
121
  if ((responseContentLength(response) || 0) > 0) throw unreadableUpstreamBody(label);
79
122
  return Buffer.alloc(0);
80
123
  }
@@ -84,7 +127,7 @@ export async function readBoundedResponseBuffer(response, {
84
127
  let total = 0;
85
128
  try {
86
129
  while (true) {
87
- const { done, value } = await reader.read();
130
+ const { done, value } = await readResponseChunk(reader, signal);
88
131
  if (done) break;
89
132
  const chunk = Buffer.from(value);
90
133
  total += chunk.length;
@@ -96,7 +139,11 @@ export async function readBoundedResponseBuffer(response, {
96
139
  }
97
140
  return Buffer.concat(chunks, total);
98
141
  } finally {
99
- reader.releaseLock();
142
+ try {
143
+ reader.releaseLock();
144
+ } catch (error) {
145
+ if (!signal?.aborted) throw error;
146
+ }
100
147
  }
101
148
  }
102
149
 
@@ -360,6 +407,9 @@ export function createRequestAdmission({
360
407
  continue;
361
408
  }
362
409
  if (activeBytes + entry.weight > byteLimit) {
410
+ // Unknown-length requests reserve the whole budget. Once one is waiting,
411
+ // do not let later arrivals keep extending its wait indefinitely.
412
+ if (entry.weight === byteLimit) break;
363
413
  index += 1;
364
414
  continue;
365
415
  }
@@ -375,11 +425,13 @@ export function createRequestAdmission({
375
425
  return Promise.reject(admissionAbortError(signal));
376
426
  }
377
427
  const weight = requestAdmissionWeight(req, byteLimit);
378
- if (queue.length >= queueLimit && activeBytes + weight > byteLimit) {
428
+ const blockedByExclusive = queue.some((entry) => !entry.cancelled && entry.weight === byteLimit);
429
+ const mustWait = blockedByExclusive || activeBytes + weight > byteLimit;
430
+ if (queue.length >= queueLimit && mustWait) {
379
431
  counters.rejected += 1;
380
432
  return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
381
433
  }
382
- if (activeBytes + weight > byteLimit) counters.queued += 1;
434
+ if (mustWait) counters.queued += 1;
383
435
 
384
436
  return new Promise((resolve, reject) => {
385
437
  let timer;
package/src/lock.mjs CHANGED
@@ -149,8 +149,12 @@ function maybeRemoveAbandonedGuard(guardPath, staleMs, nowMs, io, processAlive)
149
149
  restoreQuarantinedLock(guardPath, quarantinePath, io);
150
150
  return false;
151
151
  }
152
- io.unlinkSync(quarantinePath);
153
- return true;
152
+ try {
153
+ io.unlinkSync(quarantinePath);
154
+ return true;
155
+ } catch {
156
+ return recoverQuarantineUnlinkFailure(guardPath, quarantinePath, snapshot, io);
157
+ }
154
158
  } catch (error) {
155
159
  return error?.code === "ENOENT";
156
160
  }
@@ -167,6 +171,18 @@ function restoreQuarantinedLock(lockPath, quarantinePath, io) {
167
171
  } catch {}
168
172
  }
169
173
 
174
+ function recoverQuarantineUnlinkFailure(lockPath, quarantinePath, snapshot, io) {
175
+ restoreQuarantinedLock(lockPath, quarantinePath, io);
176
+ try {
177
+ const restored = readStaleLockSnapshot(lockPath, io);
178
+ if (!restored.safe || !sameLock(snapshot, restored)) return false;
179
+ io.unlinkSync(lockPath);
180
+ return true;
181
+ } catch (error) {
182
+ return error?.code === "ENOENT";
183
+ }
184
+ }
185
+
170
186
  function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
171
187
  if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
172
188
  try {
@@ -192,8 +208,12 @@ function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
192
208
  restoreQuarantinedLock(lockPath, quarantinePath, io);
193
209
  return false;
194
210
  }
195
- io.unlinkSync(quarantinePath);
196
- return true;
211
+ try {
212
+ io.unlinkSync(quarantinePath);
213
+ return true;
214
+ } catch {
215
+ return recoverQuarantineUnlinkFailure(lockPath, quarantinePath, current, io);
216
+ }
197
217
  } finally {
198
218
  removeOwnedLock(guard.guardPath, guard.owner, io);
199
219
  }
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { sha256Hex } from "./pm-studio-asar.mjs";
5
+
6
+ const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
7
+
8
+ function setupError(code, message, details) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ if (details !== undefined) error.details = details;
12
+ return error;
13
+ }
14
+
15
+ function hashRegularFile(filePath, expected, readBuffer) {
16
+ const hash = createHash("sha256");
17
+ const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
18
+ const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
19
+ try {
20
+ const before = fs.fstatSync(file, { bigint: true });
21
+ if (before.dev !== BigInt(expected.dev)
22
+ || before.ino !== BigInt(expected.ino)
23
+ || before.size !== BigInt(expected.size)) {
24
+ throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
25
+ }
26
+ for (;;) {
27
+ const length = fs.readSync(file, buffer, 0, buffer.length, null);
28
+ if (length === 0) break;
29
+ hash.update(buffer.subarray(0, length));
30
+ }
31
+ const after = fs.fstatSync(file, { bigint: true });
32
+ if (before.dev !== after.dev
33
+ || before.ino !== after.ino
34
+ || before.size !== after.size
35
+ || before.mtimeNs !== after.mtimeNs
36
+ || before.ctimeNs !== after.ctimeNs) {
37
+ throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
38
+ }
39
+ } finally {
40
+ fs.closeSync(file);
41
+ }
42
+ return hash.digest("hex");
43
+ }
44
+
45
+ function parseXattrHex(output, root, ignoredXattrs) {
46
+ const records = [];
47
+ const ignored = new Set(ignoredXattrs);
48
+ const seen = new Set();
49
+ let current = null;
50
+ const flush = () => {
51
+ if (!current) return;
52
+ if (!ignored.has(current.name)) {
53
+ const key = `${current.path}\0${current.name}`;
54
+ if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
55
+ seen.add(key);
56
+ const value = Buffer.from(current.hex.join(""), "hex");
57
+ records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
58
+ }
59
+ current = null;
60
+ };
61
+
62
+ for (const line of String(output || "").split(/\r?\n/)) {
63
+ if (!line) continue;
64
+ const separator = line.lastIndexOf(": ");
65
+ if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
66
+ && separator >= root.length && line.endsWith(":")) {
67
+ flush();
68
+ const target = line.slice(0, separator);
69
+ const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
70
+ if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
71
+ throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
72
+ }
73
+ current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
74
+ continue;
75
+ }
76
+ if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
77
+ const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
78
+ const tokens = data.trim().split(/\s+/);
79
+ if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
80
+ throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
81
+ }
82
+ current.hex.push(...tokens.slice(1));
83
+ }
84
+ flush();
85
+ return records.sort((left, right) => Buffer.compare(
86
+ Buffer.from(`${left[1]}\0${left[2]}`),
87
+ Buffer.from(`${right[1]}\0${right[2]}`),
88
+ ));
89
+ }
90
+
91
+ export function inspectBundleContent(appPath, {
92
+ xattrOutput = "",
93
+ ignoredXattrs = [],
94
+ readBuffer,
95
+ } = {}) {
96
+ const root = path.resolve(appPath);
97
+ const rootStat = fs.lstatSync(root);
98
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
99
+ throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
100
+ }
101
+ const records = [];
102
+ const normalizedPaths = new Map();
103
+ let regularFileCount = 0;
104
+ let regularBytes = 0;
105
+ let symlinkCount = 0;
106
+
107
+ const rememberPath = (relative) => {
108
+ const normalized = relative.normalize("NFC");
109
+ const existing = normalizedPaths.get(normalized);
110
+ if (existing && existing !== relative) {
111
+ throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
112
+ }
113
+ normalizedPaths.set(normalized, relative);
114
+ };
115
+ rememberPath(".");
116
+ records.push(["directory", ".", rootStat.mode & 0o7777]);
117
+ const visit = (directory) => {
118
+ const names = fs.readdirSync(directory).sort((left, right) =>
119
+ Buffer.compare(Buffer.from(left), Buffer.from(right)));
120
+ for (const name of names) {
121
+ const fullPath = path.join(directory, name);
122
+ const stat = fs.lstatSync(fullPath);
123
+ const relative = path.relative(root, fullPath).split(path.sep).join("/");
124
+ const mode = stat.mode & 0o7777;
125
+ rememberPath(relative);
126
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
127
+ records.push(["directory", relative, mode]);
128
+ visit(fullPath);
129
+ } else if (stat.isSymbolicLink()) {
130
+ const target = fs.readlinkSync(fullPath);
131
+ const resolved = path.resolve(path.dirname(fullPath), target);
132
+ if (path.isAbsolute(target)
133
+ || (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
134
+ || !fs.existsSync(resolved)) {
135
+ throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
136
+ }
137
+ symlinkCount += 1;
138
+ records.push(["symlink", relative, mode, target]);
139
+ } else if (stat.isFile()) {
140
+ if (stat.nlink !== 1) {
141
+ throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
142
+ }
143
+ regularFileCount += 1;
144
+ regularBytes += stat.size;
145
+ records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
146
+ } else {
147
+ throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
148
+ }
149
+ }
150
+ };
151
+ visit(root);
152
+
153
+ const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
154
+ const hash = createHash("sha256");
155
+ for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
156
+ return {
157
+ scheme: BUNDLE_CONTENT_SCHEME,
158
+ sha256: hash.digest("hex"),
159
+ entryCount: records.length,
160
+ regularFileCount,
161
+ regularBytes,
162
+ symlinkCount,
163
+ xattrCount: xattrRecords.length,
164
+ };
165
+ }
@@ -5,6 +5,10 @@ import {
5
5
  createRequestAdmission,
6
6
  httpError,
7
7
  isAbortLikeError,
8
+ MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
9
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
+ MAX_UPSTREAM_MODEL_CATALOG_BYTES,
11
+ readBoundedResponseBuffer,
8
12
  readJsonBody,
9
13
  sendJsonError,
10
14
  writeOrDrain,
@@ -150,12 +154,12 @@ function catalogData(catalog) {
150
154
  return Array.isArray(catalog?.data) ? catalog.data : null;
151
155
  }
152
156
 
153
- async function responseSnapshot(response) {
157
+ async function responseSnapshot(response, { maxBytes, label, signal }) {
154
158
  return {
155
159
  status: response.status,
156
160
  ok: response.ok,
157
161
  headers: new Headers(response.headers),
158
- body: Buffer.from(await response.arrayBuffer()),
162
+ body: await readBoundedResponseBuffer(response, { maxBytes, label, signal }),
159
163
  };
160
164
  }
161
165
 
@@ -209,7 +213,15 @@ function mergeModelCatalog(snapshot, modelRouter) {
209
213
  async function relayUpstreamResponse(response, reqBody, res, abort, streamIdleTimeoutMs) {
210
214
  const stream = reqBody?.stream === true;
211
215
  if (!stream || !response.ok) {
212
- const snapshot = await responseSnapshot(response);
216
+ const snapshot = await responseSnapshot(response, response.ok ? {
217
+ maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
218
+ label: "PM Studio Claude success body",
219
+ signal: abort.signal,
220
+ } : {
221
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
222
+ label: "PM Studio Claude error body",
223
+ signal: abort.signal,
224
+ });
213
225
  sendSnapshot(res, snapshot);
214
226
  return;
215
227
  }
@@ -345,9 +357,18 @@ export function createPmStudioRelayHandler(options = {}) {
345
357
  }
346
358
  activeModelRequests += 1;
347
359
  try {
348
- return await fetchEnterprise(
360
+ const response = await fetchEnterprise(
349
361
  "/models", "GET", inboundHeaders, token, undefined, signal,
350
- ).then(responseSnapshot);
362
+ );
363
+ return await responseSnapshot(response, response.ok ? {
364
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
365
+ label: "PM Studio model catalog",
366
+ signal,
367
+ } : {
368
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
369
+ label: "PM Studio model catalog error body",
370
+ signal,
371
+ });
351
372
  } finally {
352
373
  activeModelRequests -= 1;
353
374
  }