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 +2 -0
- package/bin/cli.mjs +10 -5
- package/docs/REFERENCE.md +7 -1
- package/package.json +1 -1
- package/src/adapter.mjs +2 -2
- package/src/image-mcp.mjs +33 -1
- package/src/image-provider.mjs +34 -17
- package/src/responses-bridge.mjs +14 -1
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
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 {
|
|
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"));
|
package/src/image-provider.mjs
CHANGED
|
@@ -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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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) {
|
package/src/responses-bridge.mjs
CHANGED
|
@@ -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)
|
|
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) {
|