codex-copilot-dx 0.9.16 → 0.9.17

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
@@ -62,6 +62,8 @@ The dashboard does not poll in the background; refresh when needed.
62
62
  break continuations. Generated images and Codex transcripts are not deleted.
63
63
  - Optional image setup/maintenance never blocks the primary service. Disabled
64
64
  users get no CCDX image skill or MCP installation.
65
+ - Image API redirects must stay on the same HTTPS origin. Returned images must
66
+ use public HTTPS URLs; well-known NAT64 mappings follow IPv4 address rules.
65
67
  - Keep the adapter on loopback. LAN mode is only for trusted networks; do not
66
68
  expose it to the public internet. The dashboard is not an authentication wall
67
69
  for the API. Diagnostic errors may contain sensitive upstream text.
package/bin/cli.mjs CHANGED
@@ -75,11 +75,16 @@ if (CLI.command === "image") {
75
75
  }
76
76
  }
77
77
  if (CLI.command === "usage") {
78
- await printUsageSummary({
79
- format: CLI.outputFormat || "auto",
80
- output: process.stdout,
81
- });
82
- process.exit(0);
78
+ try {
79
+ await printUsageSummary({
80
+ format: CLI.outputFormat || "auto",
81
+ output: process.stdout,
82
+ });
83
+ process.exit(0);
84
+ } catch (e) {
85
+ console.error(status("err", e.message));
86
+ process.exit(1);
87
+ }
83
88
  }
