@minhspark/codex-mcp-bridge 1.22.18 → 1.22.20

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.18",
3
+ "version": "1.22.20",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.20] - 2026-10-24
8
+
9
+ ### Fixed
10
+
11
+ - A Claude Desktop target no longer stays blocked for good when another sender's peer message lands in a reply chain that is waiting on a background task. Reply observation used to drop that message and everything after it, so the delivery never resolved, every later send to that session failed with `PEER_REPLY_PENDING`, and reload stayed refused until Codex Desktop restarted. A peer message for a different message id now ends observation the same way human input does: the delivery is reported as interrupted, the task outcome stays unknown, and it is not marked safe to retry.
12
+ - Peer interruption requires a nonblank string message id distinct from the tracked request. Missing, blank, non-string and matching ids cannot release pending ownership. Existing transcript ancestry, ambiguity and open-branch checks remain in force.
13
+
14
+ ### Tests
15
+
16
+ - Transcript regressions cover a background wait interrupted by another peer, malformed peer ids, unrelated ancestry, incomplete or duplicate records, and unresolved sibling branches.
17
+ - Endpoint regressions verify late interruption after a timeout, pending-send and reload recovery, retained interruption receipts across reload, and a separately requested follow-up without resending the original action.
18
+
19
+ ### Limitations
20
+
21
+ - Interruption ends reply observation only. Background work may still be running; inspect the existing task before issuing a follow-up. The original action is never marked safe to retry.
22
+
23
+ ## [1.22.19] - 2026-10-22
24
+
25
+ ### Fixed
26
+
27
+ - Relay cleanup validates the canonical parent, expected file type, ownership, link count and identity before removing a configuration file or Unix socket. A regular file selected through a socket path is preserved, and symlinks and hardlinks are rejected.
28
+ - Unix relay sockets are created with mode `0600` before accepting connections. Startup verifies the socket identity and permissions without changing permissions through an environment-derived path, and restores the process umask after binding.
29
+ - Supervisor timer intervals use explicit lower and upper bounds, including Node's timer overflow limit, so CodeQL can verify the resource limits.
30
+
31
+ ### Tests
32
+
33
+ - Regression tests cover ordinary files, hardlinks, symlinks, custom Unix sockets, private socket creation, umask restoration, replaced socket identities and timer boundaries.
34
+
7
35
  ## [1.22.18] - 2026-10-22
8
36
 
