codex-copilot-dx 0.8.2 → 0.8.4

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
@@ -28,6 +28,8 @@ Codex App requests a versioned, capability-rich model catalog that is different
28
28
 
29
29
  CCDX exposes the GPT-6 Standard path and removes its unverified speed-tier metadata. It leaves the bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. GPT-5.6 Sol Fast follows the separate rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
30
30
 
31
+ `/v1/models` still attempts live discovery first. With a usable in-process catalog, it limits that upstream wait to 1.5 seconds (or a shorter configured upstream timeout) before falling back to last-known-good data. Without a catalog it retains the regular upstream timeout. Received HTTP `401`/`403` responses remain errors, not cached successes; fallback does not grant inference access. This does not change the explicitly live `ccdx models` command.
32
+
31
33
  ## Prerequisites
32
34
 
33
35
  - A GitHub account with Copilot access to the desired models
@@ -78,6 +80,8 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
78
80
 
79
81
  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
82
 
83
+ Interactive image setup rereads the shared config after prompts and network validation, so unrelated changes made during setup are preserved. It checks the snapshot again before committing and rejects conflicting image declarations. Identical configuration bytes are not replaced; private file permissions may still be corrected. These checks reduce lost-update risks but are not a filesystem-wide transaction with other applications that do not share a lock.
84
+
81
85
  ## Version 0.7.0 migration
82
86
 
83
87
  CCDX 0.7.0 retires the former Claude App, Claude Code, Anthropic Messages API, isolated Claude account, and PM Studio patch integrations. Their setup, status, restore, profile, and routing commands are no longer supported.
@@ -147,10 +151,14 @@ ccdx enable-image
147
151
 
148
152
  The command accepts an HTTPS base URL or a full `/images/generations` endpoint, then asks for an API key without echoing the key. A bare origin such as `https://api.example` expands to `/v1/images/generations`; `https://api.example/v1` expands to the same endpoint. Custom base paths are retained, and existing full endpoints remain unchanged. CCDX uses GET for the corresponding `/models` catalog and POST for image requests; there is no endpoint-search or automatic fallback. It asks for a numeric model selection only when multiple image models are available, 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
153
 
154
+ An empty key answer retains the saved key only when the normalized endpoint has the same origin (scheme, host, and port). Changing origins requires explicitly entering a key before any provider request; the old key is never automatically sent to a new origin.
155
+
150
156
  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. Only after enabling, CCDX installs `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. The concise `SKILL.md` keeps operation selection, safety, and final-image delivery rules; editing details and helper instructions live in references read only for those operations. 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. Built-in skills and unrelated tools are not modified.
151
157
 
152
158
  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
159
 
160
+ 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.
161
+
154
162
  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
163
 
156
164
  Inspect or disable the optional provider with:
@@ -235,7 +243,9 @@ For the complete machine-readable status payload:
235
243
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
236
244
  ```
237
245
 
238
- The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome; long opaque encrypted values are redacted. They are cleared when the adapter restarts. The endpoint never includes prompts, completions, tool arguments, image content, account names, or token values.
246
+ The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome. Common credential forms, long opaque encrypted values, and explicitly labelled input/prompt/tool-content echoes are redacted. They are cleared when the adapter restarts. No request/response bodies, credential fields, or account profiles are copied into status. Arbitrary upstream prose cannot be guaranteed free of sensitive content; review diagnostics before sharing them.
247
+
248
+ `image_mcp` has its own HTTP route counter. After the image handler has been used, `image_generation` separately reports active, succeeded, failed, busy, cancelled, and chat-file delivery-failure counts, including tool errors returned with HTTP 200. A delivery failure can accompany a successful generation; it never causes another generation. Before handler initialization this field is `null`, meaning not observed, not necessarily disabled. Status never initializes that handler, reads its provider credential, installs guidance, or probes the provider. Input-image optimization metrics remain separate. `logging` reports pending records/bytes, queue caps, overflow drops, and write failures for usage and enabled debug-file logging.
239
249
 
240
250
  `stream_performance.by_route` also reports `request_ttft_ms`, measured from adapter entry to the first observed output delta, including local preparation. The existing `ttft_ms` continues to start at upstream dispatch. Neither timestamp means the client has drained or displayed the output. `preparation_ms` contains bounded histograms for admission (including history reservations), body reading/decompression/JSON parsing, history materialization/compact preparation, image optimization, and request serialization. Each sample measures one operation, including failed operations; retries can add samples. These are diagnostic intervals rather than a complete breakdown of request time: body reading includes internal decoded-body admission waits; authentication, routing, and other preparation are not individually timed. Non-streaming requests contribute preparation samples without inventing a first-token measurement.
241
251
 
@@ -267,7 +277,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
267
277
  - The GitHub OAuth credential is stored locally at `~/.local/share/copilot-api/github_token`. Short-lived Copilot service tokens remain in process memory and are refreshed as needed.
268
278
  - The optional image API credential is stored separately with mode `0600` only after `ccdx enable-image`; it is never placed in Codex configuration or diagnostic output.
