@minhspark/codex-mcp-bridge 1.22.17 → 1.22.19

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.17",
3
+ "version": "1.22.19",
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,36 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.19] - 2026-10-22
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - 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.
13
+ - Supervisor timer intervals use explicit lower and upper bounds, including Node's timer overflow limit, so CodeQL can verify the resource limits.
14
+
15
+ ### Tests
16
+
17
+ - Regression tests cover ordinary files, hardlinks, symlinks, custom Unix sockets, private socket creation, umask restoration, replaced socket identities and timer boundaries.
18
+
19
+ ## [1.22.18] - 2026-10-22
20
+
21
+ ### Fixed
22
+
23
+ - `wait_codex_reply` with a short `timeoutSec` no longer fails with `The response deadline elapsed while another operation held this thread` when no other operation held the thread. The tool's deadline starts before the caller's identity check, and that check spawns PowerShell on Windows. When it used up the whole budget, the observation never started and the bridge reported a held thread instead of a timeout. It now returns the normal timeout result with `nextAction: wait_codex_reply`. The held-thread message is now used only when another operation actually holds the thread; otherwise the error says the deadline elapsed before the operation started.
24
+
25
+ ### Tests
26
+
27
+ - The account-change wait test no longer depends on timing. Its relay fixture parks `wait_threads` on a gate that the test releases, instead of a fixed 2.5 s sleep against a 1 s budget. That timing failed on a stalled `windows-latest` runner.
28
+ - The relay fixture in `test/bridge-integration.test.mjs` provisions its creation receipts once per file, so Windows tests no longer spend four PowerShell cold starts inside their own timing.
29
+ - The Desktop task integration assertions now print the tool result when they fail.
30
+
31
+ ### Verification
32
+
33
+ - New unit tests cover a deadline that elapses before an unheld operation starts, and one that elapses while another operation holds the thread. The first fails on 1.22.17.
34
+ - Simulating a 1.2 s caller check reproduced the CI failure text exactly on 1.22.17. The rewritten test passes with 1.2 s and 3 s simulated delays.
35
+ - Full suite on macOS: 1558 pass, 0 fail, 23 skipped.
36
+
7
37
  ## [1.22.17] - 2026-10-22
8
38
 
9
39
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.17",
3
+ "version": "1.22.19",
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.17",
4
+ "version": "1.22.19",
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.17";
28
+ const VERSION = "1.22.19";
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.17";
33
+ const VERSION = "1.22.19";
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.17";
44
+ const VERSION = "1.22.19";
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.17";
43
+ const VERSION = "1.22.19";
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
+ }
@@ -160,10 +160,12 @@ export class DesktopTaskDelivery {
160
160
  }
161
161
 
162
162
  async withThread(threadId, operation, { deadline, onDeadline } = {}) {
163
+ const held = this.threadOperations.has(threadId);
163
164
  const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
164
165
  let expired = false;
165
166
  const current = previous.catch(() => {}).then(() => {
166
- if (expired || (deadline !== undefined && this.now() >= deadline)) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
167
+ if (deadline !== undefined && this.now() >= deadline) expired = true;
168
+ if (expired) throw new Error(`The response deadline elapsed ${held ? "while another operation held this thread" : "before the operation started"}. No new prompt was sent.`);
167
169
  return operation();
168
170
  });
169
171
  this.threadOperations.set(threadId, current);