codex-copilot-dx 0.7.9 → 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 +11 -5
- package/package.json +1 -1
- package/src/adapter.mjs +1 -1
- package/src/cli-animation.mjs +43 -8
- package/src/cli-image.mjs +12 -3
- package/src/cli-options.mjs +1 -1
- package/src/codex-config-doctor.mjs +8 -3
- package/src/config-toml.mjs +147 -0
- package/src/config.mjs +74 -89
- package/src/copilot-responses-compat.mjs +4 -0
- package/src/image-mcp.mjs +62 -7
- package/src/image-provider-config.mjs +4 -1
- package/src/image-provider.mjs +96 -41
- package/src/image-references.mjs +41 -0
- package/src/image-skill.mjs +11 -7
- package/src/image-tool-client.mjs +16 -7
- package/src/responses-handler.mjs +38 -0
- package/src/responses-proxy.mjs +55 -22
- package/src/responses-request.mjs +3 -1
- package/src/terminal-animation-gallery.mjs +85 -0
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
|
|
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
|
|
|
@@ -131,7 +131,7 @@ The selector is ordered numerically:
|
|
|
131
131
|
8. Relay — fixed nodes receive and pass a short light trail
|
|
132
132
|
9. Split — one incoming light trail divides into two outgoing trails
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
All nine rows preview their animations together while you enter a number to select one. The live previews keep each theme's original timing and leave the input cursor in place; selection ends them immediately. Small terminals, disabled animation, CI and dumb terminals retain static previews. Resizing or clearing the terminal pauses the live gallery so input stays usable. When the gallery cannot run, CCDX retains the selected animation's one-cycle confirmation when supported. The setting is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set.
|
|
135
135
|
|
|
136
136
|
All nine animations use the same 20-column colon track and cyan/blue palette. They indicate activity, not completion percentage. Braille was removed in 0.7.7. A saved `terminal_animation: "braille"` falls back to Comet without rewriting the settings file; choosing another animation replaces it, and explicitly choosing Comet clears it. Other settings remain intact.
|
|
137
137
|
|
|
@@ -145,9 +145,9 @@ 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
|
|
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
|
-
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 and reference
|
|
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
|
|
|
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 for providers already enabled in 0.7.8, without repeating API setup. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
|
|
153
153
|
|
|
@@ -160,7 +160,11 @@ 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
|
|
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
|
+
|
|
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
|
+
|
|
167
|
+
Editing references are opaque IDs bound to the provider configuration, not paths or URLs. Source images up to 10 MiB are kept in a 64 MiB byte-bounded in-memory LRU. IDs expire on eviction, provider/credential changes, or adapter restart; restarting Codex App alone does not clear them. A missing or expired ID fails before upstream dispatch. This feature does not import arbitrary local images or provide masks, transparent output, or an `/images/edits` endpoint. Other providers retain their existing generation behavior. The helper accepts `--image-id` for the same edit operation; its stdout remains the saved absolute file path and stderr reports the new ID. Normal host approvals still apply, and failed edits are never replaced with an automatic new generation.
|
|
164
168
|
|
|
165
169
|
### Update
|
|
166
170
|
|
|
@@ -349,6 +353,8 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
|
|
|
349
353
|
|
|
350
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.
|
|
351
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
|
+
|
|
352
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.
|
|
353
359
|
|
|
354
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
package/src/adapter.mjs
CHANGED
|
@@ -196,7 +196,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
196
196
|
if (pathname === "/mcp/image") {
|
|
197
197
|
if (imageMcpHandler) return imageMcpHandler(req, res);
|
|
198
198
|
return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
|
|
199
|
-
imageMcpHandler
|
|
199
|
+
imageMcpHandler ||= createImageMcpHandler();
|
|
200
200
|
return imageMcpHandler(req, res);
|
|
201
201
|
});
|
|
202
202
|
}
|
package/src/cli-animation.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
renderTerminalAnimationFrame,
|
|
7
7
|
TERMINAL_ANIMATION_THEMES,
|
|
8
8
|
} from "./terminal-animation.mjs";
|
|
9
|
+
import { startTerminalAnimationGallery } from "./terminal-animation-gallery.mjs";
|
|
9
10
|
import {
|
|
10
11
|
readUserSettings,
|
|
11
12
|
terminalAnimationPreference,
|
|
@@ -34,6 +35,7 @@ export async function runAnimationCommand({
|
|
|
34
35
|
output = process.stdout,
|
|
35
36
|
prompt,
|
|
36
37
|
preview = playTerminalAnimationPreview,
|
|
38
|
+
gallery = startTerminalAnimationGallery,
|
|
37
39
|
} = {}) {
|
|
38
40
|
if (!prompt && (!input.isTTY || !output.isTTY)) {
|
|
39
41
|
throw new Error(`${commandName} animation requires an interactive terminal`);
|
|
@@ -45,24 +47,49 @@ export async function runAnimationCommand({
|
|
|
45
47
|
|
|
46
48
|
output.write(`${commandName} animation\n`);
|
|
47
49
|
output.write(`Current: ${TERMINAL_ANIMATION_THEMES[currentIndex].label} (${current.source})\n\n`);
|
|
50
|
+
const labels = TERMINAL_ANIMATION_THEMES.map((theme, index) => ` ${index + 1}. ${theme.label}${markers(theme, current.theme)}`);
|
|
51
|
+
const prefixWidth = Math.max(...labels.map((label) => label.length)) + 2;
|
|
48
52
|
TERMINAL_ANIMATION_THEMES.forEach((theme, index) => {
|
|
49
|
-
|
|
50
|
-
? ` ${renderTerminalAnimationFrame(theme.id, 10)}`
|
|
51
|
-
: "";
|
|
52
|
-
output.write(` ${index + 1}. ${theme.label}${markers(theme, current.theme)}${staticPreview}\n`);
|
|
53
|
+
output.write(`${labels[index].padEnd(prefixWidth)}${renderTerminalAnimationFrame(theme.id, theme.startFrame)}\n`);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
let readline;
|
|
57
|
+
const abort = new AbortController();
|
|
56
58
|
const ask = prompt || (async (question) => {
|
|
57
|
-
readline
|
|
58
|
-
|
|
59
|
+
if (!readline) {
|
|
60
|
+
readline = createInterface({ input, output });
|
|
61
|
+
readline.once("SIGINT", () => abort.abort());
|
|
62
|
+
readline.once("close", () => abort.abort());
|
|
63
|
+
}
|
|
64
|
+
return readline.question(question, { signal: abort.signal });
|
|
59
65
|
});
|
|
60
66
|
|
|
61
67
|
let selectedTheme = current.theme;
|
|
62
68
|
let keepCurrent = false;
|
|
69
|
+
let livePreviewed = false;
|
|
70
|
+
let rowsBelow = 0;
|
|
71
|
+
let activeGallery;
|
|
72
|
+
let layoutChanged = false;
|
|
73
|
+
const stopForLayoutChange = () => {
|
|
74
|
+
layoutChanged = true;
|
|
75
|
+
activeGallery?.stop();
|
|
76
|
+
};
|
|
77
|
+
const onKeypress = (_text, key) => {
|
|
78
|
+
if ((key?.ctrl && key.name === "l") || (readline?.getCursorPos().rows ?? 0) > 0) stopForLayoutChange();
|
|
79
|
+
};
|
|
80
|
+
output.on?.("resize", stopForLayoutChange);
|
|
81
|
+
input.on?.("keypress", onKeypress);
|
|
63
82
|
try {
|
|
64
83
|
while (true) {
|
|
65
|
-
|
|
84
|
+
activeGallery = layoutChanged ? { enabled: false, stop() {} }
|
|
85
|
+
: gallery({ output, env, prefixWidth, rowsBelow, getInputRows: () => readline?.getCursorPos().rows ?? 0 });
|
|
86
|
+
livePreviewed ||= activeGallery.enabled;
|
|
87
|
+
let answer;
|
|
88
|
+
try {
|
|
89
|
+
answer = String(await ask(`Select [${currentIndex + 1}], or q to cancel: `) || "").trim();
|
|
90
|
+
} finally {
|
|
91
|
+
activeGallery.stop();
|
|
92
|
+
}
|
|
66
93
|
if (answer.toLowerCase() === "q") {
|
|
67
94
|
output.write("No changes made.\n");
|
|
68
95
|
return { changed: false, cancelled: true, theme: current.theme };
|
|
@@ -79,8 +106,16 @@ export async function runAnimationCommand({
|
|
|
79
106
|
break;
|
|
80
107
|
}
|
|
81
108
|
output.write(`Enter a number from 1 to ${TERMINAL_ANIMATION_THEMES.length}, or q to cancel.\n`);
|
|
109
|
+
rowsBelow += 2;
|
|
82
110
|
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!abort.signal.aborted) throw error;
|
|
113
|
+
output.write("\nNo changes made.\n");
|
|
114
|
+
return { changed: false, cancelled: true, theme: current.theme };
|
|
83
115
|
} finally {
|
|
116
|
+
activeGallery?.stop();
|
|
117
|
+
output.off?.("resize", stopForLayoutChange);
|
|
118
|
+
input.off?.("keypress", onKeypress);
|
|
84
119
|
readline?.close();
|
|
85
120
|
}
|
|
86
121
|
|
|
@@ -88,7 +123,7 @@ export async function runAnimationCommand({
|
|
|
88
123
|
? { changed: false, theme: current.theme }
|
|
89
124
|
: writeTerminalAnimationTheme(selectedTheme, { env, home });
|
|
90
125
|
const disabledValue = disabledEnvironmentValue(env);
|
|
91
|
-
if (!disabledValue) {
|
|
126
|
+
if (!disabledValue && !livePreviewed) {
|
|
92
127
|
try {
|
|
93
128
|
await preview(selectedTheme, { output });
|
|
94
129
|
} catch {
|
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
|
|
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
|
-
|
|
210
|
-
const
|
|
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,
|
package/src/cli-options.mjs
CHANGED
|
@@ -301,7 +301,7 @@ function topicHelp(name, topic) {
|
|
|
301
301
|
status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
|
|
302
302
|
models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
|
|
303
303
|
usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
|
|
304
|
-
animation: `Usage:\n ${name} animation\n\
|
|
304
|
+
animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
|
|
305
305
|
"enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
|
|
306
306
|
"disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
|
|
307
307
|
"image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and probes local image-tool readiness without generating an image or exposing its API key.`,
|
|
@@ -51,9 +51,14 @@ function unmanagedSettings(config) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function startupPreview(content, config, host, port) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
48
|
-
if (
|
|
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
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
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
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
|
186
|
-
lines[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|