codex-copilot-dx 0.4.17 → 0.4.19

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 CHANGED
@@ -83,11 +83,16 @@ Environment variables:
83
83
 
84
84
  | Variable | Default | Description |
85
85
  |----------|---------|-------------|
86
- | `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; set `0.0.0.0` only if you knowingly expose it on a trusted network |
86
+ | `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; only loopback hosts are allowed by default |
87
87
  | `ADAPTER_PORT` | `2026` | Port for the adapter |
88
+ | `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
88
89
  | `CCDX_MAX_BODY_BYTES` | `134217728` | Maximum compressed/raw request body size |
89
90
  | `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
90
91
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
92
+ | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for transient Copilot upstream network errors; capped at `5` |
93
+ | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
94
+ | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
95
+ | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
91
96
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
92
97
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
93
98
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
@@ -110,6 +115,10 @@ Environment variables:
110
115
 
111
116
  The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usage.jsonl` when upstream responses include usage fields. It logs counts, model names, API surface, and response IDs only; it does not log prompts, completions, tool arguments, or image content.
112
117
 
118
+ ### Debug logging
119
+
120
+ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or set `CCDX_LOG_PATH` to a custom file. Add `CCDX_LOG_LEVEL=debug` to include upstream request attempts, retry causes, status codes, and timings. Debug logs do not include prompts, completions, request bodies, or authorization tokens.
121
+
113
122
  ```bash
114
123
  codex-copilot-dx usage
