@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +3 -3
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +1 -1
- package/src/mcp-supervisor.mjs +11 -7
- package/src/native-relay-companion.mjs +18 -17
- package/src/native-relay-files.mjs +40 -0
- package/src/peer-protocol.mjs +7 -1
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
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.
|
|
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.
|
|
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 (
|
|
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
|
}
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -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
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
|
574
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
}
|