@minhspark/codex-mcp-bridge 1.22.7 → 1.22.9

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.7",
3
+ "version": "1.22.9",
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,43 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.9] - 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - Read the Claude Desktop configuration with exact bigint stats in `codex-mcp-bridge-install`, so a configuration file replaced during the installer's read is detected even when the two inodes differ only beyond Number precision. v1.22.8 converted the runtime stores but missed this installer.
12
+ - Notice a new reply in a Claude Code transcript when the file changes within the same millisecond and keeps its size. The reply poll now compares the file's device, inode, nanosecond modification and change times, and size instead of only the millisecond modification time and size.
13
+
14
+ ### Verification
15
+
16
+ - 2 new regression tests: a Desktop configuration whose inode changes only below Number precision (`2^60` and `2^60 + 1`) is rejected and left untouched, and a transcript reply is observed while its Number `mtimeMs` and size stay frozen. Both fail on v1.22.8.
17
+ - Two independent sweeps of `src/`, `scripts/` and `bin/`, one by stat API and one by stored or compared stat data, found no other Number-based identity or change detection.
18
+ - CI passed on macOS, Ubuntu and Windows with Node 22 and 24. CodeQL re-reported 3 `js/path-injection` flows on the edited stat lines; each source is operator environment configuration, matching earlier dismissals, and they were dismissed as false positives.
19
+
20
+ ### Limitations
21
+
22
+ - The 64-bit NTFS case runs for real only on Windows CI; local tests simulate it with mocked stats.
23
+
24
+ ## [1.22.8] - 2026-10-20
25
+
26
+ ### Fixed
27
+
28
+ - Compare file identities with exact 64-bit values everywhere. Reply and native creation receipts, the creation signing key, Codex rollout watermarks and segment checks, Codex account evidence, Claude Desktop task metadata and account state, project repository metadata, and the Sites connector's private files read `dev`, `ino`, `size` and nanosecond `mtime`/`ctime` as bigint. Number stats round inodes above 2^53, which NTFS file IDs and some macOS volumes (such as `/tmp` on the sealed system volume) exceed, so a replaced file or directory could pass as unchanged; this made `rejects replacement of a pinned receipt directory` fail intermittently on Windows CI.
29
+
30
+ ### Security
31
+
32
+ - A rollout watermark now stores its file identity as exact decimal strings. A reply receipt written by an earlier version still matches only when its numeric identity is an exactly representable integer equal to the current file; a rounded identity no longer matches, and the observation reports the rollout as changed instead.
33
+
34
+ ### Verification
35
+
36
+ - 7 new regression tests. Two identities that differ only below Number precision (`2^60` and `2^60 + 1`) compare as different for both `ino` and `dev`; the reply and native creation receipt stores reject a pinned directory whose inode changes only below 2^53, and both store tests fail on v1.22.7. The rollout watermark keeps exact string identities through JSON and honors only exactly representable legacy numbers.
37
+ - A review of the change found a test that now passed without reaching its byte comparison: its mock returned Number stats to code that requests bigint. It was fixed and checked by removing the comparison, which makes the test fail.
38
+ - CI passed on macOS, Ubuntu and Windows with Node 22 and 24, including `test (windows-latest, 22)` where the flake appeared. CodeQL re-reported 12 `js/path-injection` flows on the edited stat lines; each source is operator `process.env` configuration, matching the earlier dismissals, and they were dismissed as false positives.
39
+
40
+ ### Limitations
41
+
42
+ - The 64-bit NTFS case runs for real only on Windows CI; local tests simulate it with mocked bigint stats. A reply observation whose earlier receipt stored a rounded rollout identity reports the rollout as changed; inspect that task instead of resending.
43
+
7
44
  ## [1.22.7] - 2026-10-20
8
45
 
9
46
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.7",
3
+ "version": "1.22.9",
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.7",
4
+ "version": "1.22.9",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -45,23 +45,23 @@ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
45
45
  function readConfig() {
46
46
  let descriptor;
47
47
  try {
48
- const before = fs.lstatSync(cfgPath);
49
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 4 * 1024 * 1024) throw new Error("Unsafe Desktop configuration file");
48
+ const before = fs.lstatSync(cfgPath, { bigint: true });
49
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Unsafe Desktop configuration file");
50
50
  descriptor = fs.openSync(cfgPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
51
- const opened = fs.fstatSync(descriptor);
52
- const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
51
+ const opened = fs.fstatSync(descriptor, { bigint: true });
52
+ const fields = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
53
53
  const matches = (left, right) => fields.every(field => left[field] === right[field]);
54
- if (!opened.isFile() || opened.nlink !== 1 || !matches(before, opened)) throw new Error("Desktop configuration changed during access");
55
- const data = Buffer.alloc(opened.size);
54
+ if (!opened.isFile() || opened.nlink !== 1n || !matches(before, opened)) throw new Error("Desktop configuration changed during access");
55
+ const data = Buffer.alloc(Number(opened.size));
56
56
  let offset = 0;
57
57
  while (offset < data.length) {
58
58
  const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
59
59
  if (!count) throw new Error("Desktop configuration changed during access");
60
60
  offset += count;
61
61
  }
62
- const after = fs.fstatSync(descriptor);
63
- const current = fs.lstatSync(cfgPath);
64
- if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !matches(opened, after) || !matches(after, current)) throw new Error("Desktop configuration changed during access");
62
+ const after = fs.fstatSync(descriptor, { bigint: true });
63
+ const current = fs.lstatSync(cfgPath, { bigint: true });
64
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n || !matches(opened, after) || !matches(after, current)) throw new Error("Desktop configuration changed during access");
65
65
  return { data, version: fields.map(field => current[field]) };
