@minhspark/codex-mcp-bridge 1.22.6 → 1.22.8
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 +42 -0
- package/README.md +18 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-claude-desktop.mjs +3 -2
- package/scripts/install-codex-mcp.mjs +5 -3
- package/scripts/install-native-relay.mjs +3 -2
- package/src/claude-bridge.mjs +1 -1
- package/src/claude-desktop-context.mjs +7 -7
- package/src/codex-account-context.mjs +7 -7
- package/src/codex-native-response.mjs +8 -7
- package/src/desktop-account-context.mjs +8 -8
- package/src/desktop-reply-receipts.mjs +11 -10
- package/src/file-identity.mjs +13 -0
- package/src/index.mjs +1 -1
- package/src/install-paths.mjs +54 -0
- package/src/mcp-supervisor.mjs +12 -7
- package/src/native-creation-receipts.mjs +26 -25
- package/src/native-relay-companion.mjs +1 -1
- package/src/platform.mjs +4 -2
- package/src/project-manager-registration.mjs +3 -2
- package/src/project-scope.mjs +7 -7
- package/src/rollout-reader.mjs +15 -12
- package/src/rollout-segments.mjs +2 -2
- package/src/sites-connector.mjs +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.8] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Verification
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
### Limitations
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
27
|
+
## [1.22.7] - 2026-10-20
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Install with Homebrew: `brew install buidangminh23/tap/codex-mcp-bridge`. The formula in [buidangminh23/homebrew-tap](https://github.com/buidangminh23/homebrew-tap) follows each npm release.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Record Homebrew's stable `opt` paths instead of a versioned Cellar keg when the Claude Desktop, Codex and native relay installers and `bridge-projects register-manager` write a client configuration, for both the bridge and Homebrew's Node.js. A keg path broke every registration as soon as `brew upgrade` removed the old keg. Paths outside a Homebrew keg are written exactly as before.
|
|
36
|
+
- Start the supervisor when its path reaches the installation through a symbolic link, such as Homebrew's `opt` alias. The direct-execution check compared the unresolved command-line path with the resolved module path, so the process exited at once without serving MCP.
|
|
37
|
+
- Follow a Homebrew upgrade in a running supervisor: it watches the `opt` alias, reloads the new keg like any other installed update, and forks workers with the stable Node.js path, instead of reporting "Installation is not ready" after the old keg is removed.
|
|
38
|
+
|
|
39
|
+
### Verification
|
|
40
|
+
|
|
41
|
+
- 9 new regression tests: Cellar to `opt` mapping, a custom `HOMEBREW_PREFIX`, the main-module check through a symlink and a respelled path, a running supervisor following a bridge keg switch, and one following a Node.js keg replacement. The supervisor tests fail on v1.22.6 and pass here.
|
|
42
|
+
- End to end on macOS with a local tap: two packed versions installed with brew, the three installers run against an isolated home, and every recorded path was `opt/codex-mcp-bridge` and `opt/node` (the relay keeps Codex Desktop's Node). A bridge launched from the recorded Claude Desktop entry kept its supervisor through `brew upgrade` and `brew cleanup` and reloaded the new keg; fresh launches from the recorded paths answered `initialize`.
|
|
43
|
+
- An independent adversarial review confirmed two defects in the first draft, a main-module regression for respelled paths and a worker Node path resolved too late, and both were fixed before merge.
|
|
44
|
+
|
|
45
|
+
### Limitations
|
|
46
|
+
|
|
47
|
+
- Registrations written from a Homebrew keg by an earlier version still name that keg; run the installers once after upgrading. `codex-native-relay-install` still refuses to rewrite an entry that has `env_vars`, so edit that entry's path by hand.
|
|
48
|
+
|
|
7
49
|
## [1.22.6] - 2026-10-20
|
|
8
50
|
|
|
9
51
|
### Fixed
|
package/README.md
CHANGED
|
@@ -27,6 +27,14 @@ npm install -g @minhspark/codex-mcp-bridge@latest
|
|
|
27
27
|
|
|
28
28
|
Requires **Node.js 22+**. In Windows PowerShell, use `npm.cmd` instead of `npm`. Run the same command to update; reconnect MCP servers if their settings or installation paths changed.
|
|
29
29
|
|
|
30
|
+
On macOS or Linux you can install with [Homebrew](https://github.com/buidangminh23/homebrew-tap) instead, which brings its own Node.js:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
brew install buidangminh23/tap/codex-mcp-bridge
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Update with `brew upgrade codex-mcp-bridge`. The installers record Homebrew's stable `opt` paths, so registrations keep working after an upgrade and running bridges reload the new version. Keep one install channel: whichever of npm or Homebrew comes first on `PATH` provides the commands.
|
|
37
|
+
|
|
30
38
|
[Windows](#windows-powershell) · [macOS](#macos-terminal) · [Linux / WSL](#linux--wsl-bash) · [ChatGPT plugin](#chatgpt-plugin)
|
|
31
39
|
|
|
32
40
|
### Windows (PowerShell)
|
|
@@ -93,6 +101,15 @@ claude mcp add --scope user codex-bridge \
|
|
|
93
101
|
-- "$(command -v node)" "$bridge_root/src/mcp-supervisor.mjs" index.mjs
|
|
94
102
|
```
|
|
95
103
|
|
|
104
|
+
For a Homebrew install, use the stable paths instead of `npm root -g` and `command -v node`:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bridge_root="$(brew --prefix)/opt/codex-mcp-bridge/libexec/lib/node_modules/@minhspark/codex-mcp-bridge"
|
|
108
|
+
node_bin="$(brew --prefix)/opt/node/bin/node"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
and end the command with `-- "$node_bin" "$bridge_root/src/mcp-supervisor.mjs" index.mjs`.
|
|
112
|
+
|
|
96
113
|
</details>
|
|
97
114
|
|
|
98
115
|
Separate multiple allowed directories with `;` on Windows or `:` on macOS/Linux.
|
|
@@ -178,7 +195,7 @@ Dispatch expires after two minutes; operation history remains stored. Disconnect
|
|
|
178
195
|
|
|
179
196
|
## Troubleshooting
|
|
180
197
|
|
|
181
|
-
- **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory on PATH.
|
|
198
|
+
- **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory (or Homebrew's `bin`) on PATH.
|
|
182
199
|
- **PowerShell blocks scripts:** use `npm.cmd`, installer `.cmd` launchers, and `codex.exe`.
|
|
183
200
|
- **No sessions or wrong project:** open the intended Claude **Code** session and use the exact project directory saved in Codex Desktop.
|
|
184
201
|
- **Relay unavailable:** check [pipe forwarding](#native-relay-pipe-forwarding), reconnect, and run `native_relay_status` in the task.
|
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.8",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -9,10 +9,11 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
|
9
9
|
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
10
10
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
11
11
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
12
|
+
import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
|
|
12
13
|
|
|
13
14
|
exitForVersionRequest(import.meta.url);
|
|
14
15
|
|
|
15
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
16
17
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
17
18
|
const reset = process.argv.includes("--reset");
|
|
18
19
|
const forceDesktopTasks = process.argv.includes("--desktop-tasks");
|
|
@@ -29,7 +30,7 @@ if (fullAccess) enableCodexFullAccess();
|
|
|
29
30
|
*/
|
|
30
31
|
const defaultRoots = "*";
|
|
31
32
|
|
|
32
|
-
const nodeBin = process.env.NODE_EXE ??
|
|
33
|
+
const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
|
|
33
34
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
34
35
|
|
|
35
36
|
if (!fs.existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`);
|
|
@@ -10,10 +10,12 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
|
10
10
|
import { codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
|
|
11
11
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
12
12
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
13
|
+
import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
|
|
13
14
|
|
|
14
15
|
exitForVersionRequest(import.meta.url);
|
|
15
16
|
|
|
16
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
18
|
+
const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
|
|
17
19
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
18
20
|
const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
|
|
19
21
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
@@ -39,13 +41,13 @@ const servers = JSON.parse(run(["mcp", "list", "--json"]));
|
|
|
39
41
|
if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
|
|
40
42
|
const existing = servers.some((server) => server.name === serverName)
|
|
41
43
|
? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
|
|
42
|
-
const registration = codexMcpRegistration({ name: serverName, existing, node:
|
|
44
|
+
const registration = codexMcpRegistration({ name: serverName, existing, node: nodeBin, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
|
|
43
45
|
createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
|
|
44
46
|
run(registration.args);
|
|
45
47
|
|
|
46
48
|
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
47
49
|
console.log(`registered MCP server "${serverName}" with Codex:`);
|
|
48
|
-
console.log(`command: ${
|
|
50
|
+
console.log(`command: ${nodeBin} ${entry} ${entryArgs.join(" ")}`);
|
|
49
51
|
console.log(`environment keys: ${registration.environmentKeys.join(", ")}`);
|
|
50
52
|
console.log("\nReconnect claude-bridge once in the existing Codex Desktop task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle. Do not create a replacement task.");
|
|
51
53
|
console.log("Verify claude_bridge_status from that task: autoReload must be enabled, runtime state must be current, and the session policy must match this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
|
|
@@ -20,11 +20,12 @@ import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
|
20
20
|
import { stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
|
|
21
21
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
22
22
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
23
|
+
import { stableInstallPath } from "../src/install-paths.mjs";
|
|
23
24
|
|
|
24
25
|
exitForVersionRequest(import.meta.url);
|
|
25
26
|
|
|
26
|
-
const VERSION = "1.22.
|
|
27
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const VERSION = "1.22.8";
|
|
28
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
28
29
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
29
30
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
30
31
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
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.8";
|
|
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", "
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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", "
|
|
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", "
|
|
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
|
|
36
|
-
this.#boundary ??= { path: canonical,
|
|
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 !==
|
|
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.
|
|
43
|
+
const VERSION = "1.22.8";
|
|
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
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const KEG_PATH = /^(\/.*?)\/Cellar\/([^/]+)\/[^/]+(\/.*)?$/;
|
|
6
|
+
|
|
7
|
+
function resolved(target) {
|
|
8
|
+
try { return fs.realpathSync.native(target); } catch { return null; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Homebrew installs every version of a formula into its own Cellar keg and
|
|
13
|
+
* deletes the old keg on upgrade, while <prefix>/opt/<formula> is retargeted to
|
|
14
|
+
* the current one. A path that outlives this process - written into a client
|
|
15
|
+
* configuration, or watched for an upgrade - therefore has to name the opt
|
|
16
|
+
* alias. A keg path is rewritten only when the alias resolves to the same file
|
|
17
|
+
* right now; every other path is returned unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function stableInstallPath(target, env = process.env) {
|
|
20
|
+
if (process.platform === "win32" || typeof target !== "string") return target;
|
|
21
|
+
const match = KEG_PATH.exec(target);
|
|
22
|
+
if (!match) return target;
|
|
23
|
+
const real = resolved(target);
|
|
24
|
+
if (!real) return target;
|
|
25
|
+
const [, base, formula, rest = ""] = match;
|
|
26
|
+
const prefixes = [...new Set([env.HOMEBREW_PREFIX, base].filter((prefix) => prefix && path.isAbsolute(prefix)))];
|
|
27
|
+
for (const prefix of prefixes) {
|
|
28
|
+
const alias = path.join(prefix, "opt", formula) + rest;
|
|
29
|
+
if (resolved(alias) === real) return alias;
|
|
30
|
+
}
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The node binary to record for later launches. */
|
|
35
|
+
export function stableNodeBin(env = process.env) {
|
|
36
|
+
return stableInstallPath(process.execPath, env);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when `entry` names the running main module, however it was reached.
|
|
41
|
+
* Node resolves the main module with the JavaScript realpath, which follows
|
|
42
|
+
* symlinks but keeps the spelling of the path as typed (case, 8.3 names,
|
|
43
|
+
* substituted drives), so that is the form compared first.
|
|
44
|
+
*/
|
|
45
|
+
export function isMainModule(entry, moduleUrl) {
|
|
46
|
+
if (!entry) return false;
|
|
47
|
+
const file = path.resolve(entry);
|
|
48
|
+
const module = fileURLToPath(moduleUrl);
|
|
49
|
+
let typed;
|
|
50
|
+
try { typed = fs.realpathSync(file); } catch { typed = file; }
|
|
51
|
+
if (typed === module) return true;
|
|
52
|
+
const canonical = resolved(file);
|
|
53
|
+
return canonical !== null && canonical === resolved(module);
|
|
54
|
+
}
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
|
+
import { isMainModule, stableInstallPath } from "./install-paths.mjs";
|
|
8
9
|
|
|
10
|
+
const WORKER_NODE = stableInstallPath(process.execPath);
|
|
9
11
|
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
10
12
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
11
13
|
const MAX_QUEUE = 256;
|
|
@@ -40,7 +42,7 @@ class Worker {
|
|
|
40
42
|
this.retiring = false;
|
|
41
43
|
this.closed = false;
|
|
42
44
|
this.process = fork(path.join(release.directory, "src", entry), [], {
|
|
43
|
-
execPath:
|
|
45
|
+
execPath: WORKER_NODE, execArgv: [], cwd: process.cwd(), windowsHide: true,
|
|
44
46
|
env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
|
|
45
47
|
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
46
48
|
});
|
|
@@ -133,14 +135,16 @@ class Worker {
|
|
|
133
135
|
|
|
134
136
|
export async function runSupervisor(entry, options = {}) {
|
|
135
137
|
if (!ENTRIES.has(entry)) throw new Error("Expected index.mjs, claude-bridge.mjs, or native-relay-companion.mjs");
|
|
136
|
-
const
|
|
138
|
+
const installRoot = stableInstallPath(fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")));
|
|
139
|
+
const currentRoot = () => fs.realpathSync.native(installRoot);
|
|
140
|
+
const root = currentRoot();
|
|
137
141
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
138
142
|
process.stdout.write(`${JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version}\n`);
|
|
139
143
|
return;
|
|
140
144
|
}
|
|
141
145
|
const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
|
|
142
146
|
const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
143
|
-
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT:
|
|
147
|
+
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: installRoot };
|
|
144
148
|
const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
|
|
145
149
|
if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
|
|
146
150
|
const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
|
|
@@ -226,9 +230,10 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
226
230
|
};
|
|
227
231
|
const check = async () => {
|
|
228
232
|
if (switching || !ready || stopping || failure) return;
|
|
233
|
+
let source;
|
|
229
234
|
let revision;
|
|
230
235
|
let routing;
|
|
231
|
-
try { revision = sourceRevision(
|
|
236
|
+
try { source = currentRoot(); revision = sourceRevision(source); routing = routingConfiguration(); }
|
|
232
237
|
catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
|
|
233
238
|
if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
|
|
234
239
|
if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
|
|
@@ -241,7 +246,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
241
246
|
try {
|
|
242
247
|
const state = await previous.control("inspect");
|
|
243
248
|
if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
|
|
244
|
-
const release = createReleaseSnapshot(
|
|
249
|
+
const release = createReleaseSnapshot(source, { expectedRevision: revision });
|
|
245
250
|
candidate = spawn(release, routing);
|
|
246
251
|
const initialized = await candidate.rpc("initialize", initializeParams);
|
|
247
252
|
if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
|
|
@@ -251,7 +256,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
251
256
|
const catalog = await candidate.rpc("tools/list", {});
|
|
252
257
|
if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
|
|
253
258
|
const oldCatalog = await previous.rpc("tools/list", {});
|
|
254
|
-
if (sourceRevision(
|
|
259
|
+
if (sourceRevision(currentRoot()) !== revision) throw new Error("The installation changed before activation");
|
|
255
260
|
if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
|
|
256
261
|
quiesced = true;
|
|
257
262
|
const exported = await previous.control("quiesce");
|
|
@@ -339,6 +344,6 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
339
344
|
log(`supervising ${entry}; installed updates activate after pending work completes`);
|
|
340
345
|
}
|
|
341
346
|
|
|
342
|
-
if (
|
|
347
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
343
348
|
await runSupervisor(process.argv[2]);
|
|
344
349
|
}
|
|
@@ -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", "
|
|
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 &
|
|
38
|
-
this.#keyDirectoryBoundary && (this.#keyDirectoryBoundary.path !== canonical || this.#keyDirectoryBoundary
|
|
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,
|
|
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
|
|
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 !==
|
|
68
|
-
process.platform !== "win32" && ((before.mode &
|
|
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
|
|
84
|
-
this.#keyBoundary ??=
|
|
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
|
|
117
|
-
this.boundary ??= { path: canonical,
|
|
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 !==
|
|
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.
|
|
41
|
+
const VERSION = "1.22.8";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|
package/src/platform.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
|
|
8
|
+
import { stableNodeBin } from "./install-paths.mjs";
|
|
9
|
+
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
|
|
10
12
|
export const IS_MACOS = process.platform === "darwin";
|
|
@@ -230,7 +232,7 @@ export function resolveCodexDesktopNodeBin(
|
|
|
230
232
|
]) {
|
|
231
233
|
if (candidate && isRunnable(candidate)) return { path: candidate, source };
|
|
232
234
|
}
|
|
233
|
-
return { path:
|
|
235
|
+
return { path: stableNodeBin(env), source: "process.execPath" };
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
/**
|
|
@@ -393,7 +395,7 @@ export function spawnEnv(extra = {}) {
|
|
|
393
395
|
const systemDirs = IS_WINDOWS
|
|
394
396
|
? []
|
|
395
397
|
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
396
|
-
const merged = [path.dirname(
|
|
398
|
+
const merged = [path.dirname(stableNodeBin()), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
|
|
397
399
|
.filter(Boolean)
|
|
398
400
|
.filter((dir, index, all) => all.indexOf(dir) === index)
|
|
399
401
|
.join(separator);
|
|
@@ -4,15 +4,16 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
|
|
7
|
+
import { stableInstallPath, stableNodeBin } from "./install-paths.mjs";
|
|
7
8
|
|
|
8
9
|
// Optional UI adapter discovery. Neither bridge startup nor policy evaluation reads
|
|
9
10
|
// this file. Removing the card extension or this descriptor cannot stop messaging.
|
|
10
|
-
export function registerProjectManager({ policyFile, env = process.env, executable =
|
|
11
|
+
export function registerProjectManager({ policyFile, env = process.env, executable = stableNodeBin(env) } = {}) {
|
|
11
12
|
if (!path.isAbsolute(policyFile ?? "")) throw new Error("Manager policy path must be absolute");
|
|
12
13
|
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
13
14
|
const file = path.join(home, ".config", "GptClaudeBridge", "management.json");
|
|
14
15
|
const value = { version: 1, node: executable,
|
|
15
|
-
script: fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)),
|
|
16
|
+
script: stableInstallPath(fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)), env),
|
|
16
17
|
policyFile, configFile: env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json") };
|
|
17
18
|
if (!Object.values(value).filter(v => typeof v === "string").every(v => path.isAbsolute(v))) throw new Error("Manager paths must be absolute");
|
|
18
19
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/src/project-scope.mjs
CHANGED
|
@@ -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 >
|
|
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", "
|
|
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 {
|
package/src/rollout-reader.mjs
CHANGED
|
@@ -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", "
|
|
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
|
|
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 <
|
|
22
|
-
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES,
|
|
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: ${
|
|
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 !==
|
|
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
|
|
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
|
}
|
package/src/rollout-segments.mjs
CHANGED
|
@@ -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
|
}
|
package/src/sites-connector.mjs
CHANGED
|
@@ -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 !==
|
|
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", "
|
|
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 !==
|
|
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 !==
|
|
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 });
|