269
279
  - Runtime status and usage records retain metadata rather than request/response content. Diagnostic logging records status and error messages; it is not a general redaction layer for arbitrary error text. See Debug logging before sharing logs.
270
- - Request and response bodies are bounded. Queues, history, image work, SSE parsing, retries, and shutdown all have explicit resource or time limits.
280
+ - Request/response bodies, request-admission queues, retained history, image work, SSE buffers, log queues, retries, and shutdown have explicit limits. These component limits are not a single process-wide memory ceiling; completed image files are persistent user artifacts, not an automatically evicted cache.
271
281
 
272
282
  ## Configuration
273
283
 
@@ -331,9 +341,11 @@ ccdx usage
331
341
 
332
342
  The summary covers the current log and its single rotated backup, not an unbounded lifetime history. It reports recorded responses and per-model input, cache-read, output, and total token metadata.
333
343
 
344
+ Usage writes are asynchronous and batched under the existing cross-process rotation lock. Pending and in-flight records share a 4096-record / 4 MiB ceiling. If either ceiling is reached, new excess records are dropped with a warning and a status counter; inference does not wait or retry. Normal records and per-record rotation boundaries are unchanged. The bounded shutdown flush remains 1.5 seconds; disk failures and shutdown deadlines can prevent persistence, so usage is diagnostic rather than a billing ledger.
345
+
334
346
  ## Debug logging
335
347
 
336
- Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. The log mirror writes console text verbatim, including diagnostic/error messages that can originate in external components, so review logs before sharing them. `ccdx doctor config` bypasses file logging entirely.
348
+ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. File diagnostics redact common credential forms and labelled content echoes, but this cannot identify every secret in arbitrary external prose; review logs before sharing them. File writes are asynchronous and batched with a 4096-record / 2 MiB pending-and-in-flight ceiling. Overflow drops excess file records with one warning and a status counter; terminal output continues. CLI exit paths flush for at most 1.5 seconds. `ccdx doctor config` bypasses file logging entirely.
337
349
 
338
350
  ## Large histories and images
339
351
 
@@ -357,6 +369,8 @@ Changing the model or transport of a continuation can require removing incompati
357
369
 
358
370
  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.
359
371
 
372
+ Downstream backpressure waits also observe cancellation and the stream deadline. A client that stops reading cannot retain the pending stream indefinitely after timeout; CCDX closes that connection without retrying its request. Final error-frame delivery has a separate one-second maximum drain wait.
373
+
360
374
  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.
361
375
 
362
376
  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/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 });
@@ -248,6 +248,7 @@ if (CLI.command === "doctor") {
248
248
  online: CLI.online,
249
249
  compat: CLI.compat,
250
250
  });
251
+ await LOGGING.cleanup();
251
252
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
252
253
  }
253
254
 