115
124
  ```
package/bin/cli.mjs CHANGED
@@ -8,10 +8,12 @@ import { startAdapter } from "../src/adapter.mjs";
8
8
  import { listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
9
  import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
10
10
  import { status } from "../src/status.mjs";
11
+ import { configureLogging } from "../src/log.mjs";
11
12
  import { printUsageSummary } from "../src/usage.mjs";
12
13
  import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
13
14
  import { runDoctor } from "../src/doctor.mjs";
14
15
  import { checkRunningAdapter } from "../src/running-adapter.mjs";
16
+ import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
15
17
 
16
18
  const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
17
19
  const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
@@ -20,9 +22,11 @@ const EXISTING_ADAPTER_TIMEOUT_MS = parseInt(process.env.CCDX_EXISTING_ADAPTER_T
20
22
  const LOCAL_VERSION = localPackageVersion();
21
23
  const command = process.argv[2];
22
24
  const CONFIGURE_CLAUDE_DESKTOP = command === "--configure-claude-desktop" || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
25
+ const LOGGING = configureLogging();
23
26
 
24
- function isLoopbackHost(host) {
25
- return ["127.0.0.1", "localhost", "::1"].includes(String(host).toLowerCase());
27
+ if (LOGGING.filePath) {
28
+ console.log(status("info", `Debug log: ${LOGGING.filePath}`));
29
+ if (LOGGING.level === "debug") console.log(status("debug", "Debug logging enabled"));
26
30
  }
27
31
 
28
32
  async function refreshClaudeDesktopModelDefs() {
@@ -128,6 +132,7 @@ checkForUpdate({ currentVersion: LOCAL_VERSION }).then(({ latestVersion, updateA
128
132
  });
129
133
 
130
134
  try {
135
+ assertSafeAdapterHost(ADAPTER_HOST, process.env);
131
136
  if (await reuseRunningAdapterIfAvailable()) process.exit(0);
132
137
 
133
138
  // Ensure GitHub login, using device flow if no token exists.
@@ -138,7 +143,7 @@ try {
138
143
  const claudeDesktopModelDefs = await refreshClaudeDesktopModelDefs();
139
144
 
140
145
  if (!isLoopbackHost(ADAPTER_HOST)) {
141
- console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback. Use only on trusted networks.`));
146
+ console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback because CCDX_ALLOW_LAN=1 is set. Use only on trusted networks.`));
142
147
  }
143
148
 
144
149
  const claudeDesktopApiKey = CONFIGURE_CLAUDE_DESKTOP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
package/src/copilot.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
+ import { debugLog } from "./log.mjs";
5
6
  import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
7
 
7
8
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
@@ -11,8 +12,14 @@ const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
11
12
  const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
12
13
  const DEFAULT_IMG_CONCURRENCY = 4;
13
14
  const IMG_MAX_CONCURRENCY = 12;
15
+ const DEFAULT_UPSTREAM_RETRIES = 2;
16
+ const MAX_UPSTREAM_RETRIES = 5;
17
+ const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
18
+ const MAX_UPSTREAM_RETRY_DELAY_MS = 5000;
14
19
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
15
20
  const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
21
+ const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
22
+ const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
16
23
  let sharpImport = null;
17
24
 
18
25
  export function parseImageConcurrency(value) {
@@ -20,6 +27,16 @@ export function parseImageConcurrency(value) {
20
27
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
21
28
  }
22
29
 
30
+ export function parseUpstreamRetries(value) {
31
+ const n = Number.parseInt(value, 10);
32
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, MAX_UPSTREAM_RETRIES) : DEFAULT_UPSTREAM_RETRIES;
33
+ }
34
+
35
+ export function parseUpstreamRetryDelayMs(value) {
36
+ const n = Number.parseInt(value, 10);
37
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_UPSTREAM_RETRY_DELAY_MS) : DEFAULT_UPSTREAM_RETRY_DELAY_MS;
38
+ }
39
+
23
40
  export function parseApiBase(data) {
24
41
  return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
25
42
  ? data.endpoints.api
@@ -34,6 +51,126 @@ export function responsesEndpointPath() {
34
51
  return "/responses";
35
52
  }
36
53
  const GITHUB_API = "https://api.github.com";
54
+ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
55
+ "UND_ERR_CONNECT_TIMEOUT",
56
+ "UND_ERR_HEADERS_TIMEOUT",
57
+ "UND_ERR_BODY_TIMEOUT",
58
+ "ETIMEDOUT",
59
+ "ECONNRESET",
60
+ "ECONNREFUSED",
61
+ "EPIPE",
62
+ "EAI_AGAIN",
63
+ "ENOTFOUND",
64
+ ]);
65
+ const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
66
+
67
+ function upstreamTarget(url) {
68
+ try {
69
+ const parsed = new URL(url);
70
+ return `${parsed.hostname}${parsed.pathname}`;
71
+ } catch {
72
+ return String(url);
73
+ }
74
+ }
75
+
76
+ function upstreamErrorCode(err) {
77
+ return err?.cause?.code || err?.code || "";
78
+ }
79
+
80
+ function isAbortError(err, signal) {
81
+ return signal?.aborted || err?.name === "AbortError" || err?.code === "ABORT_ERR";
82
+ }
83
+
84
+ function abortError(signal) {
85
+ if (signal?.reason instanceof Error) return signal.reason;
86
+ const err = new Error("The operation was aborted");
87
+ err.name = "AbortError";
88
+ return err;
89
+ }
90
+
91
+ function sleep(ms, { signal } = {}) {
92
+ if (signal?.aborted) return Promise.reject(abortError(signal));
93
+ return new Promise((resolve, reject) => {
94
+ let timer;
95
+ const cleanup = () => {
96
+ clearTimeout(timer);
97
+ signal?.removeEventListener("abort", onAbort);
98
+ };
99
+ const onAbort = () => {
100
+ cleanup();
101
+ reject(abortError(signal));
102
+ };
103
+ timer = setTimeout(() => {
104
+ cleanup();
105
+ resolve();
106
+ }, ms);
107
+ if (!signal) return;
108
+ signal.addEventListener("abort", onAbort, { once: true });
109
+ });
110
+ }
111
+
112
+ function requestMethod(init = {}) {
113
+ return String(init.method || "GET").toUpperCase();
114
+ }
115
+
116
+ export function isRetryableUpstreamError(err, { signal } = {}) {
117
+ if (isAbortError(err, signal)) return false;
118
+ return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
119
+ }
120
+
121
+ export function isRetryableUpstreamStatus(status, method = "GET") {
122
+ const safeMethod = ["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase());
123
+ return safeMethod && RETRYABLE_UPSTREAM_STATUSES.has(status);
124
+ }
125
+
126
+ function upstreamRetryDelay(attempt, baseDelayMs) {
127
+ return Math.min(baseDelayMs * (2 ** attempt), MAX_UPSTREAM_RETRY_DELAY_MS);
128
+ }
129
+
130
+ function describeUpstreamError(err) {
131
+ const code = upstreamErrorCode(err);
132
+ return [code, err?.cause?.message || err?.message].filter(Boolean).join(": ") || "network error";
133
+ }
134
+
135
+ export async function fetchCopilotUpstream(
136
+ url,
137
+ init = {},
138
+ {
139
+ fetchImpl = fetch,
140
+ retries = UPSTREAM_RETRIES,
141
+ retryDelayMs = UPSTREAM_RETRY_DELAY_MS,
142
+ } = {},
143
+ ) {
144
+ const retryCount = parseUpstreamRetries(retries);
145
+ const baseDelay = parseUpstreamRetryDelayMs(retryDelayMs);
146
+ const method = requestMethod(init);
147
+ const signal = init.signal;
148
+ const target = upstreamTarget(url);
149
+ const totalStart = Date.now();
150
+
151
+ for (let attempt = 0; ; attempt += 1) {
152
+ const attemptStart = Date.now();
153
+ debugLog(`upstream ${method} ${target} attempt=${attempt + 1}/${retryCount + 1}`);
154
+ try {
155
+ const resp = await fetchImpl(url, init);
156
+ debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
157
+ if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
158
+ await resp.arrayBuffer?.().catch(() => {});
159
+ const delay = upstreamRetryDelay(attempt, baseDelay);
160
+ console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
161
+ await sleep(delay, { signal });
162
+ continue;
163
+ }
164
+ return resp;
165
+ } catch (e) {
166
+ debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
167
+ if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
168
+ const delay = upstreamRetryDelay(attempt, baseDelay);
169
+ console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
170
+ await sleep(delay, { signal });
171
+ }
172
+ }
173
+ }
37
174
 
38
175
  async function sharp() {
39
176
  sharpImport ||= import("sharp");
@@ -326,7 +463,7 @@ export function resetCopilotTokenForTests() {
326
463
  }
327
464
 
328
465
  // chatReq is already converted by the adapter. The caller parses the raw Response.
329
- export async function chatCompletions(chatReq, { signal } = {}) {
466
+ export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions } = {}) {
330
467
  const token = await getCopilotToken({ signal });
331
468
  const messages = chatReq.messages || [];
332
469
  const headers = buildHeaders({
@@ -335,25 +472,25 @@ export async function chatCompletions(chatReq, { signal } = {}) {
335
472
  initiator: computeInitiator(messages),
336
473
  vision: computeVision(messages),
337
474
  });
338
- return fetch(`${getApiBase()}/chat/completions`, {
475
+ return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
339
476
  method: "POST",
340
477
  headers,
341
478
  body: JSON.stringify(chatReq),
342
479
  signal,
343
- });
480
+ }, { fetchImpl, ...retryOptions });
344
481
  }
345
482
 
346
- export async function listModels({ signal } = {}) {
483
+ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
347
484
  const token = await getCopilotToken({ signal });
348
485
  const headers = buildHeaders({
349
486
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
350
487
  });
351
- const resp = await fetch(`${getApiBase()}/models`, { headers, signal });
488
+ const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
352
489
  return { status: resp.status, body: await resp.text() };
353
490
  }
354
491
 
355
492
  // Responses-only models go directly to Copilot's /responses endpoint.
356
- export async function responses(reqBody, { signal } = {}) {
493
+ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
357
494
  const token = await getCopilotToken({ signal });
358
495
  const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
359
496
  await optimizeImagesInBody(reqBody);
@@ -371,12 +508,12 @@ export async function responses(reqBody, { signal } = {}) {
371
508
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
372
509
 
373
510
  try {
374
- return await fetch(`${getApiBase()}${responsesEndpointPath()}`, {
511
+ return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
375
512
  method: "POST",
376
513
  headers,
377
514
  body: bodyBuf,
378
515
  signal,
379
- });
516
+ }, { fetchImpl, ...retryOptions });
380
517
  } catch (e) {
381
518
  const cause = e?.cause;
382
519
  const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
package/src/log.mjs ADDED
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { format } from "node:util";
5
+ import { status } from "./status.mjs";
6
+
7
+ let globalInstall = null;
8
+
9
+ export function normalizeLogLevel(value) {
10
+ return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
11
+ }
12
+
13
+ export function defaultLogPath({ home = os.homedir() } = {}) {
14
+ return path.join(home, ".local", "share", "codex-copilot-dx", "debug.log");
15
+ }
16
+
17
+ export function resolveLogPath(value, { home = os.homedir() } = {}) {
18
+ const raw = String(value || "").trim();
19
+ if (!raw) return null;
20
+ if (["1", "true", "yes"].includes(raw.toLowerCase())) return defaultLogPath({ home });
21
+ if (raw === "~") return home;
22
+ if (raw.startsWith("~/")) return path.join(home, raw.slice(2));
23
+ return raw;
24
+ }
25
+
26
+ export function isDebugLoggingEnabled(env = process.env) {
27
+ return normalizeLogLevel(env.CCDX_LOG_LEVEL) === "debug";
28
+ }
29
+
30
+ function renderLogPayload(args) {
31
+ const stamp = new Date().toISOString();
32
+ const text = format(...args);
33
+ return `${text.split(/\r?\n/).map((line) => `${stamp} ${line}`).join("\n")}\n`;
34
+ }
35
+
36
+ export function configureLogging({
37
+ env = process.env,
38
+ consoleObj = console,
39
+ home = os.homedir(),
40
+ } = {}) {
41
+ const level = normalizeLogLevel(env.CCDX_LOG_LEVEL);
42
+ const filePath = resolveLogPath(env.CCDX_LOG_PATH, { home });
43
+ if (!filePath) return { level, filePath: null, cleanup: () => {} };
44
+ if (consoleObj === console && globalInstall) {
45
+ return { level, filePath: globalInstall.filePath, cleanup: globalInstall.cleanup };
46
+ }
47
+
48
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
49
+
50
+ const methods = ["log", "warn", "error", "debug"];
51
+ const originals = Object.fromEntries(methods.map((method) => [
52
+ method,
53
+ typeof consoleObj[method] === "function" ? consoleObj[method].bind(consoleObj) : () => {},
54
+ ]));
55
+ let writeFailed = false;
56
+
57
+ function append(args) {
58
+ try {
59
+ fs.appendFileSync(filePath, renderLogPayload(args), { encoding: "utf8", mode: 0o600 });
60
+ } catch (e) {
61
+ if (writeFailed) return;
62
+ writeFailed = true;
63
+ originals.error(`codex-copilot-dx log write failed: ${e.message}`);
64
+ }
65
+ }
66
+
67
+ for (const method of methods) {
68
+ consoleObj[method] = (...args) => {
69
+ originals[method](...args);
70
+ append(args);
71
+ };
72
+ }
73
+
74
+ const cleanup = () => {
75
+ for (const method of methods) consoleObj[method] = originals[method];
76
+ if (consoleObj === console && globalInstall?.cleanup === cleanup) globalInstall = null;
77
+ };
78
+
79
+ if (consoleObj === console) globalInstall = { filePath, cleanup };
80
+ return { level, filePath, cleanup };
81
+ }
82
+
83
+ export function debugLog(message, env = process.env) {
84
+ if (isDebugLoggingEnabled(env)) console.log(status("debug", message));
85
+ }
@@ -0,0 +1,13 @@
1
+ export function isLoopbackHost(host) {
2
+ const normalized = String(host || "").toLowerCase().replace(/^\[(.*)\]$/, "$1");
3
+ return ["127.0.0.1", "localhost", "::1"].includes(normalized);
4
+ }
5
+
6
+ export function isLanAllowed(env = process.env) {
7
+ return ["1", "true", "yes"].includes(String(env.CCDX_ALLOW_LAN || "").toLowerCase());
8
+ }
9
+
10
+ export function assertSafeAdapterHost(host = "127.0.0.1", env = process.env) {
11
+ if (isLoopbackHost(host) || isLanAllowed(env)) return;
12
+ throw new Error(`Refusing to bind ADAPTER_HOST=${host} beyond loopback without CCDX_ALLOW_LAN=1. This adapter carries your GitHub Copilot access.`);
13
+ }
package/src/status.mjs CHANGED
@@ -4,6 +4,7 @@ const LABELS = {
4
4
  wait: "[WAIT]",
5
5
  warn: "[WARN]",
6
6
  err: "[ERR]",
7
+ debug: "[DEBUG]",
7
8
  };
8
9
 
9
10
  export function status(kind, message) {