84
89
  if (CLI.command === "cache") {
85
90
  try {
package/docs/REFERENCE.md CHANGED
@@ -203,7 +203,13 @@ ccdx disable-image
203
203
 
204
204
  `image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
205
205
 
206
- Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
206
+ Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. Well-known NAT64 `64:ff9b::/96` addresses apply the same IPv4 public/private rules to the embedded address, without rejecting legitimate public translations. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. Host must name loopback, the actual local socket address (including equivalent IPv6/IPv4-mapped forms), or the explicitly configured bind hostname. Missing Host remains compatible with HTTP/1.0 and in-process clients. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
207
+
208
+ Image API generation/editing requests retain same-origin HTTPS redirects, with Fetch's existing POST/GET rules, a 20-hop limit and one shared timeout/cancellation signal. Cross-origin, HTTPS-to-HTTP, credential-bearing or invalid redirect targets are refused before another request is sent. Use the final API base URL when a service redirects to another origin. Redirect bodies are cancelled; generation failures are not automatically retried. The provider's returned image-download URLs still never follow redirects.
209
+
210
+ The Chat compatibility bridge ignores repeated complete tool names only when the name is declared and concatenation cannot be a fragment of another declared name. Overlapping fragments, delayed names and ambiguous/undeclared streams retain their existing assembly rules; argument deltas and tool-call IDs are not deduplicated. Native Responses routing and payloads are unchanged.
211
+
212
+ `ccdx usage` reports genuine log-read failures with the normal error line and exit code 1, without an uncaught exception dump. Missing logs remain a successful empty summary; malformed records are skipped with a warning rather than making usage unavailable.
207
213
 
208
214
  With `qwen-image-3.0-pro` or `qwen-image-3.0` using either supported protocol, CCDX also exposes `edit_image`. Ask Codex to modify a specific CCDX-generated image; it passes that image's returned `image_id` and the edit instruction to the same configured provider. Each edit returns a new image and ID while preserving the original; omitting `size` retains the source's requested dimensions. The Qwen request contains the source image again on every edit, as required by its [single-turn image editing API](https://help.aliyun.com/zh/model-studio/qwen-image-generation-and-editing-api-reference).
209
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.9.16",
3
+ "version": "0.9.17",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -215,7 +215,7 @@ export function createAdapterHandler(options = {}) {
215
215
  if (pathname === "/mcp/image") {
216
216
  if (imageMcpHandler) return imageMcpHandler(req, res);
217
217
  return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
218
- imageMcpHandler ||= createImageMcpHandler();
218
+ imageMcpHandler ||= createImageMcpHandler({ localHostname: options.imageMcpHostname });
219
219
  return imageMcpHandler(req, res);
220
220
  });
221
221
  }
@@ -460,7 +460,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
460
460
  const terminalActivity = ownsTerminalActivity
461
461
  ? createTerminalActivityIndicator({ theme: options.terminalAnimationTheme })
462
462
  : options.terminalActivity;
463
- const handler = createAdapterHandler({ ...options, terminalActivity });
463
+ const handler = createAdapterHandler({ ...options, terminalActivity, imageMcpHostname: host });
464
464
  const server = http.createServer(handler);
465
465
  const cleanupTerminalActivity = () => {
466
466
  if (ownsTerminalActivity) terminalActivity?.cleanup?.();
package/src/image-mcp.mjs CHANGED
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { isLoopbackAddress } from "./observability.mjs";
5
+ import { isIP } from "node:net";
6
+ import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
6
7
  import { generateImage, supportsImageEditing, IMAGE_MAX_BYTES } from "./image-provider.mjs";
7
8
  import { readImageProviderConfig } from "./image-provider-config.mjs";
8
9
  import { createImageReferenceStore } from "./image-references.mjs";
@@ -49,6 +50,28 @@ function sameHostSocket(socket) {
49
50
  return remote && local && remote === local;
50
51
  }
51
52
 
53
+ function canonicalIpHost(hostname) {
54
+ const address = hostname.replace(/^\[|\]$/g, "");
55
+ const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(address);
56
+ if (!mapped) return address;
57
+ const high = Number.parseInt(mapped[1], 16);
58
+ const low = Number.parseInt(mapped[2], 16);
59
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
60
+ }
61
+
62
+ function ownHostHeader(host, socket, bindHostname) {
63
+ if (isLoopbackHostHeader(host)) return true;
64
+ if (typeof host !== "string" || !host || host.includes(",")) return false;
65
+ try {
66
+ const url = new URL(`http://${host}`);
67
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) return false;
68
+ if (bindHostname && url.hostname === bindHostname) return true;
69
+ const local = String(socket?.localAddress || "");
70
+ if (!isIP(local)) return false;
71
+ return canonicalIpHost(url.hostname) === canonicalIpHost(new URL(`http://${isIP(local) === 6 ? `[${local}]` : local}`).hostname);
72
+ } catch { return false; }
73
+ }
74
+
52
75
  function writeJson(res, statusCode, body) {
53
76
  if (res.destroyed || res.writableEnded) return;
54
77
  res.writeHead(statusCode, {
@@ -134,9 +157,14 @@ export function createImageMcpHandler({
134
157
  configLoader = () => readImageProviderConfig(),
135
158
  generateImageFn = generateImage,
136
159
  maxConcurrent = 2,
160
+ localHostname = "",
137
161
  imageReferences = createImageReferenceStore(),
138
162
  imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
139
163
  } = {}) {
164
+ let bindHostname = "";
165
+ if (localHostname && !isIP(localHostname.replace(/^\[|\]$/g, ""))) {
166
+ try { bindHostname = new URL(`http://${localHostname}`).hostname; } catch {}
167
+ }
140
168
  let active = 0;
141
169
  const metrics = { total: 0, succeeded: 0, failed: 0, busy: 0, cancelled: 0, delivery_failures: 0 };
142
170
  const handler = async function imageMcpHandler(req, res) {
@@ -144,6 +172,10 @@ export function createImageMcpHandler({
144
172
  writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
145
173
  return;
146
174
  }
175
+ if (!ownHostHeader(req.headers?.host, req.socket, bindHostname)) {
176
+ writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
177
+ return;
178
+ }
147
179
  if (req.method !== "POST") {
148
180
  res.setHeader?.("Allow", "POST");
149
181
  writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
@@ -207,10 +207,11 @@ function publicAddress(address) {
207
207
  catch { return false; }
208
208
  if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
209
209
  || /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
210
- const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower);
210
+ const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower)
211
+ || /^64:ff9b::(?:([a-f0-9]{1,4}):)?([a-f0-9]{1,4})?$/.exec(lower);
211
212
  if (!mapped) return true;
212
- const high = Number.parseInt(mapped[1], 16);
213
- const low = Number.parseInt(mapped[2], 16);
213
+ const high = Number.parseInt(mapped[1] || "0", 16);
214
+ const low = Number.parseInt(mapped[2] || "0", 16);
214
215
  return ipv4Public([high >> 8, high & 255, low >> 8, low & 255].join("."));
215
216
  }
216
217
 
@@ -305,20 +306,36 @@ export async function generateImage(config, {
305
306
  const timeout = AbortSignal.timeout(timeoutMs);
306
307
  const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
307
308
  let response;
308
- try {
309
- response = await fetchImpl(config.endpoint, {
310
- method: "POST",
311
- headers: {
312
- Authorization: `Bearer ${config.api_key}`,
313
- "Content-Type": "application/json",
314
- Accept: "application/json",
315
- },
316
- body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize, normalizedImage)),
317
- signal: requestSignal,
318
- });
319
- } catch (error) {
320
- if (requestSignal.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
321
- throw providerError("Image API could not be reached");
309
+ let requestUrl = config.endpoint;
310
+ let method = "POST";
311
+ let requestBody = JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize, normalizedImage));
312
+ let headers = { Authorization: `Bearer ${config.api_key}`, "Content-Type": "application/json", Accept: "application/json" };
313
+ for (let redirects = 0; ; redirects += 1) {
314
+ try {
315
+ if (requestSignal.aborted) throw requestSignal.reason;
316
+ response = await fetchImpl(requestUrl, { method, headers, body: requestBody, signal: requestSignal, redirect: "manual" });
317
+ } catch (error) {
318
+ if (requestSignal.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
319
+ throw providerError("Image API could not be reached");
320
+ }
321
+ const location = [301, 302, 303, 307, 308].includes(response.status) ? response.headers?.get?.("location") : null;
322
+ if (location == null) break;
323
+ await response.body?.cancel?.().catch?.(() => {});
324
+ let target;
325
+ try { target = new URL(location, requestUrl); }
326
+ catch { throw providerError("Image API returned an invalid redirect", "ccdx_image_redirect_unsafe"); }
327
+ if (target.protocol !== "https:" || target.username || target.password || target.origin !== new URL(requestUrl).origin) {
328
+ throw providerError("Image API redirect was refused; use a same-origin HTTPS endpoint", "ccdx_image_redirect_unsafe");
329
+ }
330
+ if (redirects >= 20) throw providerError("Image API returned too many redirects", "ccdx_image_redirect_limit");
331
+ // Preserve Fetch's method/body rules for legitimate same-origin redirects.
332
+ if (((response.status === 301 || response.status === 302) && method === "POST") || (response.status === 303 && method !== "GET")) {
333
+ method = "GET";
334
+ requestBody = undefined;
335
+ headers = { ...headers };
336
+ delete headers["Content-Type"];
337
+ }
338
+ requestUrl = target.href;
322
339
  }
323
340
  const body = await boundedJson(response);
324
341
  if (!response.ok) {
@@ -233,6 +233,9 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
233
233
  delete chatReq.max_tokens;
234
234
  const requestModel = chatReq.model || "unknown";
235
235
  let upstreamReq = withChatStreamUsage(chatReq);
236
+ // Retain names only, not the request's schemas, images or message history.
237
+ const declaredToolNames = new Set((Array.isArray(upstreamReq.tools) ? upstreamReq.tools : [])
238
+ .map((tool) => tool?.function?.name).filter((name) => typeof name === "string" && name));
236
239
  let {
237
240
  abort,
238
241
  bodyText,
@@ -450,7 +453,17 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
450
453
  for (const tc of delta.tool_calls) {
451
454
  const { tool, created } = await ensureToolCall(tc);
452
455
  if (tc.id) tool.callId = tc.id;
453
- if (!created && tc.function?.name) tool.name += tc.function.name;
456
+ if (!created && tc.function?.name) {
457
+ const name = tc.function.name;
458
+ let completeRepeat = name === tool.name && declaredToolNames.has(name);
459
+ if (completeRepeat) {
460
+ // An overlap may be a real fragment of a longer declared name.
461
+ for (const declared of declaredToolNames) {
462
+ if (declared.startsWith(tool.name + name)) { completeRepeat = false; break; }
463
+ }
464
+ }
465
+ if (!completeRepeat) tool.name += name;
466
+ }
454
467
  const argumentDelta = tc.function?.arguments;
455
468
  toolArgumentGuard.observe(tool.outputIndex, argumentDelta);
456
469
  if (argumentDelta) {