codex-copilot-dx 0.7.10 → 0.8.0

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
@@ -76,7 +76,7 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
76
76
  | `[features]` `context_management` | Add `true` when missing; preserve explicit values and alternative declarations as described above | `true` |
77
77
  | `[mcp_servers.ccdx_image]` | Added in a marked CCDX-owned block only after `ccdx enable-image`; removed by `ccdx disable-image` | Absent by default |
78
78
 
79
- The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with line-based edits and atomic replacement; this is not a general TOML formatter. The shell environment updater recognizes the literal `[shell_environment_policy.set]` header. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
79
+ The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with format-preserving edits and atomic replacement; this is not a general TOML formatter. Quoted keys, quoted table headers, and explicit dotted shell-environment declarations are recognized; inline shell-environment tables are left intact. Startup and image configuration commands validate UTF-8/TOML and verify that unrelated settings retain their values before writing. An unsafe edit leaves the original file unchanged. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
80
80
 
81
81
  ## Version 0.7.0 migration
82
82
 
@@ -145,7 +145,7 @@ Image generation is disabled by default. Configure it interactively only when yo
145
145
  ccdx enable-image
146
146
  ```
147
147
 
148
- The command asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog, detects either the standard OpenAI Images request format or the supported `input.messages` format, and asks for a numeric model selection only when multiple image models are available. Its validation request omits the prompt and does not generate an image.
148
+ The command asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog and asks for a numeric model selection only when multiple image models are available. It then detects either the standard OpenAI Images request format or the supported `input.messages` format for the selected model only. Its single validation request omits the prompt and does not generate an image.
149
149
 
150
150
  The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout. Enabling also installs CCDX-owned `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. Codex uses the MCP tool directly, including through deferred tool search; an already-open task without that tool can use the helper to call the same local service and display the saved image. Neither route requires Python, an OpenAI SDK, or copying credentials. The original built-in imagegen skill remains available for capabilities such as editing uploaded images and other reference-image workflows.
151
151
 
@@ -160,7 +160,7 @@ ccdx disable-image
160
160
 
161
161
  `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 CCDX image guidance; user-modified skill 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` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
162
162
 