@@ -270,7 +271,10 @@ async function printUpdateNotice() {
270
271
  try {
271
272
  assertSafeAdapterHost(ADAPTER_HOST, process.env);
272
273
  void runInBackground(printUpdateNotice);
273
- if (await reuseRunningAdapterIfAvailable()) process.exit(0);
274
+ if (await reuseRunningAdapterIfAvailable()) {
275
+ await LOGGING.cleanup();
276
+ process.exit(0);
277
+ }
274
278
 
275
279
  // Ensure GitHub login, using device flow if no token exists.
276
280
  await ensureAuth();
@@ -347,7 +351,7 @@ try {
347
351
  exitCode = 1;
348
352
  console.error(status("err", `Shutdown failed: ${e.message}`));
349
353
  } finally {
350
- LOGGING.cleanup();
354
+ await LOGGING.cleanup();
351
355
  process.exit(exitCode);
352
356
  }
353
357
  };
@@ -357,6 +361,6 @@ try {
357
361
  console.error(status("err", e.message));
358
362
  await closeHttpServer(activeServer, { timeoutMs: RUNTIME.shutdownTimeoutMs }).catch(() => {});
359
363
  await flushUsageWrites();
360
- LOGGING.cleanup();
364
+ await LOGGING.cleanup();
361
365
  process.exit(1);
362
366
  }
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.4",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -213,6 +213,7 @@ export function createAdapterHandler(options = {}) {
213
213
  admission: acquireRequest,
214
214
  imagePressure,
215
215
  responseFailures,
216
+ imageGeneration: imageMcpHandler?.stats?.(),
216
217
  modelRegistry: codexModelRegistry,
217
218
  codexClient,
218
219
  codexModelRegistry,
@@ -237,7 +238,12 @@ export function createAdapterHandler(options = {}) {
237
238
  if (req.method === "GET" && pathname === "/v1/models") {
238
239
  const clientVersion = modelClientVersion(req.url);
239
240
  const abort = createRequestAbort(req, res);
240
- abort.setTimeout(upstreamTimeoutMs);
241
+ // Keep live discovery and explicit auth failures authoritative, but do
242
+ // not make a usable cached picker wait the full inference timeout.
243
+ const modelTimeoutMs = isValidModelList(codexModelRegistry?.models)
244
+ ? Math.min(upstreamTimeoutMs, parsePositiveInteger(options.cachedModelsTimeoutMs, 1500))
245
+ : upstreamTimeoutMs;
246
+ abort.setTimeout(modelTimeoutMs);
241
247
  listModelsFn({ signal: abort.signal })
242
248
  .then(async ({ status, body }) => {
243
249
  abort.clearTimeout();
package/src/cli-image.mjs CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { atomicWriteFilePairSync } from "./atomic-file.mjs";
6
+ import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
7
7
  import { terminalCell } from "./cli-table.mjs";
8
8
  import { computeImageMcpCodexConfig } from "./config.mjs";
9
9
  import { probeImageTool } from "./image-readiness.mjs";
@@ -11,6 +11,7 @@ import { updateImageSkill } from "./image-skill.mjs";
11
11
  import {
12
12
  imageProviderConfigPath,
13
13
  inspectImageProvider,
14
+ normalizeImageEndpoint,
14
15
  readImageProviderConfig,
15
16
  validateImageProviderConfig,
16
17
  } from "./image-provider-config.mjs";
@@ -110,12 +111,15 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
110
111
  const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
111
112
  const skill = updateImageSkill({ enabled: true, codexPath, adapterPort, adapterHost });
112
113
  try {
114
+ if (fileContent(codexPath) !== codexContent) {
115
+ throw new Error("Codex configuration changed during image setup; retry to preserve the newer settings");
116
+ }
113
117
  atomicWriteFilePairSync(
114
118
  codexPath,
115
119
  codex.content,
116
120
  providerPath,
117
121
  `${JSON.stringify(normalized, null, 2)}\n`,
118
- { mode: 0o600 },
122
+ { mode: 0o600, writeFile: atomicWriteFileIfChangedSync },
119
123
  );
120
124
  } catch (error) {
121
125
  skill.rollback();
@@ -134,7 +138,12 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
134
138
  const skill = updateImageSkill({ enabled: false, codexPath, adapterPort, adapterHost });
135
139
  try {
136
140
  if (fs.existsSync(codexPath)) {
137
- atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, { mode: 0o600 });
141
+ if (fileContent(codexPath) !== codexContent) {
142
+ throw new Error("Codex configuration changed during image cleanup; retry to preserve the newer settings");
143
+ }
144
+ atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, {
145
+ mode: 0o600, writeFile: atomicWriteFileIfChangedSync,
146
+ });
138
147
  } else if (providerExists) {
139
148
  fs.unlinkSync(providerPath);
140
149
  }
@@ -203,10 +212,11 @@ export async function runImageCommand({
203
212
  output.write(`${commandName} enable-image\n`);
204
213
  output.write("Enter an HTTPS base URL (for example, https://api.example/v1) or a full /images/generations endpoint.\n");
205
214
  const endpointAnswer = String(await ask(`API base URL or endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
206
- const endpoint = endpointAnswer || current?.endpoint;
207
- if (!endpoint) throw new Error("Image API base URL or endpoint is required");
208
- const keyAnswer = String(await askSecret(`API key${current ? " [Enter to keep current]" : ""}: `) || "").trim();
209
- const apiKey = keyAnswer || current?.api_key;
215
+ if (!endpointAnswer && !current?.endpoint) throw new Error("Image API base URL or endpoint is required");
216
+ const endpoint = normalizeImageEndpoint(endpointAnswer || current.endpoint);
217
+ const sameOrigin = current && new URL(endpoint).origin === new URL(current.endpoint).origin;
218
+ const keyAnswer = String(await askSecret(`API key${sameOrigin ? " [Enter to keep current]" : current ? " [new origin; enter a key]" : ""}: `) || "").trim();
219
+ const apiKey = keyAnswer || (sameOrigin ? current.api_key : undefined);
210
220
  if (!apiKey) throw new Error("Image API key is required");
211
221
 
212
222
  output.write("Checking image API...\n");
@@ -227,7 +237,9 @@ export async function runImageCommand({
227
237
  },
228
238
  providerPath,
229
239
  codexPath,
230
- codexContent,
240
+ // Prompts and provider validation may take minutes. Merge into the latest
241
+ // shared file rather than replacing edits made while we were waiting.
242
+ codexContent: fileContent(codexPath),
231
243
  adapterPort,
232
244
  adapterHost,
233
245
  });
@@ -302,7 +302,7 @@ function topicHelp(name, topic) {
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
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
- "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) 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.`,
305
+ "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) 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. A blank key preserves the saved key only for the same origin; a new origin requires explicit key entry. Concurrent unrelated config edits are preserved.`,
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.`,
308
308
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
@@ -158,6 +158,17 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
158
158
  if (imageHistory && typeof imageHistory === "object") {
159
159
  lines.push(status("info", `Visual history: ${count(imageHistory.active_recovery_trees)} recovery trees, ${count(imageHistory.adapted_requests)} adapted requests, ${count(imageHistory.historical_images_omitted)} older images omitted, ${count(imageHistory.timeouts_recorded)} timeouts`));
160
160
  }
161
+ if (data.image_generation) {
162
+ const generation = data.image_generation;
163
+ lines.push(status("info", `Image generation: ${count(generation.active)} active, ${count(generation.succeeded)} succeeded, ${count(generation.failed)} failed (${count(generation.busy)} busy), ${count(generation.cancelled)} cancelled, ${count(generation.delivery_failures)} delivery failures`));
164
+ }
165
+ if (data.logging) {
166
+ for (const [name, logging] of Object.entries(data.logging)) {
167
+ if (logging?.enabled === false) continue;
168
+ lines.push(status(logging.dropped_records || logging.write_failures ? "warn" : "info",
169
+ `${name === "usage" ? "Usage" : "Debug"} logging: ${count(logging.pending_records)} pending (${mebibytes(logging.pending_bytes)}), ${count(logging.dropped_records)} dropped, ${count(logging.write_failures)} write failures`));
170
+ }
171
+ }
161
172
  if (responseFailures && typeof responseFailures === "object") {
162
173
  lines.push(status("info", `Response failures: ${count(responseFailures.total)} observed, ${count(responseFailures.retried)} compatibility retries`));
163
174
  for (const failure of Array.isArray(responseFailures.recent) ? responseFailures.recent.slice(-3).reverse() : []) {
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
  }
@@ -0,0 +1,11 @@
1
+ // Diagnostics are not a copy of the upstream payload. Keep useful error text,
2
+ // but remove common credential forms and explicitly labelled content echoes.
3
+ export function redactDiagnosticText(value) {
4
+ return String(value ?? "")
5
+ .replace(/\bBearer\s+[^\s,;"'<>]+/gi, "Bearer [redacted]")
6
+ .replace(/\b(?:github_pat_|gh[pousr]_|sk-|copilot_)[A-Za-z0-9._-]+/gi, "[redacted]")
7
+ .replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie)["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi, "$1[redacted]")
8
+ .replace(/((?:prompt|input|completion|tool_arguments|arguments|content)["']?\s*[:=]\s*)[^\n]+/gi, "$1[redacted]")
9
+ .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
10
+ .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
11
+ }
@@ -28,3 +28,37 @@ export function rotateFileIfNeededSync(filePath, incomingBytes, maxBytes) {
28
28
  fs.renameSync(filePath, backupPath);
29
29
  return true;
30
30
  }
31
+
32
+ // Callers serialize writers (usage also holds its cross-process file lock).
33
+ // Group adjacent lines without changing the existing per-record rotation rule.
34
+ export async function appendRotatingLines(filePath, lines, maxBytes) {
35
+ let size = 0;
36
+ try {
37
+ const stat = await fs.promises.stat(filePath);
38
+ if (!stat.isFile()) throw new Error("Log destination is not a regular file");
39
+ size = stat.size;
40
+ }
41
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
42
+ let group = [];
43
+ let bytes = 0;
44
+ const flush = async () => {
45
+ if (!group.length) return;
46
+ await fs.promises.appendFile(filePath, group.join(""), { encoding: "utf8", mode: 0o600 });
47
+ group = [];
48
+ bytes = 0;
49
+ };
50
+ for (const line of lines) {
51
+ const length = Buffer.byteLength(line);
52
+ if (maxBytes > 0 && size > 0 && size + length > maxBytes) {
53
+ await flush();
54
+ await fs.promises.rm(rotatedFilePath(filePath), { force: true });
55
+ await fs.promises.rename(filePath, rotatedFilePath(filePath));
56
+ size = 0;
57
+ }
58
+ group.push(line);
59
+ size += length;
60
+ bytes += length;
61
+ if (bytes >= 256 * 1024) await flush();
62
+ }
63
+ await flush();
64
+ }
@@ -3,6 +3,7 @@ import * as zlib from "node:zlib";
3
3
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
4
4
  import { status } from "./status.mjs";
5
5
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
6
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
6
7
 
7
8
  const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
8
9
  const gunzipAsync = promisify(zlib.gunzip);
@@ -730,21 +731,29 @@ export function sendUpstreamError(res, response, text) {
730
731
  res.end(text || JSON.stringify({ error: "Upstream request failed" }));
731
732
  }
732
733
 
733
- export function writeOrDrain(res, chunk) {
734
+ export function writeOrDrain(res, chunk, { signal } = {}) {
734
735
  if (res.destroyed || res.writableEnded) return Promise.resolve(false);
736
+ if (signal?.aborted) {
737
+ res.destroy?.();
738
+ return Promise.resolve(false);
739
+ }
735
740
  if (res.write(chunk)) return Promise.resolve(true);
736
741
  return new Promise((resolve, reject) => {
737
742
  const cleanup = () => {
738
743
  res.off("drain", onDrain);
739
744
  res.off("error", onError);
740
745
  res.off("close", onClose);
746
+ signal?.removeEventListener("abort", onAbort);
741
747
  };
742
748
  const onDrain = () => { cleanup(); resolve(true); };
743
749
  const onClose = () => { cleanup(); resolve(false); };
744
750
  const onError = (err) => { cleanup(); reject(err); };
751
+ const onAbort = () => { cleanup(); res.destroy?.(); resolve(false); };
745
752
  res.once("drain", onDrain);
746
753
  res.once("close", onClose);
747
754
  res.once("error", onError);
755
+ signal?.addEventListener("abort", onAbort, { once: true });
756
+ if (signal?.aborted) onAbort();
748
757
  });
749
758
  }
750
759
 
@@ -800,7 +809,7 @@ export function createRequestAbort(req, res) {
800
809
 
801
810
  export function logRequestFailure(label, err, abort) {
802
811
  if (!isAbortLikeError(err)) {
803
- console.error(status("err", `${label} request failed: ${err.message}`));
812
+ console.error(status("err", `${label} request failed: ${redactDiagnosticText(err.message)}`));
804
813
  return;
805
814
  }
806
815
 
package/src/image-mcp.mjs CHANGED
@@ -138,7 +138,8 @@ export function createImageMcpHandler({
138
138
  imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
139
139
  } = {}) {
140
140
  let active = 0;
141
- return async function imageMcpHandler(req, res) {
141
+ const metrics = { total: 0, succeeded: 0, failed: 0, busy: 0, cancelled: 0, delivery_failures: 0 };
142
+ const handler = async function imageMcpHandler(req, res) {
142
143
  if (!isLoopbackAddress(req.socket?.remoteAddress) && !sameHostSocket(req.socket)) {
143
144
  writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
144
145
  return;
@@ -207,18 +208,23 @@ export function createImageMcpHandler({
207
208
  writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
208
209
  return;
209
210
  }
211
+ metrics.total += 1;
210
212
  const config = configLoader();
211
213
  if (!config) {
214
+ metrics.failed += 1;
212
215
  imageReferences.clear();
213
216
  reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
214
217
  return;
215
218
  }
216
219
  const editing = request.params.name === EDIT_TOOL_NAME;
217
220
  if (editing && !supportsImageEditing(config)) {
221
+ metrics.failed += 1;
218
222
  reply({ content: [{ type: "text", text: "The configured image provider does not support CCDX editing. No edit was submitted." }], isError: true });
219
223
  return;
220
224
  }
221
225
  if (active >= maxConcurrent) {
226
+ metrics.busy += 1;
227
+ metrics.failed += 1;
222
228
  reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
223
229
  return;
224
230
  }
@@ -234,6 +240,7 @@ export function createImageMcpHandler({
234
240
  const parameters = { prompt: args.prompt, size: args.size ?? source?.size ?? "1024x1024" };
235
241
  if (source) parameters.image = source.image;
236
242
  const image = await generateImageFn(config, parameters, { signal: abort.signal });
243
+ abort.signal.throwIfAborted();
237
244
  let imageId = null;
238
245
  if (supportsImageEditing(config)) {
239
246
  try { imageId = imageReferences.remember(config, { ...image, size: parameters.size }); } catch {}
@@ -250,9 +257,12 @@ export function createImageMcpHandler({
250
257
  saved = await saveChatImage(image, imageDirectory, abort.signal);
251
258
  delivery = `\nInclude this image Markdown in your final chat reply to show its thumbnail:\n${saved.markdown}\nTo display this same image again, reuse this Markdown; do not call generate_image or edit_image.`;
252
259
  } catch {
260
+ if (abort.signal.aborted) throw abort.signal.reason;
261
+ metrics.delivery_failures += 1;
253
262
  delivery = "\nThe image was generated, but its chat-preview file could not be saved. Image content is still returned. Do not claim it was displayed, invent a file path, or generate/edit again to retry delivery.";
254
263
  }
255
264
  }
265
+ abort.signal.throwIfAborted();
256
266
  reply({
257
267
  // Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
258
268
  ...(imageId || saved ? { _meta: { ...(imageId ? { "ccdx/image_id": imageId } : {}), ...(saved ? { "ccdx/image_path": saved.filePath } : {}) } } : {}),
@@ -261,7 +271,10 @@ export function createImageMcpHandler({
261
271
  { type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}${delivery}` },
262
272
  ],
263
273
  });
274
+ metrics.succeeded += 1;
264
275
  } catch (error) {
276
+ if (abort.signal.aborted) metrics.cancelled += 1;
277
+ else metrics.failed += 1;
265
278
  if (!res.destroyed && !res.writableEnded) {
266
279
  reply({ content: [{ type: "text", text: safeToolError(error, config.api_key) }], isError: true });
267
280
  }
@@ -271,4 +284,7 @@ export function createImageMcpHandler({
271
284
  res.off?.("close", cancel);
272
285
  }
273
286
  };
287
+ // Status never reads credentials, loads image guidance, or probes a provider.
288
+ handler.stats = () => ({ ...metrics, active, max_concurrent: maxConcurrent });
289
+ return handler;
274
290
  }
@@ -179,11 +179,16 @@ function publicAddress(address) {
179
179
  const family = net.isIP(address);
180
180
  if (family === 4) return ipv4Public(address);
181
181
  if (family !== 6) return false;
182
- const lower = address.toLowerCase();
182
+ let lower;
183
+ try { lower = new URL(`https://[${address}]/`).hostname.slice(1, -1); }
184
+ catch { return false; }
183
185
  if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
184
186
  || /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
185
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
186
- return mapped ? ipv4Public(mapped[1]) : true;
187
+ const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower);
188
+ if (!mapped) return true;
189
+ const high = Number.parseInt(mapped[1], 16);
190
+ const low = Number.parseInt(mapped[2], 16);
191
+ return ipv4Public([high >> 8, high & 255, low >> 8, low & 255].join("."));
187
192
  }
188
193
 
189
194
  export async function downloadPublicImage(urlValue, {
@@ -217,7 +222,8 @@ export async function downloadPublicImage(urlValue, {
217
222
  if (signal?.aborted) { onAbort(); return; }
218
223
  signal?.addEventListener("abort", onAbort, { once: true });
219
224
  // 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) => {
225
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
226
+ Promise.resolve().then(() => settled ? undefined : lookup(hostname, { all: true, verbatim: true })).then((addresses) => {
221
227
  if (settled) return;
222
228
  if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
223
229
  finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
package/src/log.mjs CHANGED
@@ -3,10 +3,17 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { format } from "node:util";
5
5
  import { status } from "./status.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit } from "./file-rotation.mjs";
7
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
7
8
 
8
9
  let globalInstall = null;
9
10
  const DEFAULT_LOG_MAX_BYTES = 16 * 1024 * 1024;
11
+ const LOG_QUEUE_MAX_BYTES = 2 * 1024 * 1024;
12
+ const LOG_QUEUE_MAX_RECORDS = 4096;
13
+
14
+ export function debugLoggingStats() {
15
+ return globalInstall?.stats() || { enabled: false };
16
+ }
10
17
 
11
18
  export function normalizeLogLevel(value) {
12
19
  return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
@@ -35,7 +42,7 @@ export function debugLogMaxBytes(env = process.env) {
35
42
 
36
43
  function renderLogPayload(args) {
37
44
  const stamp = new Date().toISOString();
38
- const text = format(...args);
45
+ const text = redactDiagnosticText(format(...args));
39
46
  return `${text.split(/\r?\n/).map((line) => `${stamp} ${line}`).join("\n")}\n`;
40
47
  }
41
48
 
@@ -43,12 +50,13 @@ export function configureLogging({
43
50
  env = process.env,
44
51
  consoleObj = console,
45
52
  home = os.homedir(),
53
+ appendLines = appendRotatingLines,
46
54
  } = {}) {
47
55
  const level = normalizeLogLevel(env.CCDX_LOG_LEVEL);
48
56
  const filePath = resolveLogPath(env.CCDX_LOG_PATH, { home });
49
57
  if (!filePath) return { level, filePath: null, cleanup: () => {} };
50
58
  if (consoleObj === console && globalInstall) {
51
- return { level, filePath: globalInstall.filePath, cleanup: globalInstall.cleanup };
59
+ return { level, ...globalInstall };
52
60
  }
53
61
 
54
62
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -59,16 +67,52 @@ export function configureLogging({
59
67
  typeof consoleObj[method] === "function" ? consoleObj[method].bind(consoleObj) : () => {},
60
68
  ]));
61
69
  let writeFailed = false;
70
+ const pending = [];
71
+ let drain = null;
72
+ let pendingBytes = 0;
73
+ let pendingRecords = 0;
74
+ let droppedRecords = 0;
75
+ let writeFailures = 0;
76
+ const stats = () => ({ enabled: true, pending_bytes: pendingBytes, pending_records: pendingRecords,
77
+ dropped_records: droppedRecords, write_failures: writeFailures,
78
+ max_bytes: LOG_QUEUE_MAX_BYTES, max_records: LOG_QUEUE_MAX_RECORDS });
79
+
80
+ function scheduleDrain() {
81
+ if (drain) return;
82
+ drain = Promise.resolve().then(async () => {
83
+ while (pending.length) {
84
+ const batch = pending.splice(0);
85
+ try {
86
+ await appendLines(filePath, batch.map(({ payload }) => payload), debugLogMaxBytes(env));
87
+ } catch (error) {
88
+ writeFailures += 1;
89
+ if (!writeFailed) originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(error.message)}`);
90
+ writeFailed = true;
91
+ } finally {
92
+ pendingRecords -= batch.length;
93
+ pendingBytes -= batch.reduce((sum, entry) => sum + entry.bytes, 0);
94
+ }
95
+ }
96
+ }).finally(() => { drain = null; if (pending.length) scheduleDrain(); });
97
+ }
62
98
 
63
99
  function append(args) {
64
100
  try {
65
101
  const payload = renderLogPayload(args);
66
- rotateFileIfNeededSync(filePath, Buffer.byteLength(payload), debugLogMaxBytes(env));
67
- fs.appendFileSync(filePath, payload, { encoding: "utf8", mode: 0o600 });
102
+ const bytes = Buffer.byteLength(payload);
103
+ if (pendingRecords >= LOG_QUEUE_MAX_RECORDS || pendingBytes + bytes > LOG_QUEUE_MAX_BYTES) {
104
+ droppedRecords += 1;
105
+ if (droppedRecords === 1) originals.error("codex-copilot-dx log queue is full; excess file records are dropped, terminal output is unchanged (see ccdx status)");
106
+ return;
107
+ }
108
+ pending.push({ payload, bytes });
109
+ pendingRecords += 1;
110
+ pendingBytes += bytes;
111
+ scheduleDrain();
68
112
  } catch (e) {
69
113
  if (writeFailed) return;
70
114
  writeFailed = true;
71
- originals.error(`codex-copilot-dx log write failed: ${e.message}`);
115
+ originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(e.message)}`);
72
116
  }
73
117
  }
74
118
 
@@ -79,13 +123,23 @@ export function configureLogging({
79
123
  };
80
124
  }
81
125
 
126
+ const flush = async ({ timeoutMs = 1500 } = {}) => {
127
+ let timer;
128
+ const completed = await Promise.race([
129
+ (async () => { while (drain) await drain; return true; })(),
130
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
131
+ ]);
132
+ clearTimeout(timer);
133
+ if (!completed) originals.error(`codex-copilot-dx log flush timed out after ${timeoutMs}ms`);
134
+ };
82
135
  const cleanup = () => {
83
136
  for (const method of methods) consoleObj[method] = originals[method];
84
137
  if (consoleObj === console && globalInstall?.cleanup === cleanup) globalInstall = null;
138
+ return flush();
85
139
  };
86
140
 
87
- if (consoleObj === console) globalInstall = { filePath, cleanup };
88
- return { level, filePath, cleanup };
141
+ if (consoleObj === console) globalInstall = { filePath, cleanup, flush, stats };
142
+ return { level, filePath, cleanup, flush, stats };
89
143
  }
90
144
 
91
145
  export function debugLog(message, env = process.env) {
@@ -5,6 +5,8 @@ import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
7
  import { profileRouting } from "./profile-routing.mjs";
8
+ import { usageLoggingStats } from "./usage.mjs";
9
+ import { debugLoggingStats } from "./log.mjs";
8
10
 
9
11
  export { ADAPTER_STATUS_PATH };
10
12
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
@@ -13,6 +15,7 @@ const ROUTE_NAMES = Object.freeze([
13
15
  "responses",
14
16
  "responses_compact",
15
17
  "models",
18
+ "image_mcp",
16
19
  "not_found",
17
20
  ]);
18
21
 
@@ -94,6 +97,7 @@ export function classifyAdapterRoute(method, pathname) {
94
97
  if (method === "POST" && pathname === "/v1/responses") return "responses";
95
98
  if (method === "POST" && pathname === "/v1/responses/compact") return "responses_compact";
96
99
  if (method === "GET" && pathname === "/v1/models") return "models";
100
+ if (pathname === "/mcp/image") return "image_mcp";
97
101
  return "not_found";
98
102
  }
99
103
 
@@ -155,6 +159,7 @@ export function runtimeStatusPayload({
155
159
  admission,
156
160
  imagePressure,
157
161
  responseFailures,
162
+ imageGeneration,
158
163
  modelRegistry,
159
164
  codexClient,
160
165
  codexModelRegistry,
@@ -177,6 +182,8 @@ export function runtimeStatusPayload({
177
182
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
178
183
  response_history: responseHistoryStats(),
179
184
  image_optimization: imageOptimizationStats(),
185
+ image_generation: imageGeneration || null,
186
+ logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
180
187
  image_history_pressure: imagePressure?.snapshot?.() || null,
181
188
  response_failures: responseFailures?.snapshot?.() || null,
182
189
  copilot: codexRuntime,
@@ -1,17 +1,16 @@
1
1
  import { terminalCell } from "./cli-table.mjs";
2
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
2
3
 
3
4
  const DEFAULT_RECENT_FAILURES = 10;
4
5
 
5
6
  function safeIdentifier(value, fallback = "unknown") {
6
- const text = terminalCell(value, { fallback });
7
+ const text = redactDiagnosticText(terminalCell(value, { fallback }));
7
8
  return text.slice(0, 200);
8
9
  }
9
10
 
10
11
  function safeFailureMessage(value) {
11
12
  let text = terminalCell(value, { fallback: "Response failed without an error message" });
12
- text = text
13
- .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
14
- .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
13
+ text = redactDiagnosticText(text);
15
14
  return text.slice(0, 500);
16
15
  }
17
16
 
@@ -319,7 +319,7 @@ export function createResponsesHandler(options) {
319
319
  "Cache-Control": "no-cache",
320
320
  Connection: "keep-alive",
321
321
  });
322
- const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
322
+ const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { signal: abort.signal });
323
323
  if (!written) return false;
324
324
  if (event === "response.completed") {
325
325
  rememberResponseHistory(prepared, data.response);
@@ -403,13 +403,13 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
403
403
  const body = prelude.length === 1 ? prelude[0] : Buffer.concat(prelude, preludeBytes);
404
404
  prelude.length = 0;
405
405
  preludeBytes = 0;
406
- return writeOrDrain(res, body);
406
+ return writeOrDrain(res, body, { signal: options.signal });
407
407
  };
408
408
  const forwardFrame = async (frame) => {
409
409
  if (!frame) return true;
410
410
  if (!holdPrelude) {
411
411
  writeHeaders();
412
- return writeOrDrain(res, frame);
412
+ return writeOrDrain(res, frame, { signal: options.signal });
413
413
  }
414
414
  prelude.push(frame);
415
415
  preludeBytes += frame.byteLength;
@@ -11,6 +11,10 @@ export async function endStreamWithError(res, error, abort) {
11
11
  markStreamFailure();
12
12
  if (res.destroyed || res.writableEnded) return;
13
13
  const data = streamErrorData(error, abort);
14
- await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
14
+ // Allow a final error frame after an upstream timeout, but never wait forever
15
+ // for a client that has stopped reading (including protocol-error paths).
16
+ await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`, {
17
+ signal: AbortSignal.timeout(1000),
18
+ }).catch(() => false);
15
19
  if (!res.destroyed && !res.writableEnded) res.end();
16
20
  }
package/src/usage.mjs CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell } from "./cli-table.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit, rotatedFilePath } from "./file-rotation.mjs";
7
7
  import { withFileLock } from "./lock.mjs";
8
8
 
9
9
  const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
@@ -15,6 +15,17 @@ const USAGE_FLUSH_TIMEOUT_MS = 1_500;
15
15
 
16
16
  const pendingWrites = [];
17
17
  let writeDrain = null;
18
+ const USAGE_QUEUE_MAX_RECORDS = 4096;
19
+ const USAGE_QUEUE_MAX_BYTES = 4 * 1024 * 1024;
20
+ let pendingRecords = 0;
21
+ let pendingBytes = 0;
22
+ let droppedRecords = 0;
23
+ let writeFailures = 0;
24
+
25
+ export function usageLoggingStats() {
26
+ return { pending_records: pendingRecords, pending_bytes: pendingBytes, dropped_records: droppedRecords,
27
+ write_failures: writeFailures, max_records: USAGE_QUEUE_MAX_RECORDS, max_bytes: USAGE_QUEUE_MAX_BYTES };
28
+ }
18
29
 
19
30
  export function usageLogPath() {
20
31
  return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
@@ -114,10 +125,7 @@ async function writeUsageBatch(batch) {
114
125
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
115
126
  await withFileLock(`${path.resolve(filePath)}.lock`, async () => {
116
127
  batch.push(...takePendingWrites(filePath, maxBytes));
117
- for (const { line } of batch) {
118
- rotateFileIfNeededSync(filePath, Buffer.byteLength(line), maxBytes);
119
- await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
120
- }
128
+ await appendRotatingLines(filePath, batch.map(({ line }) => line), maxBytes);
121
129
  }, {
122
130
  timeoutMs: USAGE_WRITE_LOCK_TIMEOUT_MS,
123
131
  staleMs: USAGE_WRITE_LOCK_STALE_MS,
@@ -134,10 +142,15 @@ function scheduleUsageWriteDrain() {
134
142
  try {
135
143
  await writeUsageBatch(batch);
136
144
  } catch (error) {
145
+ writeFailures += 1;
137
146
  batch.push(...takePendingWrites(first.filePath, first.maxBytes));
138
147
  console.error(`codex-copilot-dx usage log write failed: ${error.message}`);
139
148
  } finally {
140
- for (const entry of batch) entry.resolve();
149
+ for (const entry of batch) {
150
+ pendingRecords -= 1;
151
+ pendingBytes -= entry.bytes;
152
+ entry.resolve();
153
+ }
141
154
  }
142
155
  }
143
156
  }).finally(() => {
@@ -150,8 +163,16 @@ export function recordUsage(record) {
150
163
  if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
151
164
  const filePath = usageLogPath();
152
165
  const line = `${JSON.stringify(record)}\n`;
166
+ const bytes = Buffer.byteLength(line);
167
+ if (pendingRecords >= USAGE_QUEUE_MAX_RECORDS || pendingBytes + bytes > USAGE_QUEUE_MAX_BYTES) {
168
+ droppedRecords += 1;
169
+ if (droppedRecords === 1) console.error("codex-copilot-dx usage queue is full; excess usage records are dropped, inference is unaffected (see ccdx status)");
170
+ return Promise.resolve();
171
+ }
172
+ pendingRecords += 1;
173
+ pendingBytes += bytes;
153
174
  const completed = new Promise((resolve) => {
154
- pendingWrites.push({ filePath, line, maxBytes: usageLogMaxBytes(), resolve });
175
+ pendingWrites.push({ filePath, line, bytes, maxBytes: usageLogMaxBytes(), resolve });
155
176
  });
156
177
  scheduleUsageWriteDrain();
157
178
  return completed;