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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
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 removeManagedImageMcp(lines) {
96
- const comments = codexTomlStatements(lines.join("\n")).filter((item) => item.kind === "comment");
97
- const starts = comments.filter((item) => lines[item.line] === IMAGE_MCP_START).map((item) => item.line);
98
- const ends = comments.filter((item) => lines[item.line] === IMAGE_MCP_END).map((item) => item.line);
99
- let start = starts[0] ?? -1;
100
- const end = ends[0] ?? -1;
101
- if (start === -1 && end === -1) return false;
102
- if (starts.length !== 1 || ends.length !== 1 || end < start) {
103
- throw new Error("Invalid managed CCDX image MCP block in Codex config");
104
- }
105
- lines.splice(start, end - start + 1);
106
- while (start > 0 && start >= lines.length && lines[start - 1].trim() === "") {
107
- lines.splice(start - 1, 1);
108
- start -= 1;
109
- }
110
- while (start < lines.length && lines[start].trim() === "" && start > 0 && lines[start - 1].trim() === "") {
111
- lines.splice(start, 1);
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 hasUnmanagedImageMcp(lines) {
117
- return configValue(parseCodexToml(lines.join("\n")), IMAGE_MANAGED_PATH) !== undefined;
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 ensureImageMcp(lines, enabled, adapterPort, adapterHost) {
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
- removeManagedImageMcp(lines);
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
- if (hasUnmanagedImageMcp(lines)) {
125
- throw new Error(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block`);
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 updated = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
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
- export function computeImageMcpCodexConfig(
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
- if (hadTrailingNewline) lines.pop();
207
- const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost);
208
- const updated = lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : "");
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 !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw error;
232
- throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
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
- const updated = computeUpdatedCodexConfig(content, adapterPort, host, { imageProviderEnabled });
235
-
236
- if (!updated.changed) {
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
- return;
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
  }