163
- 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. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
163
+ 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`.
164
164
 
165
165
  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).
166
166
 
@@ -353,6 +353,8 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
353
353
 
354
354
  Changing the model or transport of a continuation can require removing incompatible encrypted history from the forwarded request. A specific upstream encrypted-state verification failure can also trigger one sanitized retry, including when a nominally successful HTTP response ends with `response.failed`. CCDX withholds only the pre-output prelude of an encrypted continuation while evaluating that terminal event; it never retries after visible model output, so it cannot duplicate an already-started answer. Unrelated failures pass through unchanged. CCDX does not decrypt that state locally.
355
355
 
356
+ After the stream handshake succeeds, stream liveness uses the idle timeout rather than the expired handshake deadline. Eligible HTTP-200 failure recovery acquires a bounded preparation budget and history admission before rebuilding its payload, then releases those reservations when the retry opens upstream or fails. Normal streams still release preparation admission after their response headers arrive.
357
+
356
358
  Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Allowlisted rate-limit, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
357
359
 
358
360
  CCDX keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, addressing this cause of duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged by this ID normalization. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.7.10",
3
+ "version": "0.8.0",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/cli-image.mjs CHANGED
@@ -64,9 +64,12 @@ export function hiddenQuestion(input, output, question) {
64
64
  }
65
65
 
66
66
  function fileContent(filePath) {
67
- try { return fs.readFileSync(filePath, "utf8"); }
67
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath)); }
68
68
  catch (error) {
69
69
  if (error?.code === "ENOENT") return "";
70
+ if (error?.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
71
+ throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
72
+ }
70
73
  throw error;
71
74
  }
72
75
  }
@@ -206,8 +209,14 @@ export async function runImageCommand({
206
209
  if (!apiKey) throw new Error("Image API key is required");
207
210
 
208
211
  output.write("Checking image API...\n");
209
- const inspected = await inspectImageProvider({ endpoint, apiKey, fetchImpl });
210
- const model = await chooseModel(inspected.modelIds, current?.model, ask, output);
212
+ let model;
213
+ const inspected = await inspectImageProvider({
214
+ endpoint, apiKey, fetchImpl,
215
+ selectModel: async (modelIds) => {
216
+ model = await chooseModel(modelIds, current?.model, ask, output);
217
+ return model;
218
+ },
219
+ });
211
220
  const result = enableImageProvider({
212
221
  providerConfig: {
213
222
  endpoint: inspected.endpoint,
@@ -51,9 +51,14 @@ function unmanagedSettings(config) {
51
51
  }
52
52
 
53
53
  function startupPreview(content, config, host, port) {
54
- const updated = content === null
55
- ? { content: initialCodexConfig(port, host), changed: true }
56
- : computeUpdatedCodexConfig(content, port, host);
54
+ let updated;
55
+ try {
56
+ updated = content === null
57
+ ? { content: initialCodexConfig(port, host), changed: true }
58
+ : computeUpdatedCodexConfig(content, port, host);
59
+ } catch {
60
+ return { kind: "err", message: "Startup configuration cannot be edited safely; review the file before starting ccdx" };
61
+ }
57
62
  if (!updated.changed) return { kind: "ok", message: "No startup configuration changes required" };
58
63
  let next;
59
64
  try {
@@ -0,0 +1,147 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { parse } from "smol-toml";
3
+
4
+ export function parseCodexToml(content) {
5
+ try {
6
+ return parse(content, { integersAsBigInt: true });
7
+ } catch {
8
+ // TOML parser errors include source snippets, which may contain credentials.
9
+ throw new Error("Codex configuration is not valid TOML; no configuration changes were written");
10
+ }
11
+ }
12
+
13
+ export function configValue(config, keys) {
14
+ return keys.reduce((value, key) => (
15
+ value && Object.hasOwn(value, key) ? value[key] : undefined
16
+ ), config);
17
+ }
18
+
19
+ function keyParts(source) {
20
+ let value = parseCodexToml(`${source} = 0`);
21
+ const keys = [];
22
+ while (value && typeof value === "object") {
23
+ const [key] = Object.keys(value);
24
+ keys.push(key);
25
+ value = value[key];
26
+ }
27
+ return keys;
28
+ }
29
+
30
+ // Locate complete declarations without interpreting value syntax. The TOML parser
31
+ // remains authoritative; this scanner only supplies spans for surgical edits.
32
+ export function codexTomlStatements(content) {
33
+ const statements = [];
34
+ let section = [];
35
+ let offset = 0;
36
+ let line = 0;
37
+ while (offset < content.length) {
38
+ const char = content[offset];
39
+ if (/\s/.test(char)) {
40
+ if (char === "\n") line += 1;
41
+ offset += 1;
42
+ continue;
43
+ }
44
+ const start = offset;
45
+ const startLine = line;
46
+ if (char === "#" || char === "[") {
47
+ const end = content.indexOf("\n", offset);
48
+ offset = end < 0 ? content.length : end;
49
+ const source = content.slice(start, offset).trimEnd();
50
+ if (char === "#") statements.push({ kind: "comment", start, end: offset, line: startLine, source });
51
+ else {
52
+ const brackets = source.startsWith("[[") ? 2 : 1;
53
+ let quote = "";
54
+ let close = brackets;
55
+ for (; close < source.length; close += 1) {
56
+ const next = source[close];
57
+ if (quote === '"' && next === "\\") { close += 1; continue; }
58
+ if (quote) { if (next === quote) quote = ""; continue; }
59
+ if (next === '"' || next === "'") quote = next;
60
+ else if (next === "]") break;
61
+ }
62
+ section = keyParts(source.slice(brackets, close));
63
+ statements.push({ kind: "table", keys: section, start, end: offset, line: startLine });
64
+ }
65
+ continue;
66
+ }
67
+
68
+ let quote = "";
69
+ for (; offset < content.length; offset += 1) {
70
+ const next = content[offset];
71
+ if (quote === '"' && next === "\\") { offset += 1; continue; }
72
+ if (quote) { if (next === quote) quote = ""; continue; }
73
+ if (next === '"' || next === "'") quote = next;
74
+ else if (next === "=") break;
75
+ }
76
+ const keys = [...section, ...keyParts(content.slice(start, offset).trim())];
77
+ offset += 1;
78
+ while (content[offset] === " " || content[offset] === "\t") offset += 1;
79
+ const valueStart = offset;
80
+ let depth = 0;
81
+ let multiline = false;
82
+ for (; offset < content.length; offset += 1) {
83
+ const next = content[offset];
84
+ if (next === "\n") line += 1;
85
+ if (quote) {
86
+ if (quote === '"' && next === "\\") {
87
+ if (content[offset + 1] === "\n") line += 1;
88
+ offset += 1;
89
+ } else if (next === quote) {
90
+ if (!multiline) quote = "";
91
+ else if (content.startsWith(quote.repeat(3), offset)) {
92
+ while (content[offset + 1] === quote) offset += 1;
93
+ quote = "";
94
+ }
95
+ }
96
+ continue;
97
+ }
98
+ if (next === '"' || next === "'") {
99
+ quote = next;
100
+ multiline = content.startsWith(next.repeat(3), offset);
101
+ if (multiline) offset += 2;
102
+ } else if (next === "[" || next === "{") depth += 1;
103
+ else if (next === "]" || next === "}") depth -= 1;
104
+ else if (next === "#") {
105
+ if (depth === 0) break;
106
+ const end = content.indexOf("\n", offset);
107
+ offset = end < 0 ? content.length : end - 1;
108
+ } else if (next === "\n" && depth === 0) {
109
+ line -= 1;
110
+ break;
111
+ }
112
+ }
113
+ const valueEnd = valueStart + content.slice(valueStart, offset).trimEnd().length;
114
+ statements.push({ kind: "value", keys, section, start, end: offset, line: startLine, valueStart, valueEnd });
115
+ }
116
+ return statements;
117
+ }
118
+
119
+ function omitManaged(config, paths) {
120
+ const result = structuredClone(config);
121
+ for (const keys of paths) {
122
+ const parents = [];
123
+ let value = result;
124
+ for (const key of keys) {
125
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, key)) break;
126
+ parents.push([value, key]);
127
+ value = value[key];
128
+ }
129
+ if (parents.length === keys.length) {
130
+ const [parent, leaf] = parents.pop();
131
+ delete parent[leaf];
132
+ }
133
+ while (parents.length) {
134
+ const [owner, key] = parents.pop();
135
+ if (!owner[key] || typeof owner[key] !== "object" || Array.isArray(owner[key]) || Object.keys(owner[key]).length) break;
136
+ delete owner[key];
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+
142
+ export function validateManagedConfigEdit(before, content, managedPaths) {
143
+ const after = parseCodexToml(content);
144
+ if (!isDeepStrictEqual(omitManaged(before, managedPaths), omitManaged(after, managedPaths))) {
145
+ throw new Error("Unsafe Codex configuration edit outside CCDX's managed keys; no configuration changes were written");
146
+ }
147
+ }
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 { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
7
8
 
8
9
  const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
9
10
  const MODEL_CONTEXT_WINDOW = 1_000_000;
@@ -11,99 +12,75 @@ const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
11
12
  const IMAGE_MCP_START = "# ccdx:image-mcp:start";
12
13
  const IMAGE_MCP_END = "# ccdx:image-mcp:end";
13
14
  const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
15
+ const STARTUP_MANAGED_PATHS = [
16
+ "openai_base_url",
17
+ "model_context_window",
18
+ "model_auto_compact_token_limit",
19
+ "shell_environment_policy.set.OPENAI_BASE_URL",
20
+ "shell_environment_policy.set.OPENAI_API_KEY",
21
+ "features.context_management",
22
+ ].map((key) => key.split("."));
23
+ const IMAGE_MANAGED_PATH = ["mcp_servers", "ccdx_image"];
24
+
25
+ function sameKeys(left, right) {
26
+ return left.length === right.length && left.every((key, index) => key === right[index]);
27
+ }
14
28
 
15
- function isTomlTableHeader(line) {
16
- const source = line.trimStart();
17
- const openingBrackets = source.startsWith("[[") ? 2 : source.startsWith("[") ? 1 : 0;
18
- if (!openingBrackets) return false;
19
-
20
- let quote = "";
21
- let escaped = false;
22
- for (let index = openingBrackets; index < source.length; index += 1) {
23
- const char = source[index];
24
- if (quote) {
25
- if (quote === '"' && char === "\\" && !escaped) {
26
- escaped = true;
27
- continue;
28
- }
29
- if (char === quote && !escaped) quote = "";
30
- escaped = false;
31
- continue;
32
- }
33
- if (char === '"' || char === "'") {
34
- quote = char;
35
- continue;
36
- }
37
- if (char !== "]" || (openingBrackets === 2 && source[index + 1] !== "]")) continue;
38
-
39
- if (!source.slice(openingBrackets, index).trim()) return false;
40
- const tail = source.slice(index + openingBrackets).trim();
41
- return tail === "" || tail.startsWith("#");
42
- }
43
- return false;
29
+ function setExistingTomlValue(lines, keys, value) {
30
+ const content = lines.join("\n");
31
+ if (configValue(parseCodexToml(content), keys) === value) return false;
32
+ const entry = codexTomlStatements(content).find((item) => item.kind === "value" && sameKeys(item.keys, keys));
33
+ if (!entry) return false;
34
+ const updated = content.slice(0, entry.valueStart) + JSON.stringify(value) + content.slice(entry.valueEnd);
35
+ lines.splice(0, lines.length, ...updated.split("\n"));
36
+ return true;
44
37
  }
45
38
 
46
39
  function setTopLevelTomlDefault(lines, key, value) {
47
- let end = lines.findIndex(isTomlTableHeader);
48
- if (end === -1) end = lines.length;
49
-
50
- const keyRe = new RegExp(`^\\s*${key}\\s*=`);
51
- if (lines.slice(0, end).some((line) => keyRe.test(line))) return false;
52
-
40
+ const content = lines.join("\n");
41
+ if (Object.hasOwn(parseCodexToml(content), key)) return false;
42
+ let end = codexTomlStatements(content).find((item) => item.kind === "table")?.line ?? lines.length;
53
43
  while (end > 0 && lines[end - 1].trim() === "") end--;
54
44
  lines.splice(end, 0, `${key} = ${value}`);
55
45
  return true;
56
46
  }
57
47
 
58
48
  function setTomlKey(lines, sectionName, key, value) {
59
- const sectionLine = `[${sectionName}]`;
60
- const start = lines.findIndex((line) => line.trim() === sectionLine);
61
- if (start === -1) return false;
62
-
63
- let end = lines.length;
64
- for (let i = start + 1; i < lines.length; i++) {
65
- if (isTomlTableHeader(lines[i])) {
66
- end = i;
67
- break;
68
- }
49
+ const keys = [...sectionName.split("."), key];
50
+ const content = lines.join("\n");
51
+ if (configValue(parseCodexToml(content), keys) !== undefined) {
52
+ return setExistingTomlValue(lines, keys, value);
69
53
  }
70
-
71
- const nextLine = `${key} = "${value}"`;
72
- const keyRe = new RegExp(`^\\s*${key}\\s*=`);
73
- for (let i = start + 1; i < end; i++) {
74
- if (keyRe.test(lines[i])) {
75
- const changed = lines[i] !== nextLine;
76
- lines[i] = nextLine;
77
- return changed;
78
- }
79
- }
80
-
81
- lines.splice(end, 0, nextLine);
54
+ const statements = codexTomlStatements(content);
55
+ const parent = keys.slice(0, -1);
56
+ const table = statements.find((item) => item.kind === "table" && sameKeys(item.keys, parent));
57
+ const dotted = statements.find((item) => item.kind === "value"
58
+ && item.keys.length > parent.length && sameKeys(item.keys.slice(0, parent.length), parent));
59
+ const anchor = table || dotted;
60
+ // Preserve absent and inline environment tables; only edit declarations whose
61
+ // exact value or insertion scope can be identified without rewriting them.
62
+ if (!anchor) return false;
63
+ const scope = table ? table.keys : dotted.section;
64
+ const nextKey = keys.slice(scope.length).join(".");
65
+ const end = statements.find((item) => item.kind === "table" && item.start > anchor.start)?.line ?? lines.length;
66
+ lines.splice(end, 0, `${nextKey} = ${JSON.stringify(value)}`);
82
67
  return true;
83
68
  }
84
69
 
85
70
  function ensureContextManagementDefault(lines) {
86
- const firstSection = lines.findIndex(isTomlTableHeader);
87
- const topLevel = lines.slice(0, firstSection === -1 ? lines.length : firstSection);
88
- // Preserve alternative existing TOML declarations rather than creating a
89
- // conflicting table or replacing a structured context-management value.
90
- if (topLevel.some((line) => /^\s*(?:features|"features"|'features')\s*(?:=|\.)/.test(line))
91
- || lines.some((line) => /^\s*\[\s*(?:features|"features"|'features')\s*\.\s*(?:context_management|"context_management"|'context_management')\s*(?:\.|\])/.test(line))) {
92
- return false;
93
- }
94
- const start = lines.findIndex((line) => /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/.test(line));
95
- if (start === -1) {
71
+ const content = lines.join("\n");
72
+ const config = parseCodexToml(content);
73
+ if (configValue(config, ["features", "context_management"]) !== undefined) return false;
74
+ const statements = codexTomlStatements(content);
75
+ const table = statements.find((item) => item.kind === "table" && sameKeys(item.keys, ["features"]));
76
+ if (!table) {
77
+ if (Object.hasOwn(config, "features")) return false;
96
78
  if (lines.length && lines.at(-1).trim() !== "") lines.push("");
97
79
  lines.push("[features]", "context_management = true");
98
80
  return true;
99
81
  }
100
-
101
- let end = start + 1;
102
- while (end < lines.length && !isTomlTableHeader(lines[end])) end += 1;
103
- if (lines.slice(start + 1, end).some((line) => /^\s*(?:context_management|"context_management"|'context_management')\s*(?:=|\.)/.test(line))) {
104
- return false;
105
- }
106
- while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
82
+ let end = statements.find((item) => item.kind === "table" && item.start > table.start)?.line ?? lines.length;
83
+ while (end > table.line + 1 && lines[end - 1].trim() === "") end -= 1;
107
84
  lines.splice(end, 0, "context_management = true");
108
85
  return true;
109
86
  }
@@ -116,8 +93,9 @@ function imageMcpHost(adapterHost) {
116
93
  }
117
94
 
118
95
  function removeManagedImageMcp(lines) {
119
- const starts = lines.flatMap((line, index) => line === IMAGE_MCP_START ? [index] : []);
120
- const ends = lines.flatMap((line, index) => line === IMAGE_MCP_END ? [index] : []);
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);
121
99
  let start = starts[0] ?? -1;
122
100
  const end = ends[0] ?? -1;
123
101
  if (start === -1 && end === -1) return false;
@@ -136,7 +114,7 @@ function removeManagedImageMcp(lines) {
136
114
  }
137
115
 
138
116
  function hasUnmanagedImageMcp(lines) {
139
- return lines.some((line) => line.trim() === `[${IMAGE_MCP_SECTION}]`);
117
+ return configValue(parseCodexToml(lines.join("\n")), IMAGE_MANAGED_PATH) !== undefined;
140
118
  }
141
119
 
142
120
  function ensureImageMcp(lines, enabled, adapterPort, adapterHost) {
@@ -167,6 +145,7 @@ export function computeUpdatedCodexConfig(
167
145
  adapterHost = "127.0.0.1",
168
146
  { imageProviderEnabled } = {},
169
147
  ) {
148
+ const config = parseCodexToml(content);
170
149
  const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
171
150
  const hadTrailingNewline = content.endsWith("\n");
172
151
  const lines = content.split("\n");
@@ -174,17 +153,11 @@ export function computeUpdatedCodexConfig(
174
153
 
175
154
  let changed = false;
176
155
  const openaiLine = `openai_base_url = "${baseUrl}"`;
177
- const firstSection = lines.findIndex(isTomlTableHeader);
178
- const topLevelEnd = firstSection === -1 ? lines.length : firstSection;
179
- const openaiIndex = lines.findIndex((line, index) => (
180
- index < topLevelEnd && /^\s*openai_base_url\s*=/.test(line)
181
- ));
182
- if (openaiIndex === -1) {
156
+ if (!Object.hasOwn(config, "openai_base_url")) {
183
157
  lines.unshift(openaiLine);
184
158
  changed = true;
185
- } else if (lines[openaiIndex] !== openaiLine) {
186
- lines[openaiIndex] = openaiLine;
187
- changed = true;
159
+ } else {
160
+ changed = setExistingTomlValue(lines, ["openai_base_url"], baseUrl);
188
161
  }
189
162
 
190
163
  changed = setTopLevelTomlDefault(lines, "model_context_window", MODEL_CONTEXT_WINDOW) || changed;
@@ -196,7 +169,10 @@ export function computeUpdatedCodexConfig(
196
169
  changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
197
170
  }
198
171
 
199
- return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
172
+ const updated = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
173
+ validateManagedConfigEdit(config, updated, typeof imageProviderEnabled === "boolean"
174
+ ? [...STARTUP_MANAGED_PATHS, IMAGE_MANAGED_PATH] : STARTUP_MANAGED_PATHS);
175
+ return { content: updated, changed };
200
176
  }
201
177
 
202
178
  export function initialCodexConfig(adapterPort, adapterHost, { imageProviderEnabled = false } = {}) {
@@ -224,11 +200,14 @@ export function computeImageMcpCodexConfig(
224
200
  content,
225
201
  { enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
226
202
  ) {
203
+ const config = parseCodexToml(content);
227
204
  const hadTrailingNewline = content.endsWith("\n");
228
205
  const lines = content.split("\n");
229
206
  if (hadTrailingNewline) lines.pop();
230
207
  const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost);
231
- return { content: lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : ""), changed };
208
+ const updated = lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : "");
209
+ validateManagedConfigEdit(config, updated, [IMAGE_MANAGED_PATH]);
210
+ return { content: updated, changed };
232
211
  }
233
212
 
234
213
  export function ensureCodexConfig(adapterPort = 2026, {
@@ -245,7 +224,13 @@ export function ensureCodexConfig(adapterPort = 2026, {
245
224
  return;
246
225
  }
247
226
 
248
- const content = fs.readFileSync(filePath, "utf-8");
227
+ let content;
228
+ try {
229
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
230
+ } 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");
233
+ }
249
234
  const updated = computeUpdatedCodexConfig(content, adapterPort, host, { imageProviderEnabled });
250
235
 
251
236
  if (!updated.changed) {
@@ -33,6 +33,10 @@ const RETRY_POLICIES = Object.freeze([
33
33
  }),
34
34
  ]);
35
35
 
36
+ export function hasCopilotResponseRetryPolicy(statusCode, errorText, usedPolicies = new Set()) {
37
+ return RETRY_POLICIES.some((policy) => !usedPolicies.has(policy.id) && policy.matches(statusCode, errorText));
38
+ }
39
+
36
40
  export function selectCopilotResponseRetry(
37
41
  reqContext,
38
42
  statusCode,
package/src/image-mcp.mjs CHANGED
@@ -113,6 +113,17 @@ export function createImageMcpHandler({
113
113
  writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
114
114
  return;
115
115
  }
116
+ // This endpoint serves native Codex/Node clients, not browser pages. A
117
+ // loopback socket alone does not make a browser-supplied Origin trusted.
118
+ if (Object.hasOwn(req.headers || {}, "origin")) {
119
+ writeJson(res, 403, jsonRpcError(null, -32001, "Browser origins are not allowed for Image MCP"));
120
+ return;
121
+ }
122
+ const mediaType = String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
123
+ if (mediaType !== "application/json") {
124
+ writeJson(res, 415, jsonRpcError(null, -32600, "Image MCP requires application/json"));
125
+ return;
126
+ }
116
127
 
117
128
  let request;
118
129
  try { request = await readBody(req); }
@@ -170,6 +170,7 @@ export async function inspectImageProvider({
170
170
  apiKey,
171
171
  fetchImpl = fetch,
172
172
  timeoutMs = IMAGE_PROVIDER_TIMEOUT_MS,
173
+ selectModel,
173
174
  } = {}) {
174
175
  const normalizedEndpoint = normalizeImageEndpoint(endpoint);
175
176
  const normalizedKey = checkedApiKey(apiKey);
@@ -178,11 +179,13 @@ export async function inspectImageProvider({
178
179
  if (!models.response.ok) throw new Error(`Image API model lookup failed with HTTP ${models.response.status}`);
179
180
  const modelIds = imageModelIds(models.body);
180
181
  if (!modelIds.length) throw new Error("Image API model lookup returned no image models");
182
+ const model = selectModel ? await selectModel([...modelIds]) : modelIds[0];
183
+ if (!modelIds.includes(model)) throw new Error("Selected image model is not in the provider catalog");
181
184
 
182
185
  const probe = await fetchJson(normalizedEndpoint, {
183
186
  method: "POST",
184
187
  headers: { ...headers, "Content-Type": "application/json" },
185
- body: JSON.stringify({ model: modelIds[0] }),
188
+ body: JSON.stringify({ model }),
186
189
  }, { fetchImpl, timeoutMs });
187
190
  const protocol = detectProtocol(probe.response.status, probe.body);
188
191
  if (!protocol) throw new Error("Image API protocol is unsupported or could not be detected safely");
@@ -197,58 +197,66 @@ export async function downloadPublicImage(urlValue, {
197
197
  if (url.protocol !== "https:" || url.username || url.password) {
198
198
  throw providerError("Image API returned an unsafe image URL", "ccdx_image_url_unsafe");
199
199
  }
200
- const addresses = await lookup(url.hostname, { all: true, verbatim: true });
201
- if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
202
- throw providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe");
203
- }
204
200
  return new Promise((resolve, reject) => {
205
201
  let settled = false;
202
+ let request;
206
203
  const finish = (error, value) => {
207
204
  if (settled) return;
208
205
  settled = true;
206
+ clearTimeout(timer);
209
207
  signal?.removeEventListener("abort", onAbort);
210
- if (error) reject(error);
208
+ if (error) {
209
+ request?.destroy(error);
210
+ reject(error);
211
+ }
211
212
  else resolve(value);
212
213
  };
213
- const request = https.get(url, {
214
- headers: { Accept: "image/*,application/octet-stream;q=0.5" },
215
- lookup: (_hostname, options, callback) => {
216
- if (options?.all) callback(null, addresses);
217
- else callback(null, addresses[0].address, addresses[0].family);
218
- },
219
- }, (response) => {
220
- if (response.statusCode < 200 || response.statusCode >= 300) {
221
- response.resume();
222
- finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
223
- return;
224
- }
225
- const declared = Number(response.headers["content-length"]);
226
- if (Number.isFinite(declared) && declared > maxBytes) {
227
- response.destroy();
228
- finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
214
+ const timer = setTimeout(() => finish(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
215
+ timer.unref?.();
216
+ const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
217
+ if (signal?.aborted) { onAbort(); return; }
218
+ signal?.addEventListener("abort", onAbort, { once: true });
219
+ // DNS and transfer share one deadline; a late lookup cannot start a cancelled download.
220
+ Promise.resolve().then(() => settled ? undefined : lookup(url.hostname, { all: true, verbatim: true })).then((addresses) => {
221
+ if (settled) return;
222
+ if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
223
+ finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
229
224
  return;
230
225
  }
231
- const chunks = [];
232
- let total = 0;
233
- response.on("data", (chunk) => {
234
- total += chunk.length;
235
- if (total > maxBytes) {
226
+ request = https.get(url, {
227
+ headers: { Accept: "image/*,application/octet-stream;q=0.5" },
228
+ lookup: (_hostname, options, callback) => {
229
+ if (options?.all) callback(null, addresses);
230
+ else callback(null, addresses[0].address, addresses[0].family);
231
+ },
232
+ }, (response) => {
233
+ response.on("error", (error) => finish(error));
234
+ if (response.statusCode < 200 || response.statusCode >= 300) {
235
+ response.resume();
236
+ finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
237
+ return;
238
+ }
239
+ const declared = Number(response.headers["content-length"]);
240
+ if (Number.isFinite(declared) && declared > maxBytes) {
236
241
  response.destroy();
237
242
  finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
238
243
  return;
239
244
  }
240
- chunks.push(chunk);
245
+ const chunks = [];
246
+ let total = 0;
247
+ response.on("data", (chunk) => {
248
+ total += chunk.length;
249
+ if (total > maxBytes) {
250
+ response.destroy();
251
+ finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
252
+ return;
253
+ }
254
+ chunks.push(chunk);
255
+ });
256
+ response.on("end", () => finish(null, Buffer.concat(chunks, total)));
241
257
  });
242
- response.on("end", () => finish(null, Buffer.concat(chunks, total)));
243
- response.on("error", (error) => finish(error));
244
- });
245
- const timer = setTimeout(() => request.destroy(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
246
- timer.unref?.();
247
- request.on("close", () => clearTimeout(timer));
248
- request.on("error", (error) => finish(error));
249
- const onAbort = () => request.destroy(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
250
- if (signal?.aborted) onAbort();
251
- else signal?.addEventListener("abort", onAbort, { once: true });
258
+ request.on("error", (error) => finish(error));
259
+ }).catch((error) => finish(error));
252
260
  });
253
261
  }
254
262
 
@@ -144,6 +144,42 @@ export function createResponsesHandler(options) {
144
144
  activeDeadline = now() + timeoutMs;
145
145
  abort.setTimeout(timeoutMs, activeTimeoutReason);
146
146
  };
147
+ const startStream = () => {
148
+ assertPrepareActive();
149
+ // Headers completed this phase. Stream liveness is now governed by its
150
+ // idle timer, not the original handshake deadline.
151
+ activeDeadline = Number.POSITIVE_INFINITY;
152
+ };
153
+ const acquireRetryPreparation = async (reqContext) => {
154
+ assertPrepareActive();
155
+ activeDeadline = now() + upstreamTimeoutMs;
156
+ activeTimeoutReason = "responses_prepare_timeout";
157
+ abort.setTimeout(upstreamTimeoutMs, activeTimeoutReason);
158
+ let releaseRetryRequest = () => {};
159
+ let retrySnapshot = null;
160
+ const release = () => {
161
+ retrySnapshot?.release();
162
+ releaseRetryRequest();
163
+ };
164
+ try {
165
+ releaseRetryRequest = await measureRequestStageAsync("admission", () => acquireRequest(req, { signal: abort.signal }));
166
+ assertPrepareActive();
167
+ if (reqContext.historyParentId) {
168
+ retrySnapshot = measureRequestStage("history", () => acquireResponseHistorySnapshot(reqContext.historyParentId, {
169
+ assertActive: assertPrepareActive,
170
+ signal: abort.signal,
171
+ }));
172
+ if (retrySnapshot.bytes > 0) {
173
+ await measureRequestStageAsync("admission", () => releaseRetryRequest.reserveResponseHistory?.(retrySnapshot.bytes, { signal: abort.signal }));
174
+ }
175
+ }
176
+ assertPrepareActive();
177
+ return { historySnapshot: retrySnapshot, release };
178
+ } catch (error) {
179
+ release();
180
+ throw error;
181
+ }
182
+ };
147
183
  const applyAdaptiveTimeout = (error) => {
148
184
  if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
149
185
  return error;
@@ -251,10 +287,12 @@ export function createResponsesHandler(options) {
251
287
  applyCopilotResponsesRequestPolicies(prepared.body);
252
288
  if (routePlan.protocol === "openai-responses") {
253
289
  const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
290
+ acquireRetryPreparation,
254
291
  assertPrepareActive,
255
292
  signal: abort.signal,
256
293
  abort,
257
294
  onUpstreamStart: startUpstreamTimeout,
295
+ onStreamStart: startStream,
258
296
  releaseRequest: releaseUpstreamPayload,
259
297
  responseFailures,
260
298
  streamIdleTimeoutMs,
@@ -8,7 +8,11 @@ import {
8
8
  sendUpstreamError,
9
9
  writeOrDrain,
10
10
  } from "./http-transport.mjs";
11
- import { openCopilotResponse, selectCopilotResponseRetry } from "./copilot-responses-compat.mjs";
11
+ import {
12
+ hasCopilotResponseRetryPolicy,
13
+ openCopilotResponse,
14
+ selectCopilotResponseRetry,
15
+ } from "./copilot-responses-compat.mjs";
12
16
  import {
13
17
  dropMaterializedResponseHistory,
14
18
  hasEncryptedResponseState,
@@ -314,21 +318,29 @@ function streamHeaders(resp) {
314
318
  };
315
319
  }
316
320
 
317
- function selectStreamFailureRetry(reqContext, failure, usedRetryPolicies) {
321
+ async function selectStreamFailureRetry(reqContext, failure, usedRetryPolicies, options) {
318
322
  if (!failure) return null;
323
+ const errorText = responseFailureText(failure.event, failure.eventType);
324
+ if (!hasCopilotResponseRetryPolicy(400, errorText, usedRetryPolicies)) return null;
325
+ const preparation = await options.acquireRetryPreparation?.(reqContext);
326
+ let keepReservation = false;
319
327
  try {
320
- const restored = restoreMaterializedResponseHistoryForRetry(reqContext);
328
+ const restored = restoreMaterializedResponseHistoryForRetry(reqContext, {
329
+ assertActive: options.assertPrepareActive,
330
+ historySnapshot: preparation?.historySnapshot,
331
+ });
332
+ const retry = selectCopilotResponseRetry(restored, 400, errorText, usedRetryPolicies);
333
+ options.assertPrepareActive?.();
334
+ keepReservation = Boolean(retry);
321
335
  return {
322
- retry: selectCopilotResponseRetry(
323
- restored,
324
- 400,
325
- responseFailureText(failure.event, failure.eventType),
326
- usedRetryPolicies,
327
- ),
336
+ retry: retry ? { ...retry, releasePreparation: preparation?.release } : null,
328
337
  retrySkipped: null,
329
338
  };
330
339
  } catch {
340
+ options.assertPrepareActive?.();
331
341
  return { retry: null, retrySkipped: "history_unavailable" };
342
+ } finally {
343
+ if (!keepReservation) preparation?.release();
332
344
  }
333
345
  }
334
346
 
@@ -362,6 +374,7 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
362
374
  return { successful: false, compacted: false, upstreamStatus: resp.status };
363
375
  }
364
376
  await requireUpstreamEventStream(resp);
377
+ options.onStreamStart?.();
365
378
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
366
379
  const headers = streamHeaders(resp);
367
380
  let headersSent = false;
@@ -424,7 +437,13 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
424
437
 
425
438
  let retry = null;
426
439
  if (streamState.failure && holdPrelude && !streamState.sawOutput) {
427
- const selected = selectStreamFailureRetry(reqContext, streamState.failure, usedRetryPolicies);
440
+ let selected;
441
+ try {
442
+ selected = await selectStreamFailureRetry(reqContext, streamState.failure, usedRetryPolicies, options);
443
+ } catch (error) {
444
+ recordResponseFailure(options, reqContext, resp, streamState.failure, { retrySkipped: "retry_preparation_failed" });
445
+ throw error;
446
+ }
428
447
  retry = selected?.retry || null;
429
448
  if (!retry) retryDisabledReason = selected?.retrySkipped || "not_applicable";
430
449
  }
@@ -436,12 +455,16 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
436
455
  }
437
456
  if (retry) {
438
457
  console.warn(status("warn", retry.warning));
439
- opened = await openCopilotResponse(retry.reqContext, upstream, {
440
- ...options,
441
- usedRetryPolicies: retry.usedPolicies,
442
- });
443
- opened.streamRetryEligible = hasEncryptedResponseState(opened.reqContext.body?.input);
444
- dropMaterializedResponseHistory(opened.reqContext);
458
+ try {
459
+ opened = await openCopilotResponse(retry.reqContext, upstream, {
460
+ ...options,
461
+ usedRetryPolicies: retry.usedPolicies,
462
+ });
463
+ opened.streamRetryEligible = hasEncryptedResponseState(opened.reqContext.body?.input);
464
+ dropMaterializedResponseHistory(opened.reqContext);
465
+ } finally {
466
+ retry.releasePreparation?.();
467
+ }
445
468
  continue;
446
469
  }
447
470
  if (streamState.failure) markStreamFailure();
@@ -515,7 +538,13 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
515
538
  const response = parseSuccessfulResponsesResult(data);
516
539
  if (response.status === "failed") {
517
540
  const failure = { event: { type: "response.failed", response }, eventType: "response.failed" };
518
- const selected = selectStreamFailureRetry(reqContext, failure, usedRetryPolicies);
541
+ let selected;
542
+ try {
543
+ selected = await selectStreamFailureRetry(reqContext, failure, usedRetryPolicies, options);
544
+ } catch (error) {
545
+ recordResponseFailure(options, reqContext, resp, failure, { retrySkipped: "retry_preparation_failed" });
546
+ throw error;
547
+ }
519
548
  const retry = selected?.retry || null;
520
549
  recordResponseFailure(options, reqContext, resp, failure, {
521
550
  retry,
@@ -523,11 +552,15 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
523
552
  });
524
553
  if (retry) {
525
554
  console.warn(status("warn", retry.warning));
526
- opened = await openCopilotResponse(retry.reqContext, upstream, {
527
- ...options,
528
- usedRetryPolicies: retry.usedPolicies,
529
- });
530
- dropMaterializedResponseHistory(opened.reqContext);
555
+ try {
556
+ opened = await openCopilotResponse(retry.reqContext, upstream, {
557
+ ...options,
558
+ usedRetryPolicies: retry.usedPolicies,
559
+ });
560
+ dropMaterializedResponseHistory(opened.reqContext);
561
+ } finally {
562
+ retry.releasePreparation?.();
563
+ }
531
564
  continue;
532
565
  }
533
566
  }
@@ -389,7 +389,8 @@ export function dropMaterializedResponseHistory(reqContext) {
389
389
  return true;
390
390
  }
391
391
 
392
- export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertActive } = {}) {
392
+ export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertActive, historySnapshot } = {}) {
393
+ assertActive?.();
393
394
  if (!reqContext?.[RELEASED_MATERIALIZED_HISTORY] || !reqContext.historyParentId) return reqContext;
394
395
  const body = cloneJson(reqContext.body);
395
396
  body.previous_response_id = reqContext.historyParentId;
@@ -397,6 +398,7 @@ export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertA
397
398
  let restored = prepareResponsesRequest(body, {
398
399
  assertActive,
399
400
  copilotBoundary: false,
401
+ historySnapshot,
400
402
  mutate: true,
401
403
  });
402
404
  restored.surface = reqContext.surface;