9
37
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.18",
3
+ "version": "1.22.20",
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",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.18",
4
+ "version": "1.22.20",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -21,10 +21,11 @@ import { applyStdioMcpRegistration, stdioMcpRegistration } from "../src/codex-mc
21
21
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
22
22
  import { codexAccessPaths, enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
23
23
  import { stableInstallPath } from "../src/install-paths.mjs";
24
+ import { removeRelayFile } from "../src/native-relay-files.mjs";
24
25
 
25
26
  exitForVersionRequest(import.meta.url);
26
27
 
27
- const VERSION = "1.22.18";
28
+ const VERSION = "1.22.20";
28
29
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
29
30
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
30
31
  const entryArgs = ["native-relay-companion.mjs"];
@@ -50,8 +51,7 @@ if (remove) {
50
51
  const retiredExecutor = readRelayConfig()?.relayThreadId;
51
52
  const sockets = IS_WINDOWS ? [] : [...new Set([relaySocketPath(), desktopTaskSocketPath(), accountRelaySocketPath()])];
52
53
  for (const leftover of [relayConfigPath(), ...sockets]) {
53
- if (fs.existsSync(leftover)) {
54
- fs.rmSync(leftover, { force: true });
54
+ if (removeRelayFile(leftover, { kind: leftover === relayConfigPath() ? "config" : "socket" })) {
55
55
  console.log(`removed ${leftover}`);
56
56
  }
57
57
  }
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.18";
33
+ const VERSION = "1.22.20";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
package/src/index.mjs CHANGED
@@ -41,7 +41,7 @@ import { createRelayWake, relayKeeperEnabled, relayWakeStateFile, startRelayKeep
41
41
 
42
42
  exitForVersionRequest(import.meta.url);
43
43
 
44
- const VERSION = "1.22.18";
44
+ const VERSION = "1.22.20";
45
45
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
46
46
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
47
47
 
@@ -18,6 +18,14 @@ const hasId = (message) => Object.hasOwn(message, "id");
18
18
  const idKey = (id) => JSON.stringify(id);
19
19
  const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
20
20
 
21
+ export function supervisorInterval(value, fallback) {
22
+ const parsed = Number(value);
23
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
24
+ if (parsed < 100) return 100;
25
+ if (parsed > 2147483647) return 2147483647;
26
+ return parsed;
27
+ }
28
+
21
29
  function lines(stream, receive, failed) {
22
30
  let buffer = Buffer.alloc(0);
23
31
  stream.on("data", (chunk) => {
@@ -156,13 +164,9 @@ export async function runSupervisor(entry, options = {}) {
156
164
  const pipe = await resolveNativeToolsPipePath();
157
165
  if (pipe) env.CODEX_APP_TOOLS_PIPE_PATH = pipe;
158
166
  }
159
- const interval = (value, fallback) => {
160
- const parsed = Number(value);
161
- return Number.isFinite(parsed) && parsed > 0 ? Math.max(100, Math.min(2147483647, parsed)) : fallback;
162
- };
163
- const pollMs = interval(env.CODEX_BRIDGE_RELOAD_POLL_MS, 1000);
164
- const settleMs = interval(env.CODEX_BRIDGE_RELOAD_SETTLE_MS, 1500);
165
- const pruneDelayMs = interval(env.CODEX_BRIDGE_RUNTIME_PRUNE_DELAY_MS, 60000);
167
+ const pollMs = supervisorInterval(env.CODEX_BRIDGE_RELOAD_POLL_MS, 1000);
168
+ const settleMs = supervisorInterval(env.CODEX_BRIDGE_RELOAD_SETTLE_MS, 1500);
169
+ const pruneDelayMs = supervisorInterval(env.CODEX_BRIDGE_RUNTIME_PRUNE_DELAY_MS, 60000);
166
170
  let active;
167
171
  let initializeParams;
168
172
  let initializeResult;
@@ -34,12 +34,13 @@ import { createRuntimeState } from "./runtime-state.mjs";
34
34
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
35
35
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
36
36
  import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
37
+ import { removeRelayFile, relayFileIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
37
38
 
38
39
  import { NativeCreationReceipts, creationIdValid } from "./native-creation-receipts.mjs";
39
40
 
40
41
  exitForVersionRequest(import.meta.url);
41
42
 
42
- const VERSION = "1.22.18";
43
+ const VERSION = "1.22.20";
43
44
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
44
45
 
45
46
  function errorResponse(code, message, sent, details) {
@@ -393,8 +394,8 @@ export class RelaySocketServer {
393
394
  requireAccountContext = false,
394
395
  strict = hardenedBridgeEnabled(),
395
396
  protectSocket = protectCurrentUserPipe,
396
- restrictSocket = (target) => {
397
- if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
397
+ restrictSocket = (target, expected) => {
398
+ if (!IS_WINDOWS) verifyRelaySocketMode(target, expected);
398
399
  },
399
400
  signalExit = true,
400
401
  log: logFn = () => {},
@@ -468,7 +469,7 @@ export class RelaySocketServer {
468
469
  this.aclReady = true;
469
470
  for (const provisional of this.provisionalSockets) provisional.destroy();
470
471
  this.provisionalSockets.clear();
471
- } else this.restrictSocket(this.socketPath);
472
+ } else this.restrictSocket(this.socketPath, this.boundIdentity);
472
473
  if (generation !== this.startGeneration) throw new Error("Relay socket startup was cancelled");
473
474
  } catch (err) {
474
475
  this.aclReady = !IS_WINDOWS || !this.strict;
@@ -524,10 +525,15 @@ export class RelaySocketServer {
524
525
  #bind(target) {
525
526
  return new Promise((resolve, reject) => {
526
527
  this.server.once("error", reject);
527
- this.server.listen(target, () => {
528
- this.server.off("error", reject);
529
- resolve();
530
- });
528
+ const previousMask = IS_WINDOWS ? null : process.umask(0o177);
529
+ try {
530
+ this.server.listen(target, () => {
531
+ this.server.off("error", reject);
532
+ resolve();
533
+ });
534
+ } finally {
535
+ if (previousMask !== null) process.umask(previousMask);
536
+ }
531
537
  });
532
538
  }
533
539
 
@@ -553,7 +559,7 @@ export class RelaySocketServer {
553
559
  if (error.code !== "EEXIST") throw error;
554
560
  if (!replaceStale || attempt || await this.#socketIsLive()) throw new Error(`another native relay companion already owns ${this.socketPath}`);
555
561
  this.log(`removing the stale relay socket left at ${this.socketPath}`);
556
- fs.rmSync(this.socketPath, { force: true });
562
+ removeRelayFile(this.socketPath);
557
563
  }
558
564
  }
559
565
  const identity = fs.lstatSync(this.socketPath, { bigint: true });
@@ -570,13 +576,8 @@ export class RelaySocketServer {
570
576
  async #listen({ replaceStale }) {
571
577
  if (!IS_WINDOWS && await this.#listenLinked(replaceStale)) return;
572
578
  try {
573
- await new Promise((resolve, reject) => {
574
- this.server.once("error", reject);
575
- this.server.listen(this.socketPath, () => {
576
- this.server.off("error", reject);
577
- resolve();
578
- });
579
- });
579
+ await this.#bind(this.socketPath);
580
+ if (!IS_WINDOWS) this.boundIdentity = relayFileIdentity(this.socketPath, "socket");
580
581
  } catch (err) {
581
582
  if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
582
583
  if (IS_WINDOWS) {
@@ -590,7 +591,7 @@ export class RelaySocketServer {
590
591
  throw new Error(`another native relay companion already owns ${this.socketPath}`);
591
592
  }
592
593
  this.log(`removing the stale relay socket left at ${this.socketPath}`);
593
- fs.rmSync(this.socketPath, { force: true });
594
+ removeRelayFile(this.socketPath);
594
595
  await this.#listen({ replaceStale: false });
595
596
  }
596
597
  }
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function relayFileIdentity(target, kind, expected) {
5
+ const root = fs.realpathSync.native(path.dirname(target));
6
+ const name = path.basename(target);
7
+ if (kind === "config" && name !== "native-relay.json") throw new Error("Unexpected relay configuration filename");
8
+ const file = path.resolve(root, name);
9
+ if (!file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`)) throw new Error("Relay file escaped its directory");
10
+ const current = fs.lstatSync(file, { bigint: true });
11
+ if (current.isSymbolicLink() || (kind === "config" ? !current.isFile() : !current.isSocket())
12
+ || current.nlink !== 1n
13
+ || typeof process.getuid === "function" && current.uid !== BigInt(process.getuid())
14
+ || expected && (current.dev !== expected.dev || current.ino !== expected.ino)) {
15
+ throw new Error(`Unsafe relay ${kind} file: ${target}`);
16
+ }
17
+ return current;
18
+ }
19
+
20
+ export function removeRelayFile(target, { kind = "socket", directory = path.dirname(target), expected } = {}) {
21
+ let root;
22
+ try { root = fs.realpathSync.native(directory); }
23
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
24
+ const name = kind === "config" ? "native-relay.json" : path.basename(target);
25
+ if (path.basename(target) !== name) throw new Error("Unexpected relay configuration filename");
26
+ const file = path.resolve(root, name);
27
+ if (!file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`)) throw new Error("Relay file escaped its directory");
28
+ let identity;
29
+ try { identity = relayFileIdentity(file, kind, expected); }
30
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
31
+ if (fs.realpathSync.native(file) !== file) throw new Error("Relay file is redirected");
32
+ relayFileIdentity(file, kind, identity);
33
+ fs.unlinkSync(file);
34
+ return true;
35
+ }
36
+
37
+ export function verifyRelaySocketMode(target, expected) {
38
+ const current = relayFileIdentity(target, "socket", expected);
39
+ if ((current.mode & 0o777n) !== 0o600n) throw new Error("Relay socket was not created with mode 0600");
40
+ }
@@ -367,6 +367,12 @@ function injectedMessageRoot(entry, msgId) {
367
367
  return null;
368
368
  }
369
369
 
370
+ function isOtherPeerInput(entry, msgId) {
371
+ const origin = entry.origin;
372
+ return origin?.kind === "peer" && typeof origin.msg_id === "string"
373
+ && origin.msg_id.trim().length > 0 && origin.msg_id !== msgId;
374
+ }
375
+
370
376
  function isInterruptedDelivery(receipt) {
371
377
  return receipt?.status === "interrupted" && receipt.source === "transcript"
372
378
  && receipt.reasonCode === "CLAUDE_REPLY_INTERRUPTED"
@@ -532,7 +538,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
532
538
  && (typeof content === "string" && content.trim()
533
539
  || Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "text" && typeof part.text === "string")
534
540
  && content.some((part) => part.text.trim()));
535
- if (ordinaryInput) {
541
+ if (ordinaryInput || isOtherPeerInput(entry, msgId)) {
536
542
  interrupt(entry, outstanding);
537
543
  continue;
538
544
  }