codex-copilot-dx 0.7.7 → 0.7.9
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 +30 -2
- package/bin/cli.mjs +38 -2
- package/package.json +1 -1
- package/src/adapter.mjs +12 -0
- package/src/cli-image.mjs +230 -0
- package/src/cli-options.mjs +18 -0
- package/src/cli-status.mjs +12 -0
- package/src/config.mjs +88 -6
- package/src/copilot-responses-compat.mjs +28 -18
- package/src/image-mcp.mjs +171 -0
- package/src/image-provider-config.mjs +190 -0
- package/src/image-provider.mjs +268 -0
- package/src/image-readiness.mjs +55 -0
- package/src/image-skill.mjs +143 -0
- package/src/image-tool-client.mjs +141 -0
- package/src/observability.mjs +2 -0
- package/src/response-failures.mjs +100 -0
- package/src/responses-handler.mjs +2 -0
- package/src/responses-proxy.mjs +181 -35
- package/src/responses-request.mjs +23 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
|
|
5
|
+
export const IMAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
export const IMAGE_GENERATION_TIMEOUT_MS = 180_000;
|
|
7
|
+
export const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
8
|
+
export const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
9
|
+
|
|
10
|
+
function providerError(message, code = "ccdx_image_provider_error") {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checkedPrompt(value) {
|
|
17
|
+
const prompt = String(value || "").trim();
|
|
18
|
+
if (!prompt) throw providerError("Image prompt is required", "ccdx_image_prompt_required");
|
|
19
|
+
if (prompt.length > 16_000) throw providerError("Image prompt exceeds 16000 characters", "ccdx_image_prompt_too_large");
|
|
20
|
+
return prompt;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkedSize(value) {
|
|
24
|
+
const size = String(value || "1024x1024").trim().toLowerCase();
|
|
25
|
+
if (!IMAGE_SIZES.has(size)) {
|
|
26
|
+
throw providerError(`Image size must be one of: ${[...IMAGE_SIZES].join(", ")}`, "ccdx_image_size_invalid");
|
|
27
|
+
}
|
|
28
|
+
return size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generationBody(config, prompt, size) {
|
|
32
|
+
if (config.protocol === "qwen-messages") {
|
|
33
|
+
return {
|
|
34
|
+
model: config.model,
|
|
35
|
+
input: { messages: [{ role: "user", content: [{ text: prompt }] }] },
|
|
36
|
+
parameters: { n: 1, size: size.replace("x", "*"), watermark: false },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { model: config.model, prompt, n: 1, size };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function boundedJson(response) {
|
|
43
|
+
const maxBytes = 48 * 1024 * 1024;
|
|
44
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
45
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw providerError("Image API response was too large");
|
|
46
|
+
const reader = response.body?.getReader?.();
|
|
47
|
+
let text;
|
|
48
|
+
if (!reader) {
|
|
49
|
+
text = await response.text();
|
|
50
|
+
if (Buffer.byteLength(text) > maxBytes) throw providerError("Image API response was too large");
|
|
51
|
+
} else {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
try {
|
|
55
|
+
while (true) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done) break;
|
|
58
|
+
total += value.byteLength;
|
|
59
|
+
if (total > maxBytes) {
|
|
60
|
+
await reader.cancel().catch(() => {});
|
|
61
|
+
throw providerError("Image API response was too large");
|
|
62
|
+
}
|
|
63
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
text = Buffer.concat(chunks, total).toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
try { return JSON.parse(text); } catch { throw providerError("Image API returned invalid JSON"); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function upstreamMessage(body) {
|
|
74
|
+
return String(body?.message || body?.error?.message || body?.error || "")
|
|
75
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
76
|
+
.trim()
|
|
77
|
+
.slice(0, 500);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function imageSource(body) {
|
|
81
|
+
const standard = body?.data?.[0];
|
|
82
|
+
if (typeof standard?.b64_json === "string" && standard.b64_json) {
|
|
83
|
+
return { type: "base64", value: standard.b64_json };
|
|
84
|
+
}
|
|
85
|
+
if (typeof standard?.url === "string" && standard.url) return { type: "url", value: standard.url };
|
|
86
|
+
const contents = body?.output?.choices?.[0]?.message?.content;
|
|
87
|
+
if (Array.isArray(contents)) {
|
|
88
|
+
const image = contents.find((part) => typeof part?.image === "string" && part.image);
|
|
89
|
+
if (image) return { type: "url", value: image.image };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function imageMetadata(bytes) {
|
|
95
|
+
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
96
|
+
return { mimeType: "image/png", width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
97
|
+
}
|
|
98
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
99
|
+
return { mimeType: "image/webp" };
|
|
100
|
+
}
|
|
101
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
102
|
+
let offset = 2;
|
|
103
|
+
while (offset + 9 < bytes.length) {
|
|
104
|
+
if (bytes[offset] !== 0xff) { offset += 1; continue; }
|
|
105
|
+
const marker = bytes[offset + 1];
|
|
106
|
+
if (marker === 0xd8 || marker === 0xd9) { offset += 2; continue; }
|
|
107
|
+
const length = bytes.readUInt16BE(offset + 2);
|
|
108
|
+
if (length < 2 || offset + 2 + length > bytes.length) break;
|
|
109
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
110
|
+
return { mimeType: "image/jpeg", height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
|
|
111
|
+
}
|
|
112
|
+
offset += 2 + length;
|
|
113
|
+
}
|
|
114
|
+
return { mimeType: "image/jpeg" };
|
|
115
|
+
}
|
|
116
|
+
throw providerError("Image API returned an unsupported image format", "ccdx_image_format_unsupported");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ipv4Public(address) {
|
|
120
|
+
const parts = address.split(".").map(Number);
|
|
121
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
122
|
+
const [a, b, c] = parts;
|
|
123
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
|
|
124
|
+
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
125
|
+
if (a === 169 && b === 254) return false;
|
|
126
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
127
|
+
if (a === 192 && (b === 168 || (b === 0 && [0, 2].includes(c)))) return false;
|
|
128
|
+
if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false;
|
|
129
|
+
if (a === 203 && b === 0 && c === 113) return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function publicAddress(address) {
|
|
134
|
+
const family = net.isIP(address);
|
|
135
|
+
if (family === 4) return ipv4Public(address);
|
|
136
|
+
if (family !== 6) return false;
|
|
137
|
+
const lower = address.toLowerCase();
|
|
138
|
+
if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
|
|
139
|
+
|| /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
|
|
140
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
141
|
+
return mapped ? ipv4Public(mapped[1]) : true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function downloadPublicImage(urlValue, {
|
|
145
|
+
lookup = dns.lookup,
|
|
146
|
+
timeoutMs = IMAGE_DOWNLOAD_TIMEOUT_MS,
|
|
147
|
+
maxBytes = IMAGE_MAX_BYTES,
|
|
148
|
+
signal,
|
|
149
|
+
} = {}) {
|
|
150
|
+
let url;
|
|
151
|
+
try { url = new URL(urlValue); } catch { throw providerError("Image API returned an invalid image URL"); }
|
|
152
|
+
if (url.protocol !== "https:" || url.username || url.password) {
|
|
153
|
+
throw providerError("Image API returned an unsafe image URL", "ccdx_image_url_unsafe");
|
|
154
|
+
}
|
|
155
|
+
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
|
156
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
157
|
+
throw providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe");
|
|
158
|
+
}
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const finish = (error, value) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
if (error) reject(error);
|
|
166
|
+
else resolve(value);
|
|
167
|
+
};
|
|
168
|
+
const request = https.get(url, {
|
|
169
|
+
headers: { Accept: "image/*,application/octet-stream;q=0.5" },
|
|
170
|
+
lookup: (_hostname, options, callback) => {
|
|
171
|
+
if (options?.all) callback(null, addresses);
|
|
172
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
173
|
+
},
|
|
174
|
+
}, (response) => {
|
|
175
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
176
|
+
response.resume();
|
|
177
|
+
finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const declared = Number(response.headers["content-length"]);
|
|
181
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
182
|
+
response.destroy();
|
|
183
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const chunks = [];
|
|
187
|
+
let total = 0;
|
|
188
|
+
response.on("data", (chunk) => {
|
|
189
|
+
total += chunk.length;
|
|
190
|
+
if (total > maxBytes) {
|
|
191
|
+
response.destroy();
|
|
192
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
chunks.push(chunk);
|
|
196
|
+
});
|
|
197
|
+
response.on("end", () => finish(null, Buffer.concat(chunks, total)));
|
|
198
|
+
response.on("error", (error) => finish(error));
|
|
199
|
+
});
|
|
200
|
+
const timer = setTimeout(() => request.destroy(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
201
|
+
timer.unref?.();
|
|
202
|
+
request.on("close", () => clearTimeout(timer));
|
|
203
|
+
request.on("error", (error) => finish(error));
|
|
204
|
+
const onAbort = () => request.destroy(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
205
|
+
if (signal?.aborted) onAbort();
|
|
206
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function generateImage(config, {
|
|
211
|
+
prompt,
|
|
212
|
+
size = "1024x1024",
|
|
213
|
+
} = {}, {
|
|
214
|
+
fetchImpl = fetch,
|
|
215
|
+
downloadImage = downloadPublicImage,
|
|
216
|
+
signal,
|
|
217
|
+
timeoutMs = IMAGE_GENERATION_TIMEOUT_MS,
|
|
218
|
+
} = {}) {
|
|
219
|
+
const normalizedPrompt = checkedPrompt(prompt);
|
|
220
|
+
const normalizedSize = checkedSize(size);
|
|
221
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
222
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
223
|
+
let response;
|
|
224
|
+
try {
|
|
225
|
+
response = await fetchImpl(config.endpoint, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
Accept: "application/json",
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize)),
|
|
233
|
+
signal: requestSignal,
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (requestSignal.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
|
|
237
|
+
throw providerError("Image API could not be reached");
|
|
238
|
+
}
|
|
239
|
+
const body = await boundedJson(response);
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
const detail = upstreamMessage(body);
|
|
242
|
+
throw providerError(`Image API failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
243
|
+
}
|
|
244
|
+
const source = imageSource(body);
|
|
245
|
+
if (!source) throw providerError("Image API response did not contain an image");
|
|
246
|
+
let bytes;
|
|
247
|
+
try {
|
|
248
|
+
bytes = source.type === "base64"
|
|
249
|
+
? Buffer.from(source.value, "base64")
|
|
250
|
+
: await downloadImage(source.value, { signal: requestSignal, maxBytes: IMAGE_MAX_BYTES });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error?.code?.startsWith?.("ccdx_")) throw error;
|
|
253
|
+
const code = String(error?.cause?.code || error?.code || "").replace(/[^A-Z0-9_-]/gi, "").slice(0, 64);
|
|
254
|
+
throw providerError(`Generated image could not be downloaded${code ? ` (${code})` : ""}`);
|
|
255
|
+
}
|
|
256
|
+
if (!bytes.length || bytes.length > IMAGE_MAX_BYTES) {
|
|
257
|
+
throw providerError("Generated image is empty or exceeds the size limit", "ccdx_image_too_large");
|
|
258
|
+
}
|
|
259
|
+
const metadata = imageMetadata(bytes);
|
|
260
|
+
return {
|
|
261
|
+
data: bytes.toString("base64"),
|
|
262
|
+
mimeType: metadata.mimeType,
|
|
263
|
+
width: metadata.width,
|
|
264
|
+
height: metadata.height,
|
|
265
|
+
model: config.model,
|
|
266
|
+
size: normalizedSize,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { parse } from "smol-toml";
|
|
3
|
+
import { readBoundedResponseText } from "./http-transport.mjs";
|
|
4
|
+
|
|
5
|
+
export async function probeImageTool(codexContent, { fetchImpl = fetch, timeoutMs = 1500 } = {}) {
|
|
6
|
+
let server;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
server = parse(codexContent).mcp_servers?.ccdx_image;
|
|
10
|
+
if (!server || server.enabled === false) return { ready: false, reason: "not_configured" };
|
|
11
|
+
url = new URL(server.url);
|
|
12
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
13
|
+
const local = ["localhost", "127.0.0.1", "::1", ...Object.values(os.networkInterfaces())
|
|
14
|
+
.flat().filter(Boolean).map((entry) => entry.address)];
|
|
15
|
+
if (url.protocol !== "http:" || !local.includes(host) || url.username || url.password
|
|
16
|
+
|| url.pathname !== "/mcp/image" || url.search || url.hash) {
|
|
17
|
+
return { ready: false, reason: "invalid_local_endpoint" };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return { ready: false, reason: "invalid_config" };
|
|
21
|
+
}
|
|
22
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
23
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
24
|
+
async function rpc(id, method, params) {
|
|
25
|
+
const response = await fetchImpl(url.href, {
|
|
26
|
+
method: "POST", headers, signal, redirect: "error",
|
|
27
|
+
body: JSON.stringify({ jsonrpc: "2.0", ...(id === undefined ? {} : { id }), method, params }),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
await response.body?.cancel?.().catch(() => {});
|
|
31
|
+
throw new Error(`HTTP ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
if (id === undefined) {
|
|
34
|
+
await response.body?.cancel?.().catch(() => {});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const text = await readBoundedResponseText(response, { maxBytes: 64 * 1024, signal, label: "Image tool catalog" });
|
|
38
|
+
const message = JSON.parse(text);
|
|
39
|
+
if (message.jsonrpc !== "2.0" || message.id !== id || message.error) throw new Error("invalid MCP response");
|
|
40
|
+
return message.result;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const initialized = await rpc(1, "initialize", {
|
|
44
|
+
protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-status", version: "1" },
|
|
45
|
+
});
|
|
46
|
+
if (initialized?.serverInfo?.name !== "ccdx-image") return { ready: false, reason: "unexpected_server" };
|
|
47
|
+
await rpc(undefined, "notifications/initialized", {});
|
|
48
|
+
const result = await rpc(2, "tools/list", {});
|
|
49
|
+
return result?.tools?.some((tool) => tool.name === "generate_image")
|
|
50
|
+
? { ready: true }
|
|
51
|
+
: { ready: false, reason: "tool_unavailable" };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { ready: false, reason: signal.aborted ? "timeout" : /^HTTP \d+$/.test(error.message) ? error.message : "unreachable" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
|
|
5
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
|
+
|
|
7
|
+
const SKILL_MARKER = /<!-- ccdx:image-skill:([a-f0-9]{64}) -->\n/;
|
|
8
|
+
const HELPER_MARKER = /^\/\/ ccdx:image-helper:([a-f0-9]{64})\n/;
|
|
9
|
+
|
|
10
|
+
function digest(content) {
|
|
11
|
+
return createHash("sha256").update(content).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function quote(value) {
|
|
15
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function conflict(message) {
|
|
19
|
+
return Object.assign(new Error(message), { code: "CCDX_IMAGE_SKILL_CONFLICT" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function directoryCheck(directory) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.lstatSync(directory).isDirectory()) throw conflict(`CCDX image skill path is not a regular directory: ${directory}`);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code !== "ENOENT") throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ownedSnapshot(filePath, marker, preserveUnowned = false) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.lstatSync(filePath);
|
|
33
|
+
if (!stat.isFile()) throw conflict(`Refusing to replace an unowned CCDX image skill path: ${filePath}`);
|
|
34
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
const match = marker.exec(content);
|
|
36
|
+
if (!match || digest(content.replace(marker, "")) !== match[1]) {
|
|
37
|
+
throw conflict(`Preserving an existing or modified image skill file: ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
return { content, mode: stat.mode & 0o777 };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === "ENOENT") return null;
|
|
42
|
+
if (preserveUnowned && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") return { unowned: true };
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removeFile(filePath) {
|
|
48
|
+
try { fs.unlinkSync(filePath); }
|
|
49
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeEmptyDirectories(skillDirectory) {
|
|
53
|
+
for (const directory of [path.join(skillDirectory, "scripts"), skillDirectory]) {
|
|
54
|
+
try { fs.rmdirSync(directory); }
|
|
55
|
+
catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateImageSkill({
|
|
60
|
+
enabled,
|
|
61
|
+
codexPath,
|
|
62
|
+
adapterPort = 2026,
|
|
63
|
+
adapterHost = "127.0.0.1",
|
|
64
|
+
nodePath = process.execPath,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const skillDirectory = path.join(path.dirname(codexPath), "skills", "ccdx-image");
|
|
67
|
+
const skillPath = path.join(skillDirectory, "SKILL.md");
|
|
68
|
+
const helperPath = path.join(skillDirectory, "scripts", "generate.mjs");
|
|
69
|
+
try {
|
|
70
|
+
directoryCheck(skillDirectory);
|
|
71
|
+
directoryCheck(path.dirname(helperPath));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!enabled && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") {
|
|
74
|
+
return { changed: false, preserved: true, skillPath, rollback: () => {} };
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const previousSkill = ownedSnapshot(skillPath, SKILL_MARKER, !enabled);
|
|
79
|
+
const previousHelper = ownedSnapshot(helperPath, HELPER_MARKER, !enabled);
|
|
80
|
+
const touched = [];
|
|
81
|
+
let rolledBack = false;
|
|
82
|
+
const rollback = () => {
|
|
83
|
+
if (rolledBack) return;
|
|
84
|
+
for (const [filePath, snapshot] of touched) {
|
|
85
|
+
if (snapshot) atomicWriteFileSync(filePath, snapshot.content, { mode: snapshot.mode, preserveMode: false });
|
|
86
|
+
else removeFile(filePath);
|
|
87
|
+
}
|
|
88
|
+
removeEmptyDirectories(skillDirectory);
|
|
89
|
+
rolledBack = true;
|
|
90
|
+
};
|
|
91
|
+
if (!enabled) {
|
|
92
|
+
const preserved = Boolean(previousSkill?.unowned || previousHelper?.unowned);
|
|
93
|
+
if (!previousSkill?.content && !previousHelper?.content) return { changed: false, preserved, skillPath, rollback: () => {} };
|
|
94
|
+
try {
|
|
95
|
+
for (const [filePath, snapshot] of [[skillPath, previousSkill], [helperPath, previousHelper]]) {
|
|
96
|
+
if (!snapshot?.content) continue;
|
|
97
|
+
touched.push([filePath, snapshot]);
|
|
98
|
+
removeFile(filePath);
|
|
99
|
+
}
|
|
100
|
+
removeEmptyDirectories(skillDirectory);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
rollback();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { changed: true, preserved, skillPath, rollback };
|
|
106
|
+
}
|
|
107
|
+
const host = ["0.0.0.0", "::", "[::]"].includes(adapterHost) ? (adapterHost === "0.0.0.0" ? "127.0.0.1" : "::1") : adapterHost;
|
|
108
|
+
const endpoint = `${adapterBaseUrl(host, adapterPort)}/mcp/image`;
|
|
109
|
+
const skillBody = `---
|
|
110
|
+
name: ccdx-image
|
|
111
|
+
description: Generate new images from text using the user's configured CCDX image provider. Use for requests to draw, create, or render a new image. Excludes editing existing images, image references, transparent output, and code-native SVG or diagrams.
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# CCDX image generation
|
|
115
|
+
|
|
116
|
+
The user enabled this provider through ccdx enable-image. For new text-to-image requests, use the configured CCDX provider directly.
|
|
117
|
+
|
|
118
|
+
Use the ccdx_image server's generate_image tool when available. Supply a complete prompt and one size: 1024x1024 (square), 1536x1024 (landscape), or 1024x1536 (portrait). Display its returned image. No Python, SDK installation, extra API key, or provider discovery is needed.
|
|
119
|
+
|
|
120
|
+
If the tool is not directly callable and the host provides tool search, make one targeted search for ccdx_image generate_image and use the tool if it becomes callable. If it is still unavailable, run the bundled Node helper once:
|
|
121
|
+
|
|
122
|
+
\`\`\`sh
|
|
123
|
+
${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
|
|
124
|
+
\`\`\`
|
|
125
|
+
|
|
126
|
+
Quote the actual prompt safely for the shell. The helper uses the same local CCDX service and prints the saved absolute image path. An optional --out argument selects a new output file; existing files are never overwritten. Inspect and display the saved image with the available image viewer or an absolute-path Markdown image.
|
|
127
|
+
|
|
128
|
+
The helper needs loopback HTTP access. If the task reports restricted network access, use the execution tool's normal permission-request mechanism for this exact command before running it; localhost is also restricted. Keep the host's approval policy unchanged.
|
|
129
|
+
|
|
130
|
+
Only use the helper when the MCP tool is absent, not after a generation fails or times out: the first request may already have consumed a generation. A helper error explicitly saying generation has not started means no generation was submitted; resolve its stated local connection or permission issue before trying again. Otherwise report the failure once without switching providers, installing dependencies, or repeating generation.
|
|
131
|
+
|
|
132
|
+
This provider supports text-to-image only. For edits, reference images, transparent output, or explicitly requested other providers, keep the relevant native tools and skills; do not substitute a fresh CCDX generation.
|
|
133
|
+
`;
|
|
134
|
+
const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
|
|
135
|
+
const helperBody = `${fs.readFileSync(new URL("./image-tool-client.mjs", import.meta.url), "utf8")}\ntry {\n await runImageToolClient({ endpoint: ${JSON.stringify(endpoint)} });\n} catch (error) {\n console.error(String(error?.message || "Image generation failed").replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, " ").slice(0, 1000));\n process.exitCode = 1;\n}\n`;
|
|
136
|
+
const helperContent = `// ccdx:image-helper:${digest(helperBody)}\n${helperBody}`;
|
|
137
|
+
if (previousSkill?.content === skillContent && previousHelper?.content === helperContent) {
|
|
138
|
+
return { changed: false, skillPath, rollback: () => {} };
|
|
139
|
+
}
|
|
140
|
+
atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
|
|
141
|
+
touched.push([helperPath, previousHelper], [skillPath, previousSkill]);
|
|
142
|
+
return { changed: true, skillPath, rollback };
|
|
143
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
7
|
+
const IMAGE_EXTENSIONS = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"]]);
|
|
8
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
|
|
10
|
+
const USAGE = "Usage: generate.mjs --prompt <text> [--size 1024x1024|1536x1024|1024x1536] [--out <new-file>]";
|
|
11
|
+
|
|
12
|
+
export function parseImageToolArgs(args = []) {
|
|
13
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) return { help: true };
|
|
14
|
+
const values = {};
|
|
15
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
16
|
+
const option = args[index];
|
|
17
|
+
if (!["--prompt", "--size", "--out"].includes(option) || option in values) throw new Error(USAGE);
|
|
18
|
+
if (typeof args[index + 1] !== "string" || !args[index + 1].trim()) throw new Error(`Missing value for ${option}`);
|
|
19
|
+
values[option] = args[index + 1];
|
|
20
|
+
}
|
|
21
|
+
const prompt = String(values["--prompt"] || "").trim();
|
|
22
|
+
if (!prompt || prompt.length > 16_000) throw new Error("Image prompt must contain 1 to 16000 characters");
|
|
23
|
+
const size = String(values["--size"] || "1024x1024").trim().toLowerCase();
|
|
24
|
+
if (!IMAGE_SIZES.has(size)) throw new Error("Unsupported image size");
|
|
25
|
+
return { prompt, size, out: values["--out"] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localImageEndpoint(value) {
|
|
29
|
+
const endpoint = new URL(value);
|
|
30
|
+
const host = endpoint.hostname.replace(/^\[|\]$/g, "");
|
|
31
|
+
const localAddresses = Object.values(os.networkInterfaces()).flat().filter(Boolean).map(({ address }) => address);
|
|
32
|
+
const local = host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host) || localAddresses.includes(host);
|
|
33
|
+
if (!local || endpoint.protocol !== "http:" || endpoint.username || endpoint.password
|
|
34
|
+
|| endpoint.pathname !== "/mcp/image" || endpoint.search || endpoint.hash) {
|
|
35
|
+
throw new Error("The CCDX image helper requires the configured local /mcp/image endpoint");
|
|
36
|
+
}
|
|
37
|
+
return endpoint.href;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanError(value) {
|
|
41
|
+
return String(value || "Image generation failed").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readRpcResponse(response, expectedId) {
|
|
45
|
+
if (!response.ok) throw new Error(`CCDX image service returned HTTP ${response.status}`);
|
|
46
|
+
const declared = Number(response.headers.get("content-length"));
|
|
47
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
throw new Error("CCDX image response exceeds the size limit");
|
|
50
|
+
}
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let bytes = 0;
|
|
53
|
+
for await (const chunk of response.body) {
|
|
54
|
+
bytes += chunk.byteLength;
|
|
55
|
+
if (bytes > MAX_RESPONSE_BYTES) throw new Error("CCDX image response exceeds the size limit");
|
|
56
|
+
chunks.push(Buffer.from(chunk));
|
|
57
|
+
}
|
|
58
|
+
let body;
|
|
59
|
+
try { body = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")); }
|
|
60
|
+
catch { throw new Error("CCDX image service returned invalid JSON"); }
|
|
61
|
+
if (body?.jsonrpc !== "2.0" || body.id !== expectedId) throw new Error("CCDX image service returned an invalid JSON-RPC response");
|
|
62
|
+
if (body.error) throw new Error(cleanError(body.error.message));
|
|
63
|
+
if (!body.result || typeof body.result !== "object") throw new Error("CCDX image service returned no result");
|
|
64
|
+
return body.result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertNewOutput(filePath) {
|
|
68
|
+
try {
|
|
69
|
+
fs.lstatSync(filePath);
|
|
70
|
+
throw new Error(`Image output already exists: ${filePath}`);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== "ENOENT") throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runImageToolClient({
|
|
77
|
+
endpoint,
|
|
78
|
+
args = process.argv.slice(2),
|
|
79
|
+
cwd = process.cwd(),
|
|
80
|
+
fetchImpl = fetch,
|
|
81
|
+
output = process.stdout,
|
|
82
|
+
signal = AbortSignal.timeout(210_000),
|
|
83
|
+
} = {}) {
|
|
84
|
+
const options = parseImageToolArgs(args);
|
|
85
|
+
if (options.help) {
|
|
86
|
+
output.write(`${USAGE}\n`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const url = localImageEndpoint(endpoint);
|
|
90
|
+
const requestedOutput = options.out ? path.resolve(cwd, options.out) : null;
|
|
91
|
+
if (requestedOutput) assertNewOutput(requestedOutput);
|
|
92
|
+
const directory = requestedOutput ? path.dirname(requestedOutput) : path.resolve(cwd, "output", "imagegen");
|
|
93
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
94
|
+
fs.accessSync(directory, fs.constants.W_OK);
|
|
95
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
96
|
+
const post = (body) => fetchImpl(url, {
|
|
97
|
+
method: "POST", headers, body: JSON.stringify(body), redirect: "error", signal,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const initialized = await post({
|
|
101
|
+
jsonrpc: "2.0", id: 1, method: "initialize",
|
|
102
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-helper", version: "1" } },
|
|
103
|
+
});
|
|
104
|
+
const sessionId = initialized.headers.get("mcp-session-id");
|
|
105
|
+
const server = await readRpcResponse(initialized, 1);
|
|
106
|
+
if (server.serverInfo?.name !== "ccdx-image") throw new Error("The configured local service is not CCDX image generation");
|
|
107
|
+
headers["MCP-Protocol-Version"] = server.protocolVersion || "2025-06-18";
|
|
108
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
109
|
+
const notified = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
110
|
+
await notified.body?.cancel();
|
|
111
|
+
if (!notified.ok) throw new Error(`CCDX image initialization returned HTTP ${notified.status}`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const code = error.cause?.code || error.code;
|
|
114
|
+
const detail = ["EPERM", "EACCES"].includes(code)
|
|
115
|
+
? "Local network access was denied. Request normal execution permission for this helper command."
|
|
116
|
+
: cleanError(error.message);
|
|
117
|
+
throw Object.assign(new Error(`Image generation has not started: ${detail}`), { code: "CCDX_IMAGE_NOT_STARTED" });
|
|
118
|
+
}
|
|
119
|
+
const result = await readRpcResponse(await post({
|
|
120
|
+
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
121
|
+
params: { name: "generate_image", arguments: { prompt: options.prompt, size: options.size } },
|
|
122
|
+
}), 2);
|
|
123
|
+
if (result.isError) {
|
|
124
|
+
throw new Error(cleanError(result.content?.find((part) => part.type === "text")?.text));
|
|
125
|
+
}
|
|
126
|
+
const image = result.content?.find((part) => part.type === "image");
|
|
127
|
+
const extension = IMAGE_EXTENSIONS.get(image?.mimeType);
|
|
128
|
+
if (!extension || typeof image.data !== "string" || !image.data
|
|
129
|
+
|| image.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4
|
|
130
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) {
|
|
131
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
132
|
+
}
|
|
133
|
+
const bytes = Buffer.from(image.data, "base64");
|
|
134
|
+
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64") !== image.data) {
|
|
135
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
136
|
+
}
|
|
137
|
+
const filePath = requestedOutput || path.join(directory, `image-${randomUUID()}${extension}`);
|
|
138
|
+
fs.writeFileSync(filePath, bytes, { flag: "wx", mode: 0o600 });
|
|
139
|
+
output.write(`${filePath}\n`);
|
|
140
|
+
return filePath;
|
|
141
|
+
}
|
package/src/observability.mjs
CHANGED
|
@@ -154,6 +154,7 @@ export function runtimeStatusPayload({
|
|
|
154
154
|
streamPerformance,
|
|
155
155
|
admission,
|
|
156
156
|
imagePressure,
|
|
157
|
+
responseFailures,
|
|
157
158
|
modelRegistry,
|
|
158
159
|
codexClient,
|
|
159
160
|
codexModelRegistry,
|
|
@@ -177,6 +178,7 @@ export function runtimeStatusPayload({
|
|
|
177
178
|
response_history: responseHistoryStats(),
|
|
178
179
|
image_optimization: imageOptimizationStats(),
|
|
179
180
|
image_history_pressure: imagePressure?.snapshot?.() || null,
|
|
181
|
+
response_failures: responseFailures?.snapshot?.() || null,
|
|
180
182
|
copilot: codexRuntime,
|
|
181
183
|
models: codexModels,
|
|
182
184
|
profiles: {
|