codex-copilot-dx 0.8.2 → 0.8.3
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 +2 -2
- package/package.json +1 -1
- package/src/config.mjs +131 -48
package/README.md
CHANGED
|
@@ -151,6 +151,8 @@ The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.
|
|
|
151
151
|
|
|
152
152
|
The enable command verifies the local MCP handshake and generation-tool directory without generating an image. It reports provider configuration separately from local service readiness; it cannot assert that every running Codex task has refreshed its catalog. Codex normally detects changed skills; restart Codex App once if an open task has not refreshed them. Normal CCDX startup installs or updates this guidance only for an already-enabled provider, without repeating API setup. Without `enable-image`, it installs neither this skill nor the MCP registration. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
|
|
153
153
|
|
|
154
|
+
Existing compatible `ccdx_image` registrations are reused without requiring CCDX's comment markers or resetting user timeouts/tool policies. Missing or damaged markers alone do not block startup. Core GPT configuration is committed independently before optional image maintenance: image configuration conflicts, image-only write failures, or Skill maintenance failures warn without aborting the core service. No new image guidance is installed after failed image configuration. With images disabled or their provider configuration invalid/unreadable, normal startup performs no image configuration maintenance, Skill installation, or image-network probe; explicit `disable-image` owns cleanup. Automatic updates/removal are limited to recognizable CCDX defaults and isolated TOML declarations, never everything between two comments. Ambiguous shared-inline or customized registrations are left intact; `disable-image` still disables the CCDX provider and removes its owned guidance. Invalid TOML/UTF-8 in the shared Codex config or a failed essential core configuration write remains an explicit error rather than a silent success.
|
|
155
|
+
|
|
154
156
|
Direct MCP generations and edits retain their original inline image content and editing ID, and also save the exact image bytes privately under `~/.local/share/codex-copilot-dx/images`. The result includes ready-to-use image Markdown for Codex's final chat reply, so the image appears as a clickable thumbnail instead of only inside a tool result. Files use unique names and are not overwritten or removed on adapter restart. Asking to show the same image again reuses its existing Markdown, not a new generation or edit. If saving fails, CCDX still returns the generated image content, reports the delivery failure, and does not retry the provider or invent a file path. The bundled helper continues saving once at its existing output location, without a second adapter-side copy. Existing enabled users receive updated, unmodified CCDX-owned guidance on the next CCDX startup; no new setup command is needed.
|
|
155
157
|
|
|
156
158
|
Inspect or disable the optional provider with:
|
package/bin/cli.mjs
CHANGED
|
@@ -229,8 +229,8 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
229
229
|
|
|
230
230
|
async function configureCodexClient() {
|
|
231
231
|
const imageProviderEnabled = Boolean(readImageProviderConfig());
|
|
232
|
-
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
|
|
233
|
-
if (imageProviderEnabled) {
|
|
232
|
+
const { imageMcpSkipped } = ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
|
|
233
|
+
if (imageProviderEnabled && !imageMcpSkipped) {
|
|
234
234
|
try {
|
|
235
235
|
const { syncEnabledImageSkill } = await import("../src/cli-image.mjs");
|
|
236
236
|
syncEnabledImageSkill({ adapterPort: ADAPTER_PORT, adapterHost: ADAPTER_HOST });
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
6
|
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
7
|
+
import { isLoopbackHost } from "./security.mjs";
|
|
7
8
|
import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
9
|
|
|
9
10
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
@@ -12,6 +13,10 @@ const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
|
|
|
12
13
|
const IMAGE_MCP_START = "# ccdx:image-mcp:start";
|
|
13
14
|
const IMAGE_MCP_END = "# ccdx:image-mcp:end";
|
|
14
15
|
const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
|
|
16
|
+
const IMAGE_MCP_KEYS = ["enabled", "tool_timeout_sec", "url"];
|
|
17
|
+
|
|
18
|
+
// Only optional image-configuration conflicts may degrade to core-only startup.
|
|
19
|
+
export class ImageMcpConfigError extends Error {}
|
|
15
20
|
const STARTUP_MANAGED_PATHS = [
|
|
16
21
|
"openai_base_url",
|
|
17
22
|
"model_context_window",
|
|
@@ -92,41 +97,98 @@ function imageMcpHost(adapterHost) {
|
|
|
92
97
|
return host;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return true;
|
|
100
|
+
function imageMcpUrl(value) {
|
|
101
|
+
if (typeof value !== "string" || /[\s\x00-\x1f\x7f]/.test(value)) return null;
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(value);
|
|
104
|
+
return url.protocol === "http:" && url.pathname === "/mcp/image"
|
|
105
|
+
&& !url.username && !url.password && !url.search && !url.hash ? url : null;
|
|
106
|
+
} catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isImageDeclaration(item) {
|
|
110
|
+
return ["table", "value"].includes(item.kind)
|
|
111
|
+
&& sameKeys(item.keys.slice(0, IMAGE_MANAGED_PATH.length), IMAGE_MANAGED_PATH);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function imageMcpMarkers(content, statements) {
|
|
115
|
+
return statements.filter((item) => item.kind === "comment"
|
|
116
|
+
&& [IMAGE_MCP_START, IMAGE_MCP_END].includes(item.source.trim())
|
|
117
|
+
&& !content.slice(content.lastIndexOf("\n", item.start - 1) + 1, item.start).trim());
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
function
|
|
117
|
-
|
|
120
|
+
function knownImageMcp(section, expectedUrl, previousBaseUrl, statements, markers) {
|
|
121
|
+
if (!section || Object.keys(section).sort().join(",") !== IMAGE_MCP_KEYS.join(",")
|
|
122
|
+
|| section.enabled !== true || section.tool_timeout_sec !== 210n) return false;
|
|
123
|
+
const url = imageMcpUrl(section.url);
|
|
124
|
+
if (!url) return false;
|
|
125
|
+
if (url.href === new URL(expectedUrl).href) return true;
|
|
126
|
+
// The core and image URLs were configured together. This survives comment
|
|
127
|
+
// stripping and allows an adapter address change without guessing a new owner.
|
|
128
|
+
try {
|
|
129
|
+
const previous = new URL(previousBaseUrl);
|
|
130
|
+
if (previous.protocol === "http:" && previous.pathname === "/v1"
|
|
131
|
+
&& !previous.username && !previous.password && !previous.search && !previous.hash
|
|
132
|
+
&& previous.origin === url.origin
|
|
133
|
+
&& (isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return true;
|
|
134
|
+
} catch {}
|
|
135
|
+
// Legacy markers are an additional ownership hint, never deletion boundaries.
|
|
136
|
+
const starts = markers.filter((item) => item.source.trim() === IMAGE_MCP_START);
|
|
137
|
+
const ends = markers.filter((item) => item.source.trim() === IMAGE_MCP_END);
|
|
138
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start
|
|
139
|
+
|| !(isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return false;
|
|
140
|
+
const declarations = statements.filter((item) => ["table", "value"].includes(item.kind));
|
|
141
|
+
return declarations.some(isImageDeclaration)
|
|
142
|
+
&& declarations.filter(isImageDeclaration).every((item) => item.start > starts[0].start && item.end < ends[0].start)
|
|
143
|
+
&& declarations.filter((item) => item.start > starts[0].start && item.start < ends[0].start).every(isImageDeclaration);
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
function
|
|
146
|
+
function removeImageMcpDeclarations(content, statements, markers) {
|
|
147
|
+
const declarations = statements.filter(isImageDeclaration);
|
|
148
|
+
// A shared inline parent is not a surgical edit target. Reuse it unchanged or
|
|
149
|
+
// leave it for the user; never serialize away sibling settings or credentials.
|
|
150
|
+
if (!declarations.length) return null;
|
|
151
|
+
const ranges = [...declarations, ...markers].map((item) => {
|
|
152
|
+
const start = content.lastIndexOf("\n", item.start - 1) + 1;
|
|
153
|
+
const newline = content.indexOf("\n", item.end);
|
|
154
|
+
return [start, newline < 0 ? content.length : newline + 1];
|
|
155
|
+
}).sort((left, right) => right[0] - left[0]);
|
|
156
|
+
for (const [start, end] of ranges) content = content.slice(0, start) + content.slice(end);
|
|
157
|
+
return content;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ensureImageMcp(lines, enabled, adapterPort, adapterHost, previousBaseUrl) {
|
|
121
161
|
const previous = lines.join("\n");
|
|
122
|
-
|
|
162
|
+
const config = parseCodexToml(previous);
|
|
163
|
+
const section = configValue(config, IMAGE_MANAGED_PATH);
|
|
164
|
+
const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
|
|
165
|
+
if (section !== undefined) {
|
|
166
|
+
// Reuse compatible registrations without rewriting comments, formatting,
|
|
167
|
+
// user tool policies, or timeouts. An explicit disabled flag is preserved.
|
|
168
|
+
if (enabled && imageMcpUrl(section?.url)?.href === new URL(url).href
|
|
169
|
+
&& [undefined, true].includes(section?.enabled)) return false;
|
|
170
|
+
const statements = codexTomlStatements(previous);
|
|
171
|
+
const markers = imageMcpMarkers(previous, statements);
|
|
172
|
+
if (!knownImageMcp(section, url, previousBaseUrl, statements, markers)) {
|
|
173
|
+
if (!enabled) return false;
|
|
174
|
+
throw new ImageMcpConfigError(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block; its settings were preserved`);
|
|
175
|
+
}
|
|
176
|
+
const removed = removeImageMcpDeclarations(previous, statements, markers);
|
|
177
|
+
if (removed === null) {
|
|
178
|
+
if (!enabled) return false;
|
|
179
|
+
throw new ImageMcpConfigError(`Cannot safely update [${IMAGE_MCP_SECTION}] inside a shared inline table; its settings were preserved`);
|
|
180
|
+
}
|
|
181
|
+
lines.splice(0, lines.length, ...removed.split("\n"));
|
|
182
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
183
|
+
}
|
|
123
184
|
if (enabled) {
|
|
124
|
-
|
|
125
|
-
|
|
185
|
+
const parent = config.mcp_servers;
|
|
186
|
+
if (parent !== undefined && (typeof parent !== "object" || parent === null || Array.isArray(parent)
|
|
187
|
+
|| codexTomlStatements(previous).some((item) => item.kind === "value" && sameKeys(item.keys, ["mcp_servers"])))) {
|
|
188
|
+
throw new ImageMcpConfigError("Cannot append the image MCP to an inline or non-table mcp_servers setting; its settings were preserved");
|
|
126
189
|
}
|
|
127
190
|
while (lines.length && lines.at(-1).trim() === "") lines.pop();
|
|
128
191
|
if (lines.length) lines.push("");
|
|
129
|
-
const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
|
|
130
192
|
lines.push(
|
|
131
193
|
IMAGE_MCP_START,
|
|
132
194
|
`[${IMAGE_MCP_SECTION}]`,
|
|
@@ -149,7 +211,6 @@ export function computeUpdatedCodexConfig(
|
|
|
149
211
|
const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
|
|
150
212
|
const hadTrailingNewline = content.endsWith("\n");
|
|
151
213
|
const lines = content.split("\n");
|
|
152
|
-
if (hadTrailingNewline) lines.pop();
|
|
153
214
|
|
|
154
215
|
let changed = false;
|
|
155
216
|
const openaiLine = `openai_base_url = "${baseUrl}"`;
|
|
@@ -166,10 +227,11 @@ export function computeUpdatedCodexConfig(
|
|
|
166
227
|
changed = setTomlKey(lines, "shell_environment_policy.set", "OPENAI_API_KEY", "dummy") || changed;
|
|
167
228
|
changed = ensureContextManagementDefault(lines) || changed;
|
|
168
229
|
if (typeof imageProviderEnabled === "boolean") {
|
|
169
|
-
changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
|
|
230
|
+
changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost, config.openai_base_url) || changed;
|
|
170
231
|
}
|
|
171
232
|
|
|
172
|
-
const
|
|
233
|
+
const joined = lines.join("\n");
|
|
234
|
+
const updated = joined + (hadTrailingNewline && !joined.endsWith("\n") ? "\n" : "");
|
|
173
235
|
validateManagedConfigEdit(config, updated, typeof imageProviderEnabled === "boolean"
|
|
174
236
|
? [...STARTUP_MANAGED_PATHS, IMAGE_MANAGED_PATH] : STARTUP_MANAGED_PATHS);
|
|
175
237
|
return { content: updated, changed };
|
|
@@ -196,20 +258,26 @@ context_management = true
|
|
|
196
258
|
: base;
|
|
197
259
|
}
|
|
198
260
|
|
|
199
|
-
|
|
261
|
+
function computeImageConfig(
|
|
200
262
|
content,
|
|
201
263
|
{ enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
|
|
264
|
+
previousBaseUrl,
|
|
202
265
|
) {
|
|
203
266
|
const config = parseCodexToml(content);
|
|
204
267
|
const hadTrailingNewline = content.endsWith("\n");
|
|
205
268
|
const lines = content.split("\n");
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
269
|
+
const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost, previousBaseUrl ?? config.openai_base_url);
|
|
270
|
+
if (!changed) return { content, changed: false };
|
|
271
|
+
const joined = lines.join("\n");
|
|
272
|
+
const updated = joined + ((hadTrailingNewline || lines.length) && !joined.endsWith("\n") ? "\n" : "");
|
|
209
273
|
validateManagedConfigEdit(config, updated, [IMAGE_MANAGED_PATH]);
|
|
210
274
|
return { content: updated, changed };
|
|
211
275
|
}
|
|
212
276
|
|
|
277
|
+
export function computeImageMcpCodexConfig(content, options = {}) {
|
|
278
|
+
return computeImageConfig(content, options);
|
|
279
|
+
}
|
|
280
|
+
|
|
213
281
|
export function ensureCodexConfig(adapterPort = 2026, {
|
|
214
282
|
filePath = CONFIG_PATH,
|
|
215
283
|
host = "127.0.0.1",
|
|
@@ -217,26 +285,41 @@ export function ensureCodexConfig(adapterPort = 2026, {
|
|
|
217
285
|
} = {}) {
|
|
218
286
|
const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
|
|
219
287
|
|
|
220
|
-
if (!fs.existsSync(filePath)) {
|
|
221
|
-
// Codex config does not exist yet; create the local proxy defaults.
|
|
222
|
-
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host, { imageProviderEnabled }));
|
|
223
|
-
console.log(status("ok", "Created ~/.codex/config.toml"));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
288
|
let content;
|
|
228
289
|
try {
|
|
229
290
|
content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
|
|
230
291
|
} catch (error) {
|
|
231
|
-
if (error?.code
|
|
232
|
-
|
|
292
|
+
if (error?.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
|
|
293
|
+
throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
|
|
294
|
+
}
|
|
295
|
+
if (error?.code !== "ENOENT") throw error;
|
|
233
296
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
297
|
+
// Commit essential configuration independently. Image-only parse/edit/write
|
|
298
|
+
// failures must not roll it back or close an otherwise usable GPT adapter.
|
|
299
|
+
const core = content === undefined
|
|
300
|
+
? { content: initialCodexConfig(adapterPort, host), changed: true }
|
|
301
|
+
: computeUpdatedCodexConfig(content, adapterPort, host);
|
|
302
|
+
if (!core.changed) {
|
|
237
303
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
|
238
|
-
|
|
304
|
+
} else {
|
|
305
|
+
atomicWriteFileIfChangedSync(filePath, core.content);
|
|
306
|
+
console.log(status("ok", content === undefined ? "Created ~/.codex/config.toml" : `Configured Codex base URL: ${baseUrl}`));
|
|
307
|
+
}
|
|
308
|
+
// Disabled means no image maintenance, including no implicit removal of old
|
|
309
|
+
// or user-owned entries. Explicit disable-image owns the cleanup operation.
|
|
310
|
+
if (!imageProviderEnabled) return { imageMcpSkipped: false };
|
|
311
|
+
try {
|
|
312
|
+
const previousBaseUrl = content === undefined ? undefined : parseCodexToml(content).openai_base_url;
|
|
313
|
+
const image = computeImageConfig(core.content, { enabled: true, adapterPort, adapterHost: host }, previousBaseUrl);
|
|
314
|
+
if (image.changed) {
|
|
315
|
+
if (fs.readFileSync(filePath, "utf8") !== core.content) {
|
|
316
|
+
throw new ImageMcpConfigError("Codex config changed during image setup; the newer configuration was preserved");
|
|
317
|
+
}
|
|
318
|
+
atomicWriteFileIfChangedSync(filePath, image.content);
|
|
319
|
+
}
|
|
320
|
+
return { imageMcpSkipped: false };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.warn(status("warn", `Image MCP setup was skipped: ${error.message}. Core proxy startup will continue.`));
|
|
323
|
+
return { imageMcpSkipped: true };
|
|
239
324
|
}
|
|
240
|
-
atomicWriteFileIfChangedSync(filePath, updated.content);
|
|
241
|
-
console.log(status("ok", `Configured Codex base URL: ${baseUrl}`));
|
|
242
325
|
}
|