@minhspark/codex-mcp-bridge 1.12.2 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.12.3] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - Discover the native tools pipe from the launching Desktop app-server's `mcp_servers.codex_app.env` override when Desktop does not pass it to custom MCP servers. Explicit pipe settings keep precedence; discovery rejects unrelated processes, ambiguous settings, and remote Windows pipes without scanning other sessions or persisting a restart-specific address.
10
+ - Cancel pending pipe discovery when the client closes, and share discovery between concurrent connection requests so a closed client cannot send a delayed message.
11
+ - Report a reachable shared companion in native relay status when another MCP instance owns the listening endpoint.
12
+
13
+ ### Tests
14
+
15
+ - Added Windows/POSIX command-line parsing, parent discovery, environment precedence, real mock-pipe delivery, cancellation, concurrent connection, and shared-companion regressions.
16
+
5
17
  ## [1.12.2] - 2026-09-05
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -424,7 +424,7 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
424
424
 
425
425
  A companion that cannot be reached before sending falls back to the app-server path. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
426
426
 
427
- Codex Desktop supplies `CODEX_APP_TOOLS_PIPE_PATH` to its companion. The companion keeps that native connection separate from MCP stdio. Without the inherited native pipe it does not advertise a working relay endpoint; if a configured pipe is late during startup, it retries in the background.
427
+ The companion uses `CODEX_APP_TOOLS_PIPE_PATH` when inherited. Desktop builds that supply it only to their bundled `codex_app` MCP are also supported: the companion reads the exact `mcp_servers.codex_app.env` override from its launching app-server. It never picks a pipe from another session or saves a restart-specific address. The native connection remains separate from MCP stdio. If neither source provides a valid pipe, the relay stays unavailable; if a configured pipe is late during startup, it retries in the background. When several MCP instances start, status identifies a reachable shared companion instead of reporting that the relay is down.
428
428
 
429
429
  > ⚠️ `codex_app.send_message_to_thread` and the native tools pipe are **Codex Desktop internals with no public documentation**, on the same footing as the Claude peer protocol above. That is why the relay is Windows/macOS, feature-detected, optional and fallback-safe. If Codex changes it, the two places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`; `CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release. The request the companion sends is:
430
430
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.2",
3
+ "version": "1.12.3",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -8,7 +8,7 @@ import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
8
  import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
9
9
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
10
10
 
11
- const VERSION = "1.12.2";
11
+ const VERSION = "1.12.3";
12
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
13
  const entry = path.join(root, "src", "native-relay-companion.mjs");
14
14
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
8
8
  import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
 
11
- const VERSION = "1.12.2";
11
+ const VERSION = "1.12.3";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
package/src/index.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import { runTurn } from "./turn.mjs";
24
24
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
25
25
 
26
- const VERSION = "1.12.2";
26
+ const VERSION = "1.12.3";
27
27
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
28
28
 