66
66
  } catch (error) {
67
67
  if (error.code === "ENOENT" && descriptor === undefined) return null;
@@ -24,7 +24,7 @@ import { stableInstallPath } from "../src/install-paths.mjs";
24
24
 
25
25
  exitForVersionRequest(import.meta.url);
26
26
 
27
- const VERSION = "1.22.7";
27
+ const VERSION = "1.22.9";
28
28
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
29
29
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
30
30
  const entryArgs = ["native-relay-companion.mjs"];
@@ -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.7";
33
+ const VERSION = "1.22.9";
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;
@@ -9,7 +9,7 @@ const TASK_FILE = new RegExp(`^(local_${UUID})\\.json$`, "i");
9
9
  const MAX_ENTRIES = 8192;
10
10
  const MAX_FILE_BYTES = 4 * 1024 * 1024;
11
11
  const MAX_IDENTITY_BYTES = 32 * 1024 * 1024;
12
- const VERSION_FIELDS = ["size", "mtimeMs", "ctimeMs", "ino", "dev"];
12
+ const VERSION_FIELDS = ["size", "mtimeNs", "ctimeNs", "ino", "dev"];
13
13
 
14
14
  const sameVersion = (left, right) => VERSION_FIELDS.every((field) => left[field] === right[field]);
15
15
 
@@ -68,12 +68,12 @@ function metadataFiles(root, accountId) {
68
68
  function readMetadata(file, budget) {
69
69
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
70
70
  try {
71
- const before = fs.fstatSync(descriptor);
72
- if (!before.isFile() || before.size > MAX_FILE_BYTES) throw new Error("file limit");
73
- const bytes = Buffer.alloc(before.size);
71
+ const before = fs.fstatSync(descriptor, { bigint: true });
72
+ if (!before.isFile() || before.size > BigInt(MAX_FILE_BYTES)) throw new Error("file limit");
73
+ const bytes = Buffer.alloc(Number(before.size));
74
74
  if (fs.readSync(descriptor, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("short read");
75
- const after = fs.fstatSync(descriptor);
76
- const current = fs.lstatSync(file);
75
+ const after = fs.fstatSync(descriptor, { bigint: true });
76
+ const current = fs.lstatSync(file, { bigint: true });
77
77
  if (!sameVersion(before, after) || current.isSymbolicLink() || !sameVersion(current, after)) throw new Error("metadata changed");
78
78
  budget.versions.set(file, current);
79
79
  budget.digests.set(file, createHash("sha256").update(bytes).digest("hex"));
@@ -217,7 +217,7 @@ export function readClaudeDesktopTasks({ platform = process.platform, env = proc
217
217
  const currentFiles = metadataFiles(directory, account?.accountId);
218
218
  if (files.length !== currentFiles.length || files.some((file, index) => file !== currentFiles[index])) throw new Error("metadata changed");
219
219
  for (const file of files) {
220
- const current = fs.lstatSync(file);
220
+ const current = fs.lstatSync(file, { bigint: true });
221
221
  if (!current.isFile() || !sameVersion(current, budget.versions.get(file)) || rereadMetadata(file) !== budget.digests.get(file)) throw new Error("metadata changed");
222
222
  }
223
223
  return records;
@@ -16,22 +16,22 @@ function identity(value) {
16
16
  }
17
17
 
18
18
  function readStable(file, limit) {
19
- const entry = fs.lstatSync(file);
19
+ const entry = fs.lstatSync(file, { bigint: true });
20
20
  if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Codex account evidence must be a regular file");
21
21
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
22
22
  try {
23
- const before = fs.fstatSync(descriptor);
24
- if (!before.isFile() || before.size > limit || entry.ino !== before.ino || entry.dev !== before.dev) throw new Error("Codex account evidence is invalid or exceeds its read limit");
25
- const data = Buffer.alloc(before.size);
23
+ const before = fs.fstatSync(descriptor, { bigint: true });
24
+ if (!before.isFile() || before.size > BigInt(limit) || entry.ino !== before.ino || entry.dev !== before.dev) throw new Error("Codex account evidence is invalid or exceeds its read limit");
25
+ const data = Buffer.alloc(Number(before.size));
26
26
  let offset = 0;
27
27
  while (offset < data.length) {
28
28
  const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
29
29
  if (!count) throw new Error("Codex account evidence changed while reading; retry on the next call");
30
30
  offset += count;
31
31
  }
32
- const after = fs.fstatSync(descriptor);
33
- const current = fs.lstatSync(file);
34
- if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.ino !== current.ino || before.dev !== current.dev || after.size !== current.size || after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs) throw new Error("Codex account evidence changed while reading; retry on the next call");
32
+ const after = fs.fstatSync(descriptor, { bigint: true });
33
+ const current = fs.lstatSync(file, { bigint: true });
34
+ if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || before.ino !== current.ino || before.dev !== current.dev || after.size !== current.size || after.mtimeNs !== current.mtimeNs || after.ctimeNs !== current.ctimeNs) throw new Error("Codex account evidence changed while reading; retry on the next call");
35
35
  return data.toString("utf8");
36
36
  } finally {
37
37
  fs.closeSync(descriptor);
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { scanRollout } from "./rollout-reader.mjs";
7
+ import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
7
8
  import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
8
9
 
9
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -52,9 +53,9 @@ function findRollouts(threadId, env) {
52
53
  }
53
54
 
54
55
  function pathIdentity(candidate, kind) {
55
- const status = fs.lstatSync(candidate);
56
+ const status = fs.lstatSync(candidate, { bigint: true });
56
57
  if ((kind === "file" ? !status.isFile() : !status.isDirectory()) || status.isSymbolicLink()) throw new Error(`The Codex rollout ${kind === "file" ? "file" : "ancestor"} is not regular`);
57
- return { path: candidate, dev: status.dev, ino: status.ino };
58
+ return { path: candidate, ...fileIdentity(status) };
58
59
  }
59
60
 
60
61
  function capturePathBoundary(file, sessions) {
@@ -77,9 +78,9 @@ function capturePathBoundary(file, sessions) {
77
78
  }
78
79
 
79
80
  function samePathBoundary(expected, current) {
80
- return expected.file.dev === current.file.dev && expected.file.ino === current.file.ino &&
81
+ return sameFileIdentity(expected.file, current.file) &&
81
82
  expected.ancestors.length === current.ancestors.length && expected.ancestors.every((entry, index) =>
82
- entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
83
+ entry.path === current.ancestors[index].path && sameFileIdentity(entry, current.ancestors[index]));
83
84
  }
84
85
 
85
86
  function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
@@ -136,7 +137,7 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
136
137
  },
137
138
  });
138
139
  const afterBoundary = capturePathBoundary(file, sessions);
139
- if (!samePathBoundary(beforeBoundary, afterBoundary) || snapshot.identity.dev !== beforeBoundary.file.dev || snapshot.identity.ino !== beforeBoundary.file.ino) {
140
+ if (!samePathBoundary(beforeBoundary, afterBoundary) || !sameFileIdentity(snapshot.identity, beforeBoundary.file)) {
140
141
  throw new Error("The Codex rollout changed while reading");
141
142
  }
142
143
  return { ...snapshot, file, sessions, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
@@ -427,7 +428,7 @@ export function resolveCodexNativeDispatchResponse(binding, { env = process.env,
427
428
  const mark = marks.find(({ file }) => file === entry.file);
428
429
  const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
429
430
  if (mark) {
430
- if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
431
+ if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
431
432
  if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
432
433
  }
433
434
  validateIdentity(snapshot, threadId, cwd, desktopEvidence);
@@ -473,7 +474,7 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
473
474
  retainedBytes += snapshot.retainedBytes;
474
475
  if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
475
476
  if (mark) {
476
- if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
477
+ if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
477
478
  if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
478
479
  }
479
480
  if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
@@ -8,7 +8,7 @@ const PACKAGE_NAME = /^Claude_[a-z0-9._-]+$/i;
8
8
  const MAX_CONFIG_BYTES = 1024 * 1024;
9
9
  const MAX_PACKAGES = 512;
10
10
  const MAX_ROOTS = 16;
11
- const VERSION_FIELDS = ["size", "mtimeMs", "ctimeMs", "ino", "dev"];
11
+ const VERSION_FIELDS = ["size", "mtimeNs", "ctimeNs", "ino", "dev"];
12
12
  const sameVersion = (left, right) => VERSION_FIELDS.every((field) => left[field] === right[field]);
13
13
  const isMissing = (error) => error.code === "ENOENT";
14
14
 
@@ -28,7 +28,7 @@ function absoluteDirectory(value) {
28
28
  }
29
29
 
30
30
  function directoryStat(directory) {
31
- const stat = fs.lstatSync(directory);
31
+ const stat = fs.lstatSync(directory, { bigint: true });
32
32
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("invalid directory");
33
33
  return stat;
34
34
  }
@@ -73,22 +73,22 @@ function readConfig(root) {
73
73
  }
74
74
  const file = path.join(root, "config.json");
75
75
  let initial;
76
- try { initial = fs.lstatSync(file); }
76
+ try { initial = fs.lstatSync(file, { bigint: true }); }
77
77
  catch (error) {
78
78
  if (isMissing(error)) return { kind: "missing-config", rootStat };
79
79
  throw error;
80
80
  }
81
- if (!initial.isFile() || initial.isSymbolicLink() || initial.size > MAX_CONFIG_BYTES) throw new Error("invalid config");
81
+ if (!initial.isFile() || initial.isSymbolicLink() || initial.size > BigInt(MAX_CONFIG_BYTES)) throw new Error("invalid config");
82
82
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
83
83
  let bytes;
84
84
  let current;
85
85
  try {
86
- const before = fs.fstatSync(descriptor);
86
+ const before = fs.fstatSync(descriptor, { bigint: true });
87
87
  if (!before.isFile() || !sameVersion(initial, before)) throw new Error("config changed");
88
- bytes = Buffer.alloc(before.size);
88
+ bytes = Buffer.alloc(Number(before.size));
89
89
  if (fs.readSync(descriptor, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("short read");
90
- const after = fs.fstatSync(descriptor);
91
- current = fs.lstatSync(file);
90
+ const after = fs.fstatSync(descriptor, { bigint: true });
91
+ current = fs.lstatSync(file, { bigint: true });
92
92
  if (!current.isFile() || current.isSymbolicLink() || !sameVersion(before, after) || !sameVersion(after, current)) throw new Error("config changed");
93
93
  } finally {
94
94
  fs.closeSync(descriptor);
@@ -4,10 +4,11 @@ import { randomUUID } from "node:crypto";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { creationRequestHash, creationIdValid } from "./native-creation-receipts.mjs";
6
6
  import { homeDir } from "./platform.mjs";
7
+ import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
7
8
 
8
9
  const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
9
10
  const LIMIT = 256 * 1024;
10
- const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeMs", "ctimeMs"].every(field => left[field] === right[field]);
11
+ const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeNs", "ctimeNs"].every(field => left[field] === right[field]);
11
12
 
12
13
  function owner(context) {
13
14
  const { sessionId, taskId } = context?.caller ?? {};
@@ -29,11 +30,11 @@ export class DesktopReplyReceipts {
29
30
  file(id, create = false) {
30
31
  if (typeof id !== "string" || !ID.test(id)) throw new Error("Invalid reply deliveryId");
31
32
  if (create) fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
32
- const info = fs.lstatSync(this.directory);
33
+ const info = fs.lstatSync(this.directory, { bigint: true });
33
34
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe reply receipt directory");
34
35
  const canonical = fs.realpathSync.native(this.directory);
35
- if (this.#boundary && (this.#boundary.path !== canonical || this.#boundary.dev !== info.dev || this.#boundary.ino !== info.ino)) throw new Error("Reply receipt directory changed");
36
- this.#boundary ??= { path: canonical, dev: info.dev, ino: info.ino };
36
+ if (this.#boundary && (this.#boundary.path !== canonical || !sameFileIdentity(this.#boundary, fileIdentity(info)))) throw new Error("Reply receipt directory changed");
37
+ this.#boundary ??= { path: canonical, ...fileIdentity(info) };
37
38
  return path.join(canonical, `${id}.json`);
38
39
  }
39
40
 
@@ -82,11 +83,11 @@ export class DesktopReplyReceipts {
82
83
 
83
84
  read(id, context) {
84
85
  const file = this.file(id);
85
- const before = fs.lstatSync(file);
86
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > LIMIT) throw new Error("Unsafe reply receipt");
86
+ const before = fs.lstatSync(file, { bigint: true });
87
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > BigInt(LIMIT)) throw new Error("Unsafe reply receipt");
87
88
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
88
89
  try {
89
- const opened = fs.fstatSync(fd);
90
+ const opened = fs.fstatSync(fd, { bigint: true });
90
91
  if (!opened.isFile() || !sameFile(before, opened)) throw new Error("Reply receipt changed while opening");
91
92
  const buffer = Buffer.alloc(LIMIT + 1);
92
93
  let length = 0;
@@ -96,9 +97,9 @@ export class DesktopReplyReceipts {
96
97
  length += count;
97
98
  if (length > LIMIT) throw new Error("Reply receipt exceeds its read limit");
98
99
  }
99
- const after = fs.fstatSync(fd);
100
- const current = fs.lstatSync(this.file(id));
101
- if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || length !== opened.size) throw new Error("Reply receipt changed while reading");
100
+ const after = fs.fstatSync(fd, { bigint: true });
101
+ const current = fs.lstatSync(this.file(id), { bigint: true });
102
+ if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || BigInt(length) !== opened.size) throw new Error("Reply receipt changed while reading");
102
103
  const receipt = JSON.parse(buffer.subarray(0, length).toString("utf8"));
103
104
  const expected = owner(context);
104
105
  if (receipt.version !== 1 || receipt.deliveryId !== id || !receipt.owner ||
@@ -0,0 +1,13 @@
1
+ const exact = (value) => typeof value === "bigint" ? String(value)
2
+ : typeof value === "string" ? (/^-?\d+$/.test(value) ? value : null)
3
+ : Number.isSafeInteger(value) ? String(value) : null;
4
+
5
+ export function fileIdentity(stat) {
6
+ if (typeof stat?.dev !== "bigint" || typeof stat.ino !== "bigint") throw new TypeError("File identity requires bigint stats");
7
+ return { dev: String(stat.dev), ino: String(stat.ino) };
8
+ }
9
+
10
+ export function sameFileIdentity(left, right) {
11
+ const values = [left?.dev, left?.ino, right?.dev, right?.ino].map(exact);
12
+ return values.every((value) => value !== null) && values[0] === values[2] && values[1] === values[3];
13
+ }
package/src/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
40
40
 
41
41
  exitForVersionRequest(import.meta.url);
42
42
 
43
- const VERSION = "1.22.7";
43
+ const VERSION = "1.22.9";
44
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
45
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
46
46
 
@@ -3,8 +3,9 @@ import path from "node:path";
3
3
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { homeDir } from "./platform.mjs";
6
+ import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
6
7
 
7
- const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeMs", "ctimeMs"].every(field => left[field] === right[field]);
8
+ const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeNs", "ctimeNs"].every(field => left[field] === right[field]);
8
9
 
9
10
  export const creationIdValid = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value);
10
11
  export function creationRequestHash(args) {
@@ -31,23 +32,23 @@ export class NativeCreationReceipts {
31
32
  if (process.platform === "win32") this.#protectWindowsPrivate(this.#keyDirectory, true);
32
33
  } catch (error) { if (error.code !== "EEXIST") throw error; }
33
34
  }
34
- const directory = fs.lstatSync(this.#keyDirectory);
35
+ const directory = fs.lstatSync(this.#keyDirectory, { bigint: true });
35
36
  const canonical = fs.realpathSync.native(this.#keyDirectory);
36
37
  if (!directory.isDirectory() || directory.isSymbolicLink() ||
37
- process.platform !== "win32" && ((directory.mode & 0o077) || directory.uid !== process.getuid()) ||
38
- this.#keyDirectoryBoundary && (this.#keyDirectoryBoundary.path !== canonical || this.#keyDirectoryBoundary.dev !== directory.dev || this.#keyDirectoryBoundary.ino !== directory.ino)) {
38
+ process.platform !== "win32" && ((directory.mode & 0o077n) || directory.uid !== BigInt(process.getuid())) ||
39
+ this.#keyDirectoryBoundary && (this.#keyDirectoryBoundary.path !== canonical || !sameFileIdentity(this.#keyDirectoryBoundary, fileIdentity(directory)))) {
39
40
  throw Error("Unsafe native creation authority directory");
40
41
  }
41
42
  if (process.platform === "win32" && !this.#keyDirectoryBoundary) {
42
43
  this.#assertWindowsPrivate(canonical, true);
43
44
  }
44
- this.#keyDirectoryBoundary ??= { path: canonical, dev: directory.dev, ino: directory.ino };
45
+ this.#keyDirectoryBoundary ??= { path: canonical, ...fileIdentity(directory) };
45
46
  const file = path.join(canonical, "signing-key");
46
47
  if (create) {
47
48
  let created, createdBoundary;
48
49
  try {
49
50
  created = fs.openSync(file, "wx", 0o600);
50
- createdBoundary = fs.fstatSync(created);
51
+ createdBoundary = fs.fstatSync(created, { bigint: true });
51
52
  if (process.platform === "win32") this.#protectWindowsPrivate(file, false);
52
53
  fs.writeFileSync(created, randomBytes(32));
53
54
  fs.fsyncSync(created);
@@ -56,32 +57,32 @@ export class NativeCreationReceipts {
56
57
  if (created !== undefined) {
57
58
  fs.closeSync(created);
58
59
  created = undefined;
59
- const failed = fs.lstatSync(file);
60
- if (failed.dev === createdBoundary.dev && failed.ino === createdBoundary.ino && !failed.isSymbolicLink()) fs.unlinkSync(file);
60
+ const failed = fs.lstatSync(file, { bigint: true });
61
+ if (sameFileIdentity(fileIdentity(failed), fileIdentity(createdBoundary)) && !failed.isSymbolicLink()) fs.unlinkSync(file);
61
62
  }
62
63
  if (error.code !== "EEXIST") throw error;
63
64
  }
64
65
  finally { if (created !== undefined) fs.closeSync(created); }
65
66
  }
66
- const before = fs.lstatSync(file);
67
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== 32 ||
68
- process.platform !== "win32" && ((before.mode & 0o077) || before.uid !== process.getuid())) throw Error("Unsafe native creation signing key");
67
+ const before = fs.lstatSync(file, { bigint: true });
68
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size !== 32n ||
69
+ process.platform !== "win32" && ((before.mode & 0o077n) || before.uid !== BigInt(process.getuid()))) throw Error("Unsafe native creation signing key");
69
70
  if (process.platform === "win32" && !this.#keyBoundary) this.#assertWindowsPrivate(file, false);
70
71
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
71
72
  let key;
72
73
  try {
73
- const opened = fs.fstatSync(fd);
74
+ const opened = fs.fstatSync(fd, { bigint: true });
74
75
  if (!opened.isFile() || !sameFile(before, opened)) throw Error("Native creation signing key changed");
75
76
  const bytes = Buffer.alloc(33);
76
77
  let count = 0, read;
77
78
  while (count < bytes.length && (read = fs.readSync(fd, bytes, count, bytes.length - count, count))) count += read;
78
79
  key = bytes.subarray(0, count);
79
- const after = fs.fstatSync(fd);
80
- const current = fs.lstatSync(file);
80
+ const after = fs.fstatSync(fd, { bigint: true });
81
+ const current = fs.lstatSync(file, { bigint: true });
81
82
  if (key.length !== 32 || current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current)) throw Error("Native creation signing key changed");
82
83
  } finally { fs.closeSync(fd); }
83
- if (this.#keyBoundary && (this.#keyBoundary.dev !== before.dev || this.#keyBoundary.ino !== before.ino || !timingSafeEqual(this.#key, key))) throw Error("Native creation signing key changed");
84
- this.#keyBoundary ??= { dev: before.dev, ino: before.ino };
84
+ if (this.#keyBoundary && (!sameFileIdentity(this.#keyBoundary, fileIdentity(before)) || !timingSafeEqual(this.#key, key))) throw Error("Native creation signing key changed");
85
+ this.#keyBoundary ??= fileIdentity(before);
85
86
  this.#key ??= key;
86
87
  return this.#key;
87
88
  }
@@ -110,11 +111,11 @@ export class NativeCreationReceipts {
110
111
  file(id) {
111
112
  if (!creationIdValid(id)) throw Error("Invalid native creation receipt id");
112
113
  fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
113
- const st = fs.lstatSync(this.directory);
114
+ const st = fs.lstatSync(this.directory, { bigint: true });
114
115
  if (!st.isDirectory() || st.isSymbolicLink()) throw Error("Unsafe native creation receipt directory");
115
116
  const canonical = fs.realpathSync.native(this.directory);
116
- if (this.boundary && (this.boundary.path !== canonical || this.boundary.dev !== st.dev || this.boundary.ino !== st.ino)) throw Error("Native creation receipt directory changed");
117
- this.boundary ??= { path: canonical, dev: st.dev, ino: st.ino };
117
+ if (this.boundary && (this.boundary.path !== canonical || !sameFileIdentity(this.boundary, fileIdentity(st)))) throw Error("Native creation receipt directory changed");
118
+ this.boundary ??= { path: canonical, ...fileIdentity(st) };
118
119
  this.directory = canonical;
119
120
  return path.join(canonical, id + ".json");
120
121
  }
@@ -122,18 +123,18 @@ export class NativeCreationReceipts {
122
123
  const file = this.file(id);
123
124
  let fd, receiptFound = false;
124
125
  try {
125
- const before = fs.lstatSync(file);
126
+ const before = fs.lstatSync(file, { bigint: true });
126
127
  receiptFound = true;
127
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 1024 * 1024) throw Error("Unsafe native creation receipt");
128
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > 1024n * 1024n) throw Error("Unsafe native creation receipt");
128
129
  fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
129
- const opened = fs.fstatSync(fd);
130
+ const opened = fs.fstatSync(fd, { bigint: true });
130
131
  if (!opened.isFile() || !sameFile(before, opened)) throw Error("Native creation receipt changed");
131
132
  const bytes = Buffer.alloc(1024 * 1024 + 1);
132
133
  let count = 0, n;
133
134
  while ((n = fs.readSync(fd, bytes, count, bytes.length - count, count))) { count += n; if (count > 1024 * 1024) throw Error("Oversized native creation receipt"); }
134
- const after = fs.fstatSync(fd);
135
- const current = fs.lstatSync(this.file(id));
136
- if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || count !== opened.size) throw Error("Native creation receipt changed");
135
+ const after = fs.fstatSync(fd, { bigint: true });
136
+ const current = fs.lstatSync(this.file(id), { bigint: true });
137
+ if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || BigInt(count) !== opened.size) throw Error("Native creation receipt changed");
137
138
  const row = JSON.parse(bytes.subarray(0, count).toString());
138
139
  if (row.version !== 1 || row.id !== id || row.hash !== hash || creationRequestHash(row.args) !== hash ||
139
140
  !accounts || ["claude", "codex"].some(k => !accounts[k] || row.accounts?.[k] !== accounts[k]) ||
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
38
 
39
39
  exitForVersionRequest(import.meta.url);
40
40
 
41
- const VERSION = "1.22.7";
41
+ const VERSION = "1.22.9";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
@@ -860,7 +860,7 @@ export class PeerEndpoint {
860
860
  const { sessionId, cwd } = pending.transcriptSession;
861
861
  const file = findTranscriptFile(sessionId, cwd);
862
862
  let stamp;
863
- try { const stat = fs.statSync(file); stamp = `${stat.mtimeMs}:${stat.size}`; }
863
+ try { const stat = fs.statSync(file, { bigint: true }); stamp = `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`; }
864
864
  catch { continue; }
865
865
  if (stamp === pending.transcriptStamp) continue;
866
866
  pending.transcriptStamp = stamp;
@@ -15,23 +15,23 @@ function samePath(left, right) {
15
15
  }
16
16
 
17
17
  function metadata(file) {
18
- const entry = fs.lstatSync(file);
19
- if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 4096) throw new Error("Invalid project repository metadata");
18
+ const entry = fs.lstatSync(file, { bigint: true });
19
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 4096n) throw new Error("Invalid project repository metadata");
20
20
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
21
21
  try {
22
- const before = fs.fstatSync(descriptor);
23
- const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
22
+ const before = fs.fstatSync(descriptor, { bigint: true });
23
+ const fields = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
24
24
  const matches = (left, right) => fields.every((field) => left[field] === right[field]);
25
25
  if (!before.isFile() || !matches(entry, before)) throw new Error("Project repository metadata changed during access");
26
- const data = Buffer.alloc(before.size);
26
+ const data = Buffer.alloc(Number(before.size));
27
27
  let offset = 0;
28
28
  while (offset < data.length) {
29
29
  const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
30
30
  if (!count) throw new Error("Project repository metadata changed during access");
31
31
  offset += count;
32
32
  }
33
- const after = fs.fstatSync(descriptor);
34
- const current = fs.lstatSync(file);
33
+ const after = fs.fstatSync(descriptor, { bigint: true });
34
+ const current = fs.lstatSync(file, { bigint: true });
35
35
  if (!current.isFile() || current.isSymbolicLink() || !matches(before, after) || !matches(after, current)) throw new Error("Project repository metadata changed during access");
36
36
  return data.toString("utf8").trim();
37
37
  } finally {
@@ -1,25 +1,28 @@
1
1
  import fs from "node:fs";
2
+ import { fileIdentity } from "./file-identity.mjs";
2
3
 
3
4
  const CHUNK_BYTES = 256 * 1024;
4
- const VERSION_FIELDS = ["size", "mtimeMs", "ctimeMs", "ino", "dev"];
5
+ const VERSION_FIELDS = ["size", "mtimeNs", "ctimeNs", "ino", "dev"];
5
6
  const sameVersion = (a, b) => VERSION_FIELDS.every((key) => a[key] === b[key]);
6
7
 
7
8
  // Scan the complete history, but retain at most one bounded JSONL record.
8
9
  // Skipping the middle of a rollout would miss duplicate identities or corruption.
9
10
  export function scanRollout(file, { maxRecordBytes, onRecord, onChunk = () => {} }) {
10
11
  if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 1) throw new Error("The rollout record read limit is invalid");
11
- const named = fs.lstatSync(file);
12
+ const named = fs.lstatSync(file, { bigint: true });
12
13
  if (!named.isFile() || named.isSymbolicLink()) throw new Error("The rollout is not a regular file");
13
14
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
14
15
  try {
15
- const before = fs.fstatSync(descriptor);
16
+ const before = fs.fstatSync(descriptor, { bigint: true });
16
17
  if (!before.isFile()) throw new Error("The opened rollout is not a regular file");
17
18
  if (!sameVersion(named, before)) throw new Error("The rollout changed while opening");
18
- if (before.size === 0) throw new Error("The rollout is empty (0 bytes)");
19
+ if (before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("The rollout exceeds the safe size range");
20
+ const size = Number(before.size);
21
+ if (size === 0) throw new Error("The rollout is empty (0 bytes)");
19
22
  let parts = [], length = 0, start = 0, position = 0;
20
23
  const decoder = new TextDecoder("utf-8", { fatal: true });
21
- while (position < before.size) {
22
- const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, before.size - position));
24
+ while (position < size) {
25
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, size - position));
23
26
  const count = fs.readSync(descriptor, buffer, 0, buffer.length, position);
24
27
  if (!count) throw new Error("The rollout changed while reading");
25
28
  const chunk = buffer.subarray(0, count);
@@ -29,7 +32,7 @@ export function scanRollout(file, { maxRecordBytes, onRecord, onChunk = () => {}
29
32
  const newline = chunk.indexOf(0x0a, offset);
30
33
  const end = newline < 0 ? count : newline;
31
34
  length += end - offset;
32
- if (length > maxRecordBytes) throw new Error(`The rollout record at byte ${start} exceeds the ${maxRecordBytes}-byte per-record read limit (file: ${before.size} bytes)`);
35
+ if (length > maxRecordBytes) throw new Error(`The rollout record at byte ${start} exceeds the ${maxRecordBytes}-byte per-record read limit (file: ${size} bytes)`);
33
36
  parts.push(chunk.subarray(offset, end));
34
37
  if (newline < 0) break;
35
38
  if (length) {
@@ -45,14 +48,14 @@ export function scanRollout(file, { maxRecordBytes, onRecord, onChunk = () => {}
45
48
  }
46
49
  position += count;
47
50
  }
48
- if (start !== before.size) throw new Error("The rollout has an incomplete final record");
49
- const after = fs.fstatSync(descriptor);
50
- const current = fs.lstatSync(file);
51
+ if (start !== size) throw new Error("The rollout has an incomplete final record");
52
+ const after = fs.fstatSync(descriptor, { bigint: true });
53
+ const current = fs.lstatSync(file, { bigint: true });
51
54
  if (!current.isFile() || current.isSymbolicLink() || !sameVersion(before, after) || !sameVersion(after, current)) {
52
55
  throw new Error("The rollout changed while reading");
53
56
  }
54
- return { size: before.size, identity: { dev: before.dev, ino: before.ino },
55
- version: Object.fromEntries(VERSION_FIELDS.map((key) => [key, before[key]])) };
57
+ return { size, identity: fileIdentity(before),
58
+ version: Object.fromEntries(VERSION_FIELDS.map((key) => [key, String(before[key])])) };
56
59
  } finally {
57
60
  fs.closeSync(descriptor);
58
61
  }
@@ -59,7 +59,7 @@ export function assertRolloutSetStable(sessions, threadId, snapshots) {
59
59
  const files = findRolloutSegments(sessions, threadId);
60
60
  if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
61
61
  for (const { file, version } of snapshots) {
62
- const current = fs.lstatSync(file);
63
- if (!current.isFile() || current.isSymbolicLink() || Object.entries(version).some(([key, value]) => current[key] !== value)) throw new Error("A rollout segment changed while reading");
62
+ const current = fs.lstatSync(file, { bigint: true });
63
+ if (!current.isFile() || current.isSymbolicLink() || Object.entries(version).some(([key, value]) => typeof value !== "string" || String(current[key]) !== value)) throw new Error("A rollout segment changed while reading");
64
64
  }
65
65
  }
@@ -49,24 +49,24 @@ export function validateSitesConfig(value, { allowLoopback = false } = {}) {
49
49
  }
50
50
 
51
51
  async function readPrivateJson(file, maxBytes = 2 * 1024 * 1024) {
52
- const info = await fs.lstat(file);
53
- if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > maxBytes) throw new Error("Private connector file is unsafe or oversized");
52
+ const info = await fs.lstat(file, { bigint: true });
53
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > BigInt(maxBytes)) throw new Error("Private connector file is unsafe or oversized");
54
54
  const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
55
55
  try {
56
- const current = await handle.stat();
57
- const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
56
+ const current = await handle.stat({ bigint: true });
57
+ const fields = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
58
58
  const matches = (left, right) => fields.every((field) => left[field] === right[field]);
59
- if (!current.isFile() || current.nlink !== 1 || current.size > maxBytes || !matches(info, current)) throw new Error("Private connector file changed during access");
60
- const data = Buffer.alloc(current.size);
59
+ if (!current.isFile() || current.nlink !== 1n || current.size > BigInt(maxBytes) || !matches(info, current)) throw new Error("Private connector file changed during access");
60
+ const data = Buffer.alloc(Number(current.size));
61
61
  let offset = 0;
62
62
  while (offset < data.length) {
63
63
  const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
64
64
  if (!bytesRead) throw new Error("Private connector file changed during access");
65
65
  offset += bytesRead;
66
66
  }
67
- const after = await handle.stat();
68
- const entry = await fs.lstat(file);
69
- if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || !matches(current, after) || !matches(after, entry)) throw new Error("Private connector file changed during access");
67
+ const after = await handle.stat({ bigint: true });
68
+ const entry = await fs.lstat(file, { bigint: true });
69
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1n || !matches(current, after) || !matches(after, entry)) throw new Error("Private connector file changed during access");
70
70
  return JSON.parse(data.toString("utf8"));
71
71
  } catch (error) {
72
72
  if (error.code === "ENOENT") throw new Error("Private connector file changed during access", { cause: error });