codex-copilot-dx 0.9.24 → 0.9.25

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.
@@ -0,0 +1,102 @@
1
+ import net from "node:net";
2
+ import tls from "node:tls";
3
+ import https from "node:https";
4
+ import { networkProxyFor } from "./network-route.mjs";
5
+
6
+ // A bounded read preserves bytes following the handshake for the TLS parser.
7
+ function readSocket(socket, ready, maxBytes = 16 * 1024) {
8
+ return new Promise((resolve, reject) => {
9
+ let chunks = [], size = 0;
10
+ const cleanup = () => { socket.off("data", data); socket.off("error", error); socket.off("close", closed); };
11
+ const error = (e) => { cleanup(); reject(e); };
12
+ const closed = () => error(new Error("Proxy closed the connection"));
13
+ const data = (chunk) => {
14
+ chunks.push(chunk); size += chunk.length;
15
+ if (size > maxBytes) { error(new Error("Proxy handshake exceeds its limit")); return; }
16
+ const bytes = Buffer.concat(chunks, size), end = ready(bytes);
17
+ if (end !== null) {
18
+ socket.pause(); cleanup();
19
+ if (bytes.length > end) socket.unshift(bytes.subarray(end));
20
+ resolve(bytes.subarray(0, end));
21
+ }
22
+ };
23
+ socket.on("data", data); socket.once("error", error); socket.once("close", closed); socket.resume();
24
+ });
25
+ }
26
+
27
+ async function connectProxy(target, route, timeoutMs, pending) {
28
+ const proxy = route.url;
29
+ const proxyHost = proxy.hostname.replace(/^\[|\]$/g, "");
30
+ const socket = proxy.protocol === "https:"
31
+ ? tls.connect({ host: proxyHost, port: Number(proxy.port || 443), servername: net.isIP(proxyHost) ? undefined : proxyHost, rejectUnauthorized: true })
32
+ : net.connect({ host: proxyHost, port: Number(proxy.port || (proxy.protocol.startsWith("socks") ? 1080 : 80)) });
33
+ pending.add(socket);
34
+ const timer = setTimeout(() => socket.destroy(new Error("Proxy connection timed out")), timeoutMs);
35
+ socket.once("close", () => pending.delete(socket));
36
+ try {
37
+ await new Promise((resolve, reject) => {
38
+ const event = proxy.protocol === "https:" ? "secureConnect" : "connect";
39
+ const failed = (error) => { socket.off(event, connected); reject(error); };
40
+ const connected = () => { socket.off("error", failed); resolve(); };
41
+ socket.once(event, connected); socket.once("error", failed);
42
+ });
43
+ if (proxy.protocol.startsWith("socks5")) {
44
+ const user = Buffer.from(decodeURIComponent(proxy.username)), password = Buffer.from(decodeURIComponent(proxy.password));
45
+ if (user.length > 255 || password.length > 255) throw new Error("SOCKS5 proxy credentials are too long");
46
+ const greeting = readSocket(socket, b => b.length >= 2 ? 2 : null);
47
+ socket.write(user.length ? Buffer.from([5, 2, 0, 2]) : Buffer.from([5, 1, 0]));
48
+ const choice = await greeting;
49
+ if (choice[0] !== 5 || ![0, 2].includes(choice[1]) || (choice[1] === 2 && !user.length)) throw new Error("SOCKS5 proxy authentication is unsupported");
50
+ if (choice[1] === 2) {
51
+ const auth = readSocket(socket, b => b.length >= 2 ? 2 : null);
52
+ socket.write(Buffer.concat([Buffer.from([1, user.length]), user, Buffer.from([password.length]), password]));
53
+ const result = await auth; if (result[0] !== 1 || result[1] !== 0) throw new Error("SOCKS5 proxy authentication failed");
54
+ }
55
+ const host = Buffer.from(target.hostname);
56
+ if (host.length > 255) throw new Error("Proxy target hostname is too long");
57
+ const response = readSocket(socket, b => {
58
+ if (b.length < 5) return null;
59
+ const size = b[3] === 1 ? 10 : b[3] === 4 ? 22 : b[3] === 3 ? 7 + b[4] : 5;
60
+ return b.length >= size ? size : null;
61
+ });
62
+ socket.write(Buffer.concat([Buffer.from([5, 1, 0, 3, host.length]), host, Buffer.from([1, 187])]));
63
+ const result = await response;
64
+ if (result[0] !== 5 || result[1] !== 0 || ![1, 3, 4].includes(result[3])) throw new Error("SOCKS5 proxy rejected the connection");
65
+ } else {
66
+ const response = readSocket(socket, b => { const end = b.indexOf("\r\n\r\n"); return end < 0 ? null : end + 4; });
67
+ const credentials = proxy.username || proxy.password
68
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}\r\n` : "";
69
+ socket.write(`CONNECT ${target.hostname}:443 HTTP/1.1\r\nHost: ${target.hostname}:443\r\n${credentials}\r\n`);
70
+ const headers = (await response).toString("latin1");
71
+ const status = /^HTTP\/1\.[01] (\d{3})/.exec(headers)?.[1];
72
+ if (status !== "200") throw new Error(`HTTP proxy rejected CONNECT (${status || "invalid response"})`);
73
+ }
74
+ clearTimeout(timer); pending.delete(socket);
75
+ return socket;
76
+ } catch (error) { clearTimeout(timer); socket.destroy(); throw error; }
77
+ }
78
+
79
+ export function createProxyAgent(plan, { timeoutMs = 30_000 } = {}) {
80
+ const pending = new Set();
81
+ let closed = false;
82
+ const agent = new https.Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 4 });
83
+ const direct = agent.createConnection.bind(agent);
84
+ agent.createConnection = (options, callback) => {
85
+ if (closed) { queueMicrotask(() => callback(new Error("Proxy agent is closed"))); return undefined; }
86
+ const target = new URL(`https://${options.host}`), route = networkProxyFor(target, plan);
87
+ if (!route) return direct(options, callback);
88
+ void connectProxy(target, route, timeoutMs, pending).then(socket => {
89
+ if (closed) { socket.destroy(); callback(new Error("Proxy agent is closed")); return; }
90
+ const secure = tls.connect({ ...options, socket, rejectUnauthorized: true });
91
+ pending.add(secure);
92
+ const timer = setTimeout(() => secure.destroy(new Error("Proxy target TLS timed out")), timeoutMs);
93
+ const fail = (error) => { clearTimeout(timer); pending.delete(secure); secure.off("secureConnect", done); callback(error); };
94
+ const done = () => { clearTimeout(timer); pending.delete(secure); secure.off("error", fail); callback(null, secure); };
95
+ secure.once("error", fail); secure.once("secureConnect", done); secure.resume();
96
+ }, callback);
97
+ return undefined;
98
+ };
99
+ const destroy = agent.destroy.bind(agent);
100
+ agent.destroy = () => { closed = true; for (const socket of pending) socket.destroy(new Error("Proxy agent closed")); pending.clear(); destroy(); };
101
+ return agent;
102
+ }
@@ -309,7 +309,8 @@ export function prepareResponsesRequest(reqBody, {
309
309
  assertActive?.();
310
310
  const body = mutate ? reqBody : cloneJson(reqBody);
311
311
  assertActive?.();
312
- const currentInputItems = responsesInputItems(body.input, { assertActive, clone: !mutate });
312
+ // body is already an owned clone unless the caller explicitly allows mutation.
313
+ const currentInputItems = responsesInputItems(body.input, { assertActive, clone: false });
313
314
  const currentInputRefs = new Set(
314
315
  currentInputItems.filter((item) => item && typeof item === "object"),
315
316
  );
@@ -359,7 +360,9 @@ export function prepareResponsesRequest(reqBody, {
359
360
  // concrete Copilot RoutePlan.
360
361
  if (copilotBoundary) applyCopilotResponsesRequestPolicies(body);
361
362
  stripInternalResponsesInputFields(body.input, { assertActive });
362
- stripInternalResponsesInputFields(historyInputItems, { assertActive });
363
+ // The first pass already covers shared current items. Only an independent
364
+ // pre-adaptation snapshot needs another pass.
365
+ if (historyInputItems !== currentInputItems) stripInternalResponsesInputFields(historyInputItems, { assertActive });
363
366
  assertActive?.();
364
367
  const retainedCurrentInputStart = body.input.findIndex((item) => currentInputRefs.has(item));
365
368
 
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
+ import { withFileLockSync } from "./lock.mjs";
5
6
  import { AUTO_REVIEW_REASONING_EFFORTS, DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
6
7
  import { loadRuntimeConfig, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
7
8
  import {
@@ -155,7 +156,10 @@ export function decodedBodyLimitPreference({ env = process.env, home = os.homedi
155
156
  return { bytes: RUNTIME_DEFAULTS.maxDecodedBodyBytes, mib: DECODED_BODY_LIMIT_MIN_MIB, source: "default" };
156
157
  }
157
158
 
158
- export function writeDecodedBodyLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
159
+ export function writeDecodedBodyLimitMib(mib, options = {}) {
160
+ return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeDecodedBodyLimitMibUnlocked(mib, options));
161
+ }
162
+ function writeDecodedBodyLimitMibUnlocked(mib, { env = process.env, home = os.homedir() } = {}) {
159
163
  const filePath = userSettingsPath({ env, home });
160
164
  const settings = readUserSettings({ env, home, strict: true });
161
165
  const next = { ...settings };
@@ -177,7 +181,10 @@ export function writeDecodedBodyLimitMib(mib, { env = process.env, home = os.hom
177
181
  return { changed, filePath, ...decodedBodyLimitPreference({ env, home }) };
178
182
  }
179
183
 
180
- export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
184
+ export function writeResponseHistoryLimitMib(mib, options = {}) {
185
+ return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeResponseHistoryLimitMibUnlocked(mib, options));
186
+ }
187
+ function writeResponseHistoryLimitMibUnlocked(mib, { env = process.env, home = os.homedir() } = {}) {
181
188
  const filePath = userSettingsPath({ env, home });
182
189
  const settings = readUserSettings({ env, home, strict: true });
183
190
  const next = { ...settings };
@@ -199,7 +206,10 @@ export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os
199
206
  return { changed, filePath, ...responseHistoryLimitPreference({ env, home }) };
200
207
  }
201
208
 
202
- export function writeAutoReviewModel(model, { env = process.env, home = os.homedir(), reasoningEffort } = {}) {
209
+ export function writeAutoReviewModel(model, options = {}) {
210
+ return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeAutoReviewModelUnlocked(model, options));
211
+ }
212
+ function writeAutoReviewModelUnlocked(model, { env = process.env, home = os.homedir(), reasoningEffort } = {}) {
203
213
  if (reasoningEffort !== undefined && reasoningEffort !== null
204
214
  && !AUTO_REVIEW_REASONING_EFFORTS.includes(reasoningEffort)) {
205
215
  throw new Error("Auto-review reasoning effort is invalid");
@@ -220,7 +230,10 @@ export function writeAutoReviewModel(model, { env = process.env, home = os.homed
220
230
  return { changed, filePath, model: value || DEFAULT_CODEX_AUTO_REVIEW_MODEL };
221
231
  }
222
232
 
223
- export function writeTerminalAnimationTheme(theme, { env = process.env, home = os.homedir() } = {}) {
233
+ export function writeTerminalAnimationTheme(theme, options = {}) {
234
+ return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeTerminalAnimationThemeUnlocked(theme, options));
235
+ }
236
+ function writeTerminalAnimationThemeUnlocked(theme, { env = process.env, home = os.homedir() } = {}) {
224
237
  if (!isTerminalAnimationTheme(theme)) {
225
238
  const choices = TERMINAL_ANIMATION_THEMES.map(({ id }) => id).join(", ");
226
239
  throw new Error(`Terminal animation must be one of: ${choices}`);
package/src/version.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { operationError } from "./operation-error.mjs";
4
5
 
5
- const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/DaleXiao/codex-copilot-dx/releases/latest";
6
- const MAX_RELEASE_BYTES = 256 * 1024;
6
+ const GITHUB_LATEST_RELEASE_URL = "https://github.com/DaleXiao/codex-copilot-dx/releases/latest";
7
7
 
8
8
  export function localPackageVersion() {
9
9
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -37,62 +37,39 @@ export function githubReleaseVersion(tag) {
37
37
  return match.slice(1).join(".");
38
38
  }
39
39
 
40
- async function readReleaseJson(response, signal) {
41
- const reader = response.body?.getReader?.();
42
- if (!reader) throw new Error("GitHub release response has no readable body");
43
- const cancel = () => { void reader.cancel().catch(() => {}); };
44
- signal.addEventListener("abort", cancel, { once: true });
45
- const chunks = [];
46
- let bytes = 0;
47
- let complete = false;
48
- try {
49
- if (Number(response.headers?.get?.("content-length")) > MAX_RELEASE_BYTES) {
50
- throw new Error("GitHub release response is too large");
51
- }
52
- while (!signal.aborted) {
53
- const { done, value } = await reader.read();
54
- if (done) {
55
- complete = true;
56
- break;
57
- }
58
- bytes += value.byteLength;
59
- if (bytes > MAX_RELEASE_BYTES) throw new Error("GitHub release response is too large");
60
- chunks.push(Buffer.from(value));
61
- }
62
- if (signal.aborted) throw new Error("GitHub release request timed out");
63
- return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
64
- } finally {
65
- signal.removeEventListener("abort", cancel);
66
- if (!complete) cancel();
67
- reader.releaseLock();
68
- }
69
- }
70
-
71
- export async function fetchLatestRelease({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
40
+ export async function lookupLatestRelease({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
72
41
  const controller = new AbortController();
73
42
  const timer = setTimeout(() => controller.abort(), timeoutMs);
74
43
  try {
75
44
  const resp = await fetchImpl(GITHUB_LATEST_RELEASE_URL, {
76
- headers: { Accept: "application/vnd.github+json", "User-Agent": "codex-copilot-dx" },
77
- redirect: "error",
45
+ headers: { Accept: "text/html", "User-Agent": "codex-copilot-dx" },
46
+ redirect: "manual",
78
47
  signal: controller.signal,
79
48
  });
80
- if (!resp.ok) {
81
- void resp.body?.cancel?.().catch(() => {});
82
- return null;
49
+ void resp.body?.cancel?.().catch(() => {});
50
+ if ([403, 429].includes(resp.status)) throw operationError("github_rate_limit", `GitHub release lookup was refused (HTTP ${resp.status}); retry later. No update was installed.`, { status: resp.status });
51
+ if (![301, 302, 303, 307, 308].includes(resp.status)) throw operationError("github_http_error", `GitHub release lookup returned HTTP ${resp.status}; no stable release redirect was received. No update was installed.`, { status: resp.status });
52
+ let url;
53
+ try { url = new URL(resp.headers.get("location"), GITHUB_LATEST_RELEASE_URL); } catch {}
54
+ const match = url?.pathname.match(/^\/DaleXiao\/codex-copilot-dx\/releases\/tag\/([^/]+)$/i);
55
+ const tagName = match?.[1], version = githubReleaseVersion(tagName);
56
+ if (!url || url.origin !== "https://github.com" || url.username || url.password || url.search || url.hash || !version) {
57
+ throw operationError("github_invalid_redirect", "GitHub did not return a valid stable release tag for this repository. No update was installed.");
83
58
  }
84
- const data = await readReleaseJson(resp, controller.signal);
85
- const version = githubReleaseVersion(data?.tag_name);
86
- return version && data.draft === false && data.prerelease === false
87
- ? { version, tagName: data.tag_name }
88
- : null;
89
- } catch {
90
- return null;
59
+ return { version, tagName };
60
+ } catch (error) {
61
+ if (error.code?.startsWith("github_")) throw error;
62
+ if (controller.signal.aborted) throw operationError("github_timeout", "GitHub release lookup timed out. No update was installed.");
63
+ throw operationError(error.cause?.code || "github_network_error", "Could not connect to the GitHub release page. Check your network or proxy. No update was installed.");
91
64
  } finally {
92
65
  clearTimeout(timer);
93
66
  }
94
67
  }
95
68
 
69
+ export async function fetchLatestRelease(options = {}) {
70
+ try { return await lookupLatestRelease(options); } catch { return null; }
71
+ }
72
+
96
73
  export async function fetchLatestVersion(options) {
97
74
  return (await fetchLatestRelease(options))?.version || null;
98
75
  }