29
29
  /**
@@ -17,7 +17,7 @@ import {
17
17
  } from "./native-relay.mjs";
18
18
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
19
19
 
20
- const VERSION = "1.12.2";
20
+ const VERSION = "1.12.3";
21
21
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
22
22
 
23
23
  function errorResponse(code, message) {
@@ -201,6 +201,10 @@ export class RelaySocketServer {
201
201
  });
202
202
  }
203
203
 
204
+ async isListening() {
205
+ return this.started || this.#socketIsLive();
206
+ }
207
+
204
208
  #handleConnection(socket) {
205
209
  let buffer = Buffer.alloc(0);
206
210
  let handled = false;
@@ -350,6 +354,7 @@ if (invokedDirectly) {
350
354
  },
351
355
  },
352
356
  async () => {
357
+ const listening = await relay.isListening();
353
358
  let executor = "(unconfigured)";
354
359
  try {
355
360
  const resolved = resolveRelayThreadId();
@@ -364,7 +369,7 @@ if (invokedDirectly) {
364
369
  text: [
365
370
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
366
371
  `companion: codex-native-relay ${VERSION}`,
367
- `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
372
+ `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
368
373
  `executor: ${executor}`,
369
374
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
370
375
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
7
9
 
@@ -139,14 +141,168 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
139
141
  };
140
142
  }
141
143
 
144
+ const execFileAsync = promisify(execFile);
145
+
146
+ function splitDesktopCommandLine(commandLine, platform) {
147
+ const args = [];
148
+ let value = "";
149
+ let quote = null;
150
+ let depth = 0;
151
+ for (let index = 0; index < commandLine.length; index++) {
152
+ const char = commandLine[index];
153
+ if (platform === "win32" && char === "\\") {
154
+ let end = index;
155
+ while (commandLine[end] === "\\") end++;
156
+ const count = end - index;
157
+ if (commandLine[end] === '"') {
158
+ value += "\\".repeat(Math.floor(count / 2));
159
+ if (count % 2) value += '"';
160
+ else quote = quote ? null : '"';
161
+ index = end;
162
+ } else {
163
+ value += "\\".repeat(count);
164
+ index = end - 1;
165
+ }
166
+ continue;
167
+ }
168
+ if (platform !== "win32" && depth > 0) {
169
+ value += char;
170
+ if (quote) {
171
+ if (char === "\\" && quote === '"') value += commandLine[++index] ?? "";
172
+ else if (char === quote) quote = null;
173
+ } else if (char === '"' || char === "'") quote = char;
174
+ else if (char === "{" || char === "[") depth++;
175
+ else if (char === "}" || char === "]") depth--;
176
+ continue;
177
+ }
178
+ if (char === '"' || (platform !== "win32" && char === "'")) {
179
+ if (!quote) quote = char;
180
+ else if (quote === char) quote = null;
181
+ else value += char;
182
+ } else if (!quote && /\s/.test(char)) {
183
+ if (value) args.push(value);
184
+ value = "";
185
+ } else {
186
+ value += char;
187
+ if (platform !== "win32" && !quote && char === "{") depth++;
188
+ }
189
+ }
190
+ if (quote || depth) return [];
191
+ if (value) args.push(value);
192
+ return args;
193
+ }
194
+
195
+ function inlineTableValues(table) {
196
+ const text = table.trim();
197
+ if (!text.startsWith("{") || !text.endsWith("}")) return null;
198
+ const entries = [];
199
+ let start = 1;
200
+ let quote = null;
201
+ let depth = 0;
202
+ for (let index = 1; index < text.length - 1; index++) {
203
+ const char = text[index];
204
+ if (quote) {
205
+ if (char === "\\" && quote === '"') index++;
206
+ else if (char === quote) quote = null;
207
+ } else if (char === '"' || char === "'") quote = char;
208
+ else if (char === "{" || char === "[") depth++;
209
+ else if (char === "}" || char === "]") {
210
+ if (--depth < 0) return null;
211
+ } else if (char === "," && depth === 0) {
212
+ entries.push(text.slice(start, index));
213
+ start = index + 1;
214
+ }
215
+ }
216
+ if (quote || depth) return null;
217
+ entries.push(text.slice(start, -1));
218
+ const values = new Map();
219
+ for (const entry of entries) {
220
+ if (!entry.trim()) continue;
221
+ const match = entry.match(/^\s*(?:"([A-Za-z_][A-Za-z0-9_-]*)"|'([A-Za-z_][A-Za-z0-9_-]*)'|([A-Za-z_][A-Za-z0-9_-]*))\s*=\s*([\s\S]+)$/);
222
+ if (!match) return null;
223
+ const key = match[1] ?? match[2] ?? match[3];
224
+ if (values.has(key)) return null;
225
+ values.set(key, match[4].trim());
226
+ }
227
+ return values;
228
+ }
229
+
230
+ export function nativeToolsPipeFromCommandLine(commandLine, { platform = process.platform } = {}) {
231
+ if (typeof commandLine !== "string" || /[\r\n\0]/.test(commandLine)) return null;
232
+ const args = splitDesktopCommandLine(commandLine, platform);
233
+ const paths = platform === "win32" ? path.win32 : path.posix;
234
+ if (!/^codex(?:\.exe)?$/i.test(paths.basename(args[0] ?? ""))) return null;
235
+ const overrides = [];
236
+ let appServer = false;
237
+ for (let index = 1; index < args.length; index++) {
238
+ const arg = args[index];
239
+ if (arg === "-c" || arg === "--config") {
240
+ overrides.push(args[++index] ?? "");
241
+ } else if (arg.startsWith("--config=")) overrides.push(arg.slice(9));
242
+ else if (arg === "app-server") appServer = true;
243
+ else if (!arg.startsWith("-") && !appServer) return null;
244
+ }
245
+ if (!appServer) return null;
246
+ const candidates = [];
247
+ for (const override of overrides) {
248
+ const match = override.match(/^mcp_servers\.codex_app\s*=\s*([\s\S]+)$/);
249
+ if (!match) continue;
250
+ const config = inlineTableValues(match[1]);
251
+ if (!config) return null;
252
+ const env = inlineTableValues(config.get("env") ?? "{}");
253
+ if (!env) return null;
254
+ const raw = env.get("CODEX_APP_TOOLS_PIPE_PATH");
255
+ if (raw === undefined) continue;
256
+ let candidate;
257
+ try {
258
+ candidate = raw.startsWith('"') ? JSON.parse(raw) : /^'[^']*'$/.test(raw) ? raw.slice(1, -1) : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ if (typeof candidate !== "string" || /[\r\n\0]/.test(candidate)) return null;
263
+ if (platform === "win32" ? !/^\\\\\.\\pipe\\[^\\]/i.test(candidate) : !path.posix.isAbsolute(candidate)) return null;
264
+ candidates.push(candidate);
265
+ }
266
+ return candidates.length && new Set(candidates).size === 1 ? candidates[0] : null;
267
+ }
268
+
269
+ async function readParentCommandLine(parentPid, platform) {
270
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
271
+ const options = { timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true };
272
+ if (platform === "win32") {
273
+ const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
274
+ const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${parentPid}'; if ($p.Name -eq 'codex.exe') { $p.CommandLine | ConvertTo-Json -Compress }`;
275
+ const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], options);
276
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
277
+ }
278
+ const { stdout } = await execFileAsync("/bin/ps", ["-ww", "-p", String(parentPid), "-o", "args="], options);
279
+ return stdout.trim();
280
+ }
281
+
282
+ export async function resolveNativeToolsPipePath({
283
+ env = process.env,
284
+ parentPid = process.ppid,
285
+ platform = process.platform,
286
+ readParentCommandLine: readParent = readParentCommandLine,
287
+ } = {}) {
288
+ if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
289
+ try {
290
+ return nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
142
296
  export class NativeToolsClient {
143
- constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
297
+ constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }) } = {}) {
144
298
  this.env = env;
145
299
  this.socketPath = socketPath;
146
300
  this.timeoutMs = timeoutMs;
301
+ this.resolveSocketPath = resolveSocketPath;
147
302
  this.socket = null;
148
303
  this.connectingSocket = null;
149
304
  this.connecting = null;
305
+ this.connectionGeneration = 0;
150
306
  this.pending = new Map();
151
307
  this.nextId = 1;
152
308
  }
@@ -154,10 +310,17 @@ export class NativeToolsClient {
154
310
  async connect() {
155
311
  if (this.connecting) return this.connecting;
156
312
  if (this.socket && !this.socket.destroyed) return;
157
- if (!this.socketPath) {
158
- throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
159
- }
160
- this.connecting = new Promise((resolve, reject) => {
313
+ const generation = this.connectionGeneration;
314
+ this.connecting = (async () => {
315
+ const socketPath = this.socketPath || await this.resolveSocketPath();
316
+ if (generation !== this.connectionGeneration) {
317
+ throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
318
+ }
319
+ this.socketPath = socketPath;
320
+ if (!this.socketPath) {
321
+ throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing from the environment and parent Desktop app-server configuration; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
322
+ }
323
+ return new Promise((resolve, reject) => {
161
324
  const socket = net.connect({ path: this.socketPath });
162
325
  this.connectingSocket = socket;
163
326
  let buffer = Buffer.alloc(0);
@@ -215,7 +378,8 @@ export class NativeToolsClient {
215
378
  });
216
379
  socket.on("error", (err) => fail(new NativeRelayError(`Codex Desktop native pipe failed: ${err.message}`, connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
217
380
  socket.on("close", () => fail(new NativeRelayError("Codex Desktop native tools pipe closed before confirming delivery", connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
218
- });
381
+ });
382
+ })();
219
383
  try {
220
384
  await this.connecting;
221
385
  } finally {
@@ -264,6 +428,7 @@ export class NativeToolsClient {
264
428
  }
265
429
 
266
430
  close() {
431
+ this.connectionGeneration++;
267
432
  const socket = this.socket;
268
433
  this.socket = null;
269
434
  for (const pending of this.pending.values()) {