@minhspark/codex-mcp-bridge 1.21.0 → 1.21.2

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.21.0",
3
+ "version": "1.21.2",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.21.2] - 2026-10-10
8
+
9
+ ### Added
10
+
11
+ - Show an npm download trend in the repository README, with links to the interactive npmcharts view and GitHub dependents.
12
+ - Publish a source-dated trend SVG from the existing hourly analytics history. Distinguish unavailable data from a reported zero and label stale sources at generation time.
13
+
14
+ ### Fixed
15
+
16
+ - Preserve a telemetry lock replaced by another writer on Windows even when rapid file creation gives both locks the same reported file identity and birth time.
17
+
18
+ ### Upgrade notes
19
+
20
+ - MCP behavior is unchanged. The repository analytics workflow publishes the new SVG after this release reaches `main`.
21
+
22
+ ## [1.21.1] - 2026-10-07
23
+
24
+ ### Fixed
25
+
26
+ - Read project policies, onboarding settings, manager descriptors and automation configuration through bounded, verified file descriptors. Reject files replaced, linked, enlarged or modified during reads, and preserve verified bytes in backups.
27
+ - Pin reply receipt directory identity and recheck receipt and signing-key metadata before and after descriptor reads. Reject nonregular rollout descriptors and open without blocking on POSIX pipes.
28
+ - Encode Windows ACL target paths as data so Unicode quotation marks in directory names cannot alter the PowerShell command.
29
+ - Reject linked and oversized installer configuration, restore configuration through exclusive temporary files, and validate whole-home project aliases before acquiring onboarding locks.
30
+ - Update the MCP SDK to 1.32.1 for GHSA-6qxp-vccf-f47h.
31
+
32
+ ### Upgrade notes
33
+
34
+ - Update to `@minhspark/codex-mcp-bridge@1.21.1` and reconnect existing MCP connections to load the fixes in running processes.
35
+ - The 91 open findings reported for 1.21.0 have been resolved: nine closed after code changes and 82 were reviewed as false positives with individual evidence. Additional findings from the new scans were reviewed against their data-flow sources and file validation controls. The final main-branch scan reported zero open CodeQL alerts without disabling queries or excluding paths.
36
+
7
37
  ## [1.21.0] - 2026-10-06
8
38
 
9
39
  ### Added
package/README.md CHANGED
@@ -16,6 +16,12 @@ Send prompts and replies between **Claude and Codex**, keeping each conversation
16
16
 
17
17
  https://github.com/user-attachments/assets/98b23989-826f-4d7d-9dcb-ad7dd2739095
18
18
 
19
+ ## Package activity
20
+
21
+ [![npm download trend](https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-npm-downloads.svg)](https://www.npmcharts.com/compare/%40minhspark%2Fcodex-mcp-bridge?minimal=true)
22
+
23
+ [Explore the interactive npmcharts trend](https://www.npmcharts.com/compare/%40minhspark%2Fcodex-mcp-bridge?minimal=true) · [View public GitHub dependents](https://github.com/buidangminh23/codex-mcp-bridge/network/dependents). The chart uses the repository's hourly npm analytics snapshots; npm downloads include updates and automation.
24
+
19
25
  ## Release notifications
20
26
 
21
27
  To receive new release notifications, open [this repository](https://github.com/buidangminh23/codex-mcp-bridge), select **Watch → Custom → Releases**, then click **Apply**. Choose GitHub or email delivery in your [notification settings](https://github.com/settings/notifications).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.21.0",
3
+ "version": "1.21.2",
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",
@@ -79,7 +79,7 @@
79
79
  "node": ">=22"
80
80
  },
81
81
  "dependencies": {
82
- "@modelcontextprotocol/sdk": "^1.30.0",
82
+ "@modelcontextprotocol/sdk": "^1.32.1",
83
83
  "zod": "^3.25.76"
84
84
  }
85
85
  }
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.21.0",
4
+ "version": "1.21.2",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
- import fs from "node:fs";
4
3
  import os from "node:os";
5
- import { createProjectScope, editProjectGrant, readProjectPolicy, updateProjectPolicy } from "../src/project-policy.mjs";
4
+ import { createProjectScope, editProjectGrant, readProjectPolicy, updateProjectPolicy, readRegularConfigFile } from "../src/project-policy.mjs";
6
5
  import { exitForVersionRequest } from "../src/cli-version.mjs";
7
6
  import { inspectBridgeProject, prepareBridgeProject } from "../src/project-onboarding.mjs";
8
7
  import { registerProjectManager } from "../src/project-manager-registration.mjs";
@@ -19,9 +18,7 @@ try {
19
18
  if (action === "register-manager" || action === "register-card-settings") console.log(JSON.stringify(registerProjectManager({ policyFile: file }), null, 2));
20
19
  else if (action === "sync-card-settings") {
21
20
  if (!input || !path.isAbsolute(input)) throw new Error('An absolute settings snapshot file is required');
22
- const stat = fs.lstatSync(input);
23
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024) throw new Error('Invalid settings snapshot');
24
- const snapshot = JSON.parse(fs.readFileSync(input, 'utf8'));
21
+ const snapshot = JSON.parse(readRegularConfigFile(input, { label: "Invalid settings snapshot" }));
25
22
  console.log(JSON.stringify(syncCardSettings(file, snapshot, { acknowledgeRender: snapshot.acknowledgeRender === true }), null, 2));
26
23
  }
27
24
  else if (["prepare", "inspect"].includes(action)) {
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { claudeDesktopConfigPath } from "../src/platform.mjs";
7
7
  import { checkCodeRegistration, planCodeRegistration } from "../src/claude-code-registration.mjs";
8
- import { readProjectPolicy } from "../src/project-policy.mjs";
8
+ import { readProjectPolicy, readRegularConfigFile, backupRegularConfigFile } from "../src/project-policy.mjs";
9
9
  import { exitForVersionRequest } from "../src/cli-version.mjs";
10
10
 
11
11
  exitForVersionRequest(import.meta.url);
@@ -15,9 +15,11 @@ const option = (key, fallback) => { const index = args.indexOf(key); return inde
15
15
  const codeFile = option("--code-config", path.join(os.homedir(), ".claude.json"));
16
16
  const desktopFile = option("--desktop-config", claudeDesktopConfigPath());
17
17
  const policyFile = option("--policy", process.env.CODEX_BRIDGE_PROJECT_POLICY);
18
+ const maxConfigBytes = 8 * 1024 * 1024;
19
+ const read = (file) => readRegularConfigFile(file, { maxBytes: maxConfigBytes, missing: true });
18
20
  try {
19
21
  const files = [codeFile, desktopFile];
20
- const originals = files.map((file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
22
+ const originals = files.map(read);
21
23
  const [code, desktop] = originals.map((raw) => raw === null ? {} : JSON.parse(raw.replace(/^\uFEFF/, "")));
22
24
  if (args.includes("--check")) {
23
25
  const result = checkCodeRegistration({ code, desktop });
@@ -37,25 +39,36 @@ try {
37
39
  try {
38
40
  for (let index = 0; index < files.length; index++) {
39
41
  const file = files[index];
40
- const now = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
42
+ const now = read(file);
41
43
  if (now !== originals[index]) throw new Error(`Configuration changed concurrently: ${file}`);
42
44
  if (now === null && index === 1 && !Object.keys(plan.desktop.mcpServers ?? {}).length) continue;
43
- if (now !== null) fs.copyFileSync(file, `${file}.backup-${suffix}`, fs.constants.COPYFILE_EXCL);
45
+ if (now !== null) backupRegularConfigFile(file, `${file}.backup-${suffix}`, now, maxConfigBytes);
44
46
  fs.mkdirSync(path.dirname(file), { recursive: true });
45
47
  const temporary = `${file}.pending-${suffix}`;
46
48
  try {
47
49
  fs.writeFileSync(temporary, contents[index], { flag: "wx", mode: 0o600 });
50
+ if (read(file) !== now) throw new Error(`Configuration changed concurrently: ${file}`);
48
51
  fs.renameSync(temporary, file);
49
52
  } finally { fs.rmSync(temporary, { force: true }); }
50
53
  written.push(index);
51
54
  }
52
55
  } catch (error) {
53
- for (const index of written) {
54
- if (fs.readFileSync(files[index], "utf8") === contents[index]) {
55
- if (originals[index] === null) fs.rmSync(files[index]);
56
- else fs.writeFileSync(files[index], originals[index]);
57
- }
56
+ const remaining = [];
57
+ for (const index of written.reverse()) {
58
+ const file = files[index];
59
+ const temporary = `${file}.rollback-${suffix}`;
60
+ try {
61
+ if (read(file) !== contents[index]) { remaining.push(file); continue; }
62
+ if (originals[index] === null) fs.unlinkSync(file);
63
+ else {
64
+ fs.writeFileSync(temporary, originals[index], { flag: "wx", mode: 0o600 });
65
+ if (read(file) !== contents[index]) { remaining.push(file); continue; }
66
+ fs.renameSync(temporary, file);
67
+ }
68
+ } catch { remaining.push(file); }
69
+ finally { fs.rmSync(temporary, { force: true }); }
58
70
  }
71
+ if (remaining.length) error.message += `; rollback requires review: ${remaining.join(", ")}`;
59
72
  throw error;
60
73
  }
61
74
  console.log("Reopen the original Code task and call codex_bridge_status with the intended project cwd. Update the configuration manager's matching entry to the same policy file. No new per-project MCP entry is needed.");
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.21.0";
26
+ const VERSION = "1.21.2";
27
27
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
29
  const entryArgs = ["native-relay-companion.mjs"];
@@ -128,6 +128,23 @@ export function renderGithubViewsBadge(data, { now = Date.now() } = {}) {
128
128
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="24" viewBox="0 0 ${width} 24" role="img" aria-label="${escapeXml(accessible)}"><title>${escapeXml(`${accessible}. ${detail}`)}</title><rect width="${width}" height="24" rx="4" fill="#555"/><path d="M${labelWidth} 0H${width - 4}Q${width} 0 ${width} 4V20Q${width} 24 ${width - 4} 24H${labelWidth}Z" fill="${color}"/><g fill="#fff" font-family="DejaVu Sans Mono,monospace" font-size="12" text-anchor="middle"><text x="${labelWidth / 2}" y="16">${escapeXml(label)}</text><text x="${labelWidth + messageWidth / 2}" y="16">${escapeXml(message)}</text></g></svg>\n`;
129
129
  }
130
130
 
131
+ export function renderNpmDownloadsChart(data, { now = Date.now() } = {}) {
132
+ const snapshot = [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row.npm);
133
+ const collectedAt = snapshot?.sourceCollectedAt?.npm;
134
+ const rows = Object.entries(data.daily.npm || {}).filter(([day, row]) => date(day) && number(row?.downloads) != null).sort(([left], [right]) => left.localeCompare(right)).slice(-30);
135
+ const values = rows.map(([, row]) => row.downloads);
136
+ const maximum = Math.max(1, ...values);
137
+ const total = values.reduce((sum, value) => sum + value, 0);
138
+ const status = !date(collectedAt) ? 'Source unavailable' : now - Date.parse(collectedAt) > staleAfter ? 'Source stale at generation' : 'Source collected';
139
+ const summary = rows.length ? `${format(total)} downloads across ${rows.length} reported ${rows.length === 1 ? 'day' : 'days'} · ${rows[0][0]} to ${rows.at(-1)[0]} UTC` : 'No download history available';
140
+ const points = rows.map(([day, row], index) => ({ day, value: row.downloads, x: 76 + index * 680 / Math.max(1, rows.length - 1), y: 226 - row.downloads * 132 / maximum }));
141
+ const path = points.map((point, index) => `${index ? 'L' : 'M'}${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
142
+ const grid = [94, 160, 226].map(y => `<path d="M76 ${y}H756" stroke="#d8dee4" stroke-width="1"/>`).join('');
143
+ const axis = points.length ? `${grid}<text x="66" y="98" text-anchor="end" font-size="12" fill="#57606a">${escapeXml(format(maximum))}</text><text x="66" y="230" text-anchor="end" font-size="12" fill="#57606a">0</text><text x="76" y="250" font-size="12" fill="#57606a">${escapeXml(rows[0][0])}</text><text x="756" y="250" text-anchor="end" font-size="12" fill="#57606a">${escapeXml(rows.at(-1)[0])}</text>` : '';
144
+ const line = points.length ? `<path d="${path}" fill="none" stroke="#2f81f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>${points.map(point => `<circle cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="3" fill="#2f81f7"><title>${escapeXml(`${point.day}: ${format(point.value)} downloads`)}</title></circle>`).join('')}` : '<text x="416" y="167" text-anchor="middle" font-size="17" fill="#57606a">Download data unavailable</text>';
145
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="820" height="300" viewBox="0 0 820 300" role="img" aria-label="npm download trend"><title>npm download trend</title><desc>${escapeXml(`${summary}. ${status}: ${displayTime(collectedAt)}.`)}</desc><rect width="820" height="300" rx="12" fill="#ffffff"/><rect x="0.5" y="0.5" width="819" height="299" rx="11.5" fill="none" stroke="#d8dee4"/><g font-family="Arial, sans-serif"><text x="32" y="38" font-size="21" font-weight="bold" fill="#24292f">npm download trend</text><text x="32" y="65" font-size="14" fill="#57606a">${escapeXml(summary)}</text>${axis}${line}<text x="32" y="280" font-size="12" fill="${status === 'Source collected' ? '#57606a' : '#9a6700'}">${escapeXml(`npm API · ${status} · ${displayTime(collectedAt)}`)}</text></g></svg>\n`;
146
+ }
147
+
131
148
  export function renderLiveDashboard(data) {
132
149
  const latest = source => [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row[source]);
133
150
  const npm = latest('npm');
@@ -216,9 +233,9 @@ export function publicFiles(history, usage) {
216
233
  const usageDays = Object.fromEntries((data.usage?.daily || []).map(row => [row.day, row.installations]));
217
234
  const days = [...new Set([...Object.keys(data.daily.views), ...Object.keys(data.daily.clones), ...Object.keys(data.daily.npm), ...Object.keys(usageDays)])].sort().reverse();
218
235
  const table = ['| Date (UTC) | Views | Unique visitors | Clones | Unique cloners | npm downloads | Active installations |', '|---|---:|---:|---:|---:|---:|---:|', ...days.map(day => `| ${day} | ${[data.daily.views[day]?.count, data.daily.views[day]?.uniques, data.daily.clones[day]?.count, data.daily.clones[day]?.uniques, data.daily.npm[day]?.downloads, usageDays[day]].map(format).join(' | ')} |`)].join('\n');
219
- const readme = `# Public repository analytics\n\n![Repository dashboard](dashboard.svg)\n\n[Aggregate JSON history](data.json) · [Repository](https://github.com/${data.repo})\n\nAll dates and source windows use UTC. Source timestamps on the dashboard show when each metric was last available. Downloads and clones include automation and do not measure people. Unique visitors/cloners apply only to the source window; never sum daily unique counts across dates. Active installations cover consenting installations only, with distinct counts for each whole period. Operating system and version breakdowns cover 30 days; an installation may appear in multiple version groups after upgrading.\n\nThis branch contains only aggregate statistics. No installation identifiers, prompts, account details, credentials, private error messages, or local paths are published. Missing metrics mean unavailable, not zero.\n\n## Daily history\n\nEvery archived source day is listed below, newest first. Active installations count consenting installations that reported that UTC day.\n\n${table}\n`;
236
+ const readme = `# Public repository analytics\n\n![Repository dashboard](dashboard.svg)\n\n![npm download trend](stats-npm-downloads.svg)\n\n[Aggregate JSON history](data.json) · [Repository](https://github.com/${data.repo})\n\nAll dates and source windows use UTC. Source timestamps on the dashboard show when each metric was last available. Downloads and clones include automation and do not measure people. Unique visitors/cloners apply only to the source window; never sum daily unique counts across dates. Active installations cover consenting installations only, with distinct counts for each whole period. Operating system and version breakdowns cover 30 days; an installation may appear in multiple version groups after upgrading.\n\nThis branch contains only aggregate statistics. No installation identifiers, prompts, account details, credentials, private error messages, or local paths are published. Missing metrics mean unavailable, not zero.\n\n## Daily history\n\nEvery archived source day is listed below, newest first. Active installations count consenting installations that reported that UTC day.\n\n${table}\n`;
220
237
  const pagesUrl = `https://${data.repo.split('/')[0]}.github.io/${data.repo.split('/')[1]}/`;
221
- return { 'stats-github-views.svg': renderGithubViewsBadge(data), 'dashboard.svg': renderPublicDashboard(data), 'README.md': readme.replace('![Repository dashboard]', `[Live dashboard · refreshes every 30 seconds](${pagesUrl})\n\n![Repository dashboard]`), 'data.json': `${JSON.stringify(data, null, 2)}\n`, 'index.html': renderLiveDashboard(data) };
238
+ return { 'stats-github-views.svg': renderGithubViewsBadge(data), 'stats-npm-downloads.svg': renderNpmDownloadsChart(data), 'dashboard.svg': renderPublicDashboard(data), 'README.md': readme.replace('![Repository dashboard]', `[Live dashboard · refreshes every 30 seconds](${pagesUrl})\n\n![Repository dashboard]`), 'data.json': `${JSON.stringify(data, null, 2)}\n`, 'index.html': renderLiveDashboard(data) };
222
239
  }
223
240
 
224
241
  export function githubEnvironment(token, env = process.env) {
@@ -29,7 +29,7 @@ import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
29
29
 
30
30
  exitForVersionRequest(import.meta.url);
31
31
 
32
- const VERSION = "1.21.0";
32
+ const VERSION = "1.21.2";
33
33
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
34
34
  const FORWARD_MIN_INTERVAL_MS = 5000;
35
35
  const FORWARD_MAX_PER_SESSION = 50;
@@ -6,6 +6,7 @@ import { homeDir } from "./platform.mjs";
6
6
 
7
7
  const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
8
8
  const LIMIT = 256 * 1024;
9
+ const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeMs", "ctimeMs"].every(field => left[field] === right[field]);
9
10
 
10
11
  function owner(context) {
11
12
  const { sessionId, taskId } = context?.caller ?? {};
@@ -19,16 +20,20 @@ function owner(context) {
19
20
  // These are observation bindings, never permission grants. Every continuation
20
21
  // still passes the normal live sender, account, workspace and rollout checks.
21
22
  export class DesktopReplyReceipts {
23
+ #boundary;
22
24
  constructor({ directory = path.join(process.env.CODEX_HOME ?? path.join(homeDir(), ".codex"), "bridge-reply-receipts") } = {}) {
23
25
  this.directory = path.resolve(directory);
24
26
  }
25
27
 
26
28
  file(id, create = false) {
27
- if (!ID.test(id)) throw new Error("Invalid reply deliveryId");
29
+ if (typeof id !== "string" || !ID.test(id)) throw new Error("Invalid reply deliveryId");
28
30
  if (create) fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
29
31
  const info = fs.lstatSync(this.directory);
30
32
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe reply receipt directory");
31
- return path.join(this.directory, `${id}.json`);
33
+ const canonical = fs.realpathSync.native(this.directory);
34
+ if (this.#boundary && (this.#boundary.path !== canonical || this.#boundary.dev !== info.dev || this.#boundary.ino !== info.ino)) throw new Error("Reply receipt directory changed");
35
+ this.#boundary ??= { path: canonical, dev: info.dev, ino: info.ino };
36
+ return path.join(canonical, `${id}.json`);
32
37
  }
33
38
 
34
39
  create(delivered, context) {
@@ -51,10 +56,10 @@ export class DesktopReplyReceipts {
51
56
  const file = this.file(id);
52
57
  const before = fs.lstatSync(file);
53
58
  if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > LIMIT) throw new Error("Unsafe reply receipt");
54
- const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
59
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
55
60
  try {
56
61
  const opened = fs.fstatSync(fd);
57
- if (!opened.isFile() || opened.nlink !== 1 || opened.size > LIMIT || opened.ino !== before.ino || opened.dev !== before.dev) throw new Error("Reply receipt changed while opening");
62
+ if (!opened.isFile() || !sameFile(before, opened)) throw new Error("Reply receipt changed while opening");
58
63
  const buffer = Buffer.alloc(LIMIT + 1);
59
64
  let length = 0;
60
65
  for (;;) {
@@ -64,7 +69,8 @@ export class DesktopReplyReceipts {
64
69
  if (length > LIMIT) throw new Error("Reply receipt exceeds its read limit");
65
70
  }
66
71
  const after = fs.fstatSync(fd);
67
- if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || length !== opened.size) throw new Error("Reply receipt changed while reading");
72
+ const current = fs.lstatSync(this.file(id));
73
+ if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || length !== opened.size) throw new Error("Reply receipt changed while reading");
68
74
  const receipt = JSON.parse(buffer.subarray(0, length).toString("utf8"));
69
75
  const expected = owner(context);
70
76
  if (receipt.version !== 1 || receipt.deliveryId !== id || !receipt.owner ||
package/src/index.mjs CHANGED
@@ -39,7 +39,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
39
39
 
40
40
  exitForVersionRequest(import.meta.url);
41
41
 
42
- const VERSION = "1.21.0";
42
+ const VERSION = "1.21.2";
43
43
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
44
44
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
45
45
 
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { readProjectPolicy } from "./project-policy.mjs";
4
+ import { readProjectPolicy, readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
5
5
 
6
6
  export const MESSAGE_TOOLS = Object.freeze([
7
7
  "mcp__codex-bridge__send_to_codex_thread",
@@ -25,10 +25,7 @@ The user enabled automatic messaging with the installed bridge. This is standing
25
25
  }
26
26
 
27
27
  function read(file) {
28
- if (!fs.existsSync(file)) return null;
29
- const stat = fs.lstatSync(file);
30
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 2 * 1024 * 1024) throw new Error(`Refusing nonregular, linked or oversized configuration: ${file}`);
31
- return fs.readFileSync(file, "utf8");
28
+ return readRegularConfigFile(file, { maxBytes: 2 * 1024 * 1024, missing: true });
32
29
  }
33
30
  const json = (raw) => raw === null ? {} : JSON.parse(raw.replace(/^\uFEFF/, ""));
34
31
  function matches(rule, tool) {
@@ -68,7 +65,7 @@ export function applyMessageAutomation(plan) {
68
65
  if (read(item.file) !== item.before) throw new Error(`Configuration changed concurrently: ${item.file}`);
69
66
  if (item.before === item.after) continue;
70
67
  fs.mkdirSync(path.dirname(item.file), { recursive: true });
71
- if (item.before !== null) fs.copyFileSync(item.file, `${item.file}.backup-${suffix}`, fs.constants.COPYFILE_EXCL);
68
+ if (item.before !== null) backupRegularConfigFile(item.file, `${item.file}.backup-${suffix}`, item.before, 2 * 1024 * 1024);
72
69
  const temporary = `${item.file}.pending-${suffix}`;
73
70
  try {
74
71
  fs.writeFileSync(temporary, item.after, { flag: "wx", mode: 0o600 });
@@ -81,7 +78,13 @@ export function applyMessageAutomation(plan) {
81
78
  for (const item of changed.reverse()) {
82
79
  if (read(item.file) !== item.after) continue;
83
80
  if (item.before === null) fs.rmSync(item.file);
84
- else fs.writeFileSync(item.file, item.before);
81
+ else {
82
+ const rollback = `${item.file}.rollback-${suffix}`;
83
+ try {
84
+ fs.writeFileSync(rollback, item.before, { flag: "wx", mode: 0o600 });
85
+ if (read(item.file) === item.after) fs.renameSync(rollback, item.file);
86
+ } finally { fs.rmSync(rollback, { force: true }); }
87
+ }
85
88
  }
86
89
  throw error;
87
90
  }
@@ -4,6 +4,8 @@ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { homeDir } from "./platform.mjs";
6
6
 
7
+ const sameFile = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeMs", "ctimeMs"].every(field => left[field] === right[field]);
8
+
7
9
  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);
8
10
  export function creationRequestHash(args) {
9
11
  const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object"
@@ -65,17 +67,18 @@ export class NativeCreationReceipts {
65
67
  if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== 32 ||
66
68
  process.platform !== "win32" && ((before.mode & 0o077) || before.uid !== process.getuid())) throw Error("Unsafe native creation signing key");
67
69
  if (process.platform === "win32" && !this.#keyBoundary) this.#assertWindowsPrivate(file, false);
68
- const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
70
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
69
71
  let key;
70
72
  try {
71
73
  const opened = fs.fstatSync(fd);
72
- if (opened.dev !== before.dev || opened.ino !== before.ino) throw Error("Native creation signing key changed");
74
+ if (!opened.isFile() || !sameFile(before, opened)) throw Error("Native creation signing key changed");
73
75
  const bytes = Buffer.alloc(33);
74
76
  let count = 0, read;
75
77
  while (count < bytes.length && (read = fs.readSync(fd, bytes, count, bytes.length - count, count))) count += read;
76
78
  key = bytes.subarray(0, count);
77
79
  const after = fs.fstatSync(fd);
78
- if (key.length !== 32 || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw Error("Native creation signing key changed");
80
+ const current = fs.lstatSync(file);
81
+ if (key.length !== 32 || current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current)) throw Error("Native creation signing key changed");
79
82
  } finally { fs.closeSync(fd); }
80
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");
81
84
  this.#keyBoundary ??= { dev: before.dev, ino: before.ino };
@@ -83,15 +86,15 @@ export class NativeCreationReceipts {
83
86
  return this.#key;
84
87
  }
85
88
  #protectWindowsPrivate(file, directory) {
86
- const quoted = file.replaceAll("'", "''");
89
+ const encodedPath = Buffer.from(file, "utf8").toString("base64");
87
90
  const securityType = directory ? "DirectorySecurity" : "FileSecurity";
88
91
  const inheritance = directory ? "ContainerInherit,ObjectInherit" : "None";
89
- const script = `$ErrorActionPreference='Stop';$s=[Security.Principal.WindowsIdentity]::GetCurrent().User;$a=New-Object Security.AccessControl.${securityType};$a.SetOwner($s);$a.SetAccessRuleProtection($true,$false);$r=New-Object Security.AccessControl.FileSystemAccessRule($s,'FullControl','${inheritance}','None','Allow');$a.AddAccessRule($r);Set-Acl -LiteralPath '${quoted}' -AclObject $a`;
92
+ const script = `$ErrorActionPreference='Stop';$s=[Security.Principal.WindowsIdentity]::GetCurrent().User;$a=New-Object Security.AccessControl.${securityType};$a.SetOwner($s);$a.SetAccessRuleProtection($true,$false);$r=New-Object Security.AccessControl.FileSystemAccessRule($s,'FullControl','${inheritance}','None','Allow');$a.AddAccessRule($r);Set-Acl -LiteralPath ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))) -AclObject $a`;
90
93
  this.#runWindowsAclScript(script);
91
94
  }
92
95
  #assertWindowsPrivate(file, directory) {
93
- const quoted = file.replaceAll("'", "''");
94
- const script = `$ErrorActionPreference='Stop';$a=Get-Acl -LiteralPath '${quoted}';$s=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;if(${directory ? "$true" : "$false"} -and !$a.AreAccessRulesProtected){throw 'Unprotected native authority'};if($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $s){throw 'Unexpected native authority owner'};foreach($r in $a.Access){if($r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -ne $s -or $r.AccessControlType -ne 'Allow'){throw 'Unexpected native authority access'}};if($a.Access.Count -ne 1){throw 'Ambiguous native authority access'}`;
96
+ const encodedPath = Buffer.from(file, "utf8").toString("base64");
97
+ const script = `$ErrorActionPreference='Stop';$a=Get-Acl -LiteralPath ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}')));$s=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;if(${directory ? "$true" : "$false"} -and !$a.AreAccessRulesProtected){throw 'Unprotected native authority'};if($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $s){throw 'Unexpected native authority owner'};foreach($r in $a.Access){if($r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -ne $s -or $r.AccessControlType -ne 'Allow'){throw 'Unexpected native authority access'}};if($a.Access.Count -ne 1){throw 'Ambiguous native authority access'}`;
95
98
  this.#runWindowsAclScript(script);
96
99
  }
97
100
  #runWindowsAclScript(script) {
@@ -122,14 +125,15 @@ export class NativeCreationReceipts {
122
125
  const before = fs.lstatSync(file);
123
126
  receiptFound = true;
124
127
  if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 1024 * 1024) throw Error("Unsafe native creation receipt");
125
- fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
128
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
126
129
  const opened = fs.fstatSync(fd);
127
- if (opened.ino !== before.ino || opened.dev !== before.dev) throw Error("Native creation receipt changed");
130
+ if (!opened.isFile() || !sameFile(before, opened)) throw Error("Native creation receipt changed");
128
131
  const bytes = Buffer.alloc(1024 * 1024 + 1);
129
132
  let count = 0, n;
130
133
  while ((n = fs.readSync(fd, bytes, count, bytes.length - count, count))) { count += n; if (count > 1024 * 1024) throw Error("Oversized native creation receipt"); }
131
134
  const after = fs.fstatSync(fd);
132
- if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || count !== opened.size) throw Error("Native creation receipt changed");
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");
133
137
  const row = JSON.parse(bytes.subarray(0, count).toString());
134
138
  if (row.version !== 1 || row.id !== id || row.hash !== hash || creationRequestHash(row.args) !== hash ||
135
139
  !accounts || ["claude", "codex"].some(k => !accounts[k] || row.accounts?.[k] !== accounts[k]) ||
@@ -36,7 +36,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
36
36
 
37
37
  exitForVersionRequest(import.meta.url);
38
38
 
39
- const VERSION = "1.21.0";
39
+ const VERSION = "1.21.2";
40
40
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
41
41
 
42
42
  function errorResponse(code, message, sent) {
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
6
7
 
7
8
  // Optional UI adapter discovery. Neither bridge startup nor policy evaluation reads
8
9
  // this file. Removing the card extension or this descriptor cannot stop messaging.
@@ -16,18 +17,14 @@ export function registerProjectManager({ policyFile, env = process.env, executab
16
17
  if (!Object.values(value).filter(v => typeof v === "string").every(v => path.isAbsolute(v))) throw new Error("Manager paths must be absolute");
17
18
  fs.mkdirSync(path.dirname(file), { recursive: true });
18
19
  const next = JSON.stringify(value, null, 2) + "\n";
19
- let before = null;
20
- if (fs.existsSync(file)) {
21
- const stat = fs.lstatSync(file);
22
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 16384) throw new Error("Invalid manager descriptor");
23
- before = fs.readFileSync(file, "utf8");
24
- }
20
+ const read = () => readRegularConfigFile(file, { maxBytes: 16384, missing: true, label: "Invalid manager descriptor" });
21
+ const before = read();
25
22
  if (next === before) return { file, changed: false };
26
23
  const temp = file + ".pending-" + randomUUID();
27
24
  try {
28
25
  fs.writeFileSync(temp, next, { flag: "wx", mode: 0o600 });
29
- if ((fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null) !== before) throw new Error("Manager descriptor changed concurrently");
30
- if (before !== null) fs.copyFileSync(file, file + ".backup-" + randomUUID(), fs.constants.COPYFILE_EXCL);
26
+ if (read() !== before) throw new Error("Manager descriptor changed concurrently");
27
+ if (before !== null) backupRegularConfigFile(file, file + ".backup-" + randomUUID(), before, 16384);
31
28
  fs.renameSync(temp, file);
32
29
  } finally { fs.rmSync(temp, { force: true }); }
33
30
  return { file, changed: true };
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { randomUUID } from "node:crypto";
5
- import { directoryBinding, repositoryBinding, editProjectGrant, readProjectPolicy, createProjectScope } from "./project-policy.mjs";
5
+ import { directoryBinding, repositoryBinding, editProjectGrant, readProjectPolicy, parseProjectPolicy, createProjectScope, readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
6
6
 
7
7
  const MAX_CONFIG_BYTES = 8 * 1024 * 1024;
8
8
  const object = value => value && typeof value === "object" && !Array.isArray(value);
@@ -20,12 +20,7 @@ export function onboardingPaths(env = process.env) {
20
20
  }
21
21
 
22
22
  function read(file, maxBytes = MAX_CONFIG_BYTES) {
23
- let stat;
24
- try { stat = fs.lstatSync(file); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
25
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maxBytes) throw new Error(`Refusing linked, nonregular or oversized configuration: ${file}`);
26
- const raw = fs.readFileSync(file, "utf8");
27
- if (Buffer.byteLength(raw) > maxBytes) throw new Error(`Configuration exceeds size limit: ${file}`);
28
- return raw;
23
+ return readRegularConfigFile(file, { maxBytes, missing: true });
29
24
  }
30
25
  function parse(raw) {
31
26
  const value = raw === null ? {} : JSON.parse(raw.replace(/^\uFEFF/, ""));
@@ -70,7 +65,7 @@ function load(cwd, options) {
70
65
  const config = parse(configRaw);
71
66
  const trust = trustEntry(config, project);
72
67
  const policyRaw = read(files.policyFile, 256 * 1024);
73
- const policy = policyRaw === null ? { version: 1, grants: [], denies: [] } : readProjectPolicy(files.policyFile).policy;
68
+ const policy = policyRaw === null ? { version: 1, grants: [], denies: [] } : parseProjectPolicy(policyRaw).policy;
74
69
  const denials = denied(policy, project);
75
70
  const authorized = policyRaw !== null && createProjectScope(files.policyFile, options).allows(project.directory.path);
76
71
  return { files, project, configRaw, config, trust, policyRaw, policy, denials, authorized };
@@ -102,6 +97,7 @@ export function inspectBridgeProject({ cwd, taskCards = false }, options = {}) {
102
97
  // compare before replacement, and roll back only bytes this transaction still owns.
103
98
  export function prepareBridgeProject({ cwd, reauthorize = false, taskCards = false }, options = {}) {
104
99
  const files = onboardingPaths(options.env);
100
+ target(cwd, { ...options, home: files.home });
105
101
  const locks = [], changed = [], backups = [];
106
102
  const suffix = randomUUID();
107
103
  const pending = [];
@@ -131,7 +127,7 @@ export function prepareBridgeProject({ cwd, reauthorize = false, taskCards = fal
131
127
  if (read(item.file) !== item.before) throw new Error("Configuration changed concurrently; retry inspection before applying");
132
128
  if (item.before !== null) {
133
129
  const backup = `${item.file}.backup-${suffix}`;
134
- fs.copyFileSync(item.file, backup, fs.constants.COPYFILE_EXCL);
130
+ backupRegularConfigFile(item.file, backup, item.before, MAX_CONFIG_BYTES);
135
131
  backups.push(backup);
136
132
  }
137
133
  item.temp = `${item.file}.pending-${suffix}`;
@@ -10,6 +10,38 @@ const inside = (root, candidate) => {
10
10
  };
11
11
  const equal = (a, b) => path.relative(a, b) === "";
12
12
 
13
+ export function readRegularConfigFile(file, { maxBytes = MAX_BYTES, missing = false, encoding = "utf8", label = "Configuration" } = {}) {
14
+ let entry;
15
+ try { entry = fs.lstatSync(file, { bigint: true }); }
16
+ catch (error) { if (missing && error.code === "ENOENT") return null; throw error; }
17
+ const valid = stat => stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n && stat.size <= BigInt(maxBytes);
18
+ const matches = (left, right) => ["dev", "ino", "size", "mtimeNs", "ctimeNs", "nlink"].every(field => left[field] === right[field]);
19
+ if (!valid(entry)) throw new Error(`${label}: refusing linked or nonregular file, or exceeded size limit`);
20
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
21
+ try {
22
+ const before = fs.fstatSync(fd, { bigint: true });
23
+ if (!valid(before) || !matches(entry, before)) throw new Error(`${label} changed during access`);
24
+ const data = Buffer.alloc(Number(before.size) + 1);
25
+ let offset = 0;
26
+ while (offset < data.length) {
27
+ const count = fs.readSync(fd, data, offset, data.length - offset, offset);
28
+ if (!count) break;
29
+ offset += count;
30
+ }
31
+ const after = fs.fstatSync(fd, { bigint: true });
32
+ const current = fs.lstatSync(file, { bigint: true });
33
+ if (offset !== Number(before.size) || !valid(after) || !valid(current) || !matches(before, after) || !matches(after, current)) throw new Error(`${label} changed during access`);
34
+ const bytes = data.subarray(0, offset);
35
+ return encoding === null ? bytes : bytes.toString(encoding);
36
+ } finally { fs.closeSync(fd); }
37
+ }
38
+
39
+ export function backupRegularConfigFile(file, backup, expected, maxBytes = MAX_BYTES) {
40
+ const bytes = readRegularConfigFile(file, { maxBytes, encoding: null });
41
+ if (bytes.toString("utf8") !== expected) throw new Error("Configuration changed concurrently before backup");
42
+ fs.writeFileSync(backup, bytes, { flag: "wx", mode: 0o600 });
43
+ }
44
+
13
45
  export function directoryBinding(input) {
14
46
  if (typeof input !== "string" || !path.isAbsolute(input)) throw new Error("Project paths must be absolute directories");
15
47
  const resolved = fs.realpathSync.native(input);
@@ -59,10 +91,11 @@ function validateBinding(binding) {
59
91
 
60
92
  export function readProjectPolicy(file) {
61
93
  if (!path.isAbsolute(file)) throw new Error("CODEX_BRIDGE_PROJECT_POLICY must be an absolute file path");
62
- const stat = fs.statSync(file);
63
- if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("Project policy exceeds its file size limit");
64
- const raw = fs.readFileSync(file, "utf8");
65
- if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error("Project policy exceeds its file size limit");
94
+ const raw = readRegularConfigFile(file, { label: "Project policy" });
95
+ return parseProjectPolicy(raw);
96
+ }
97
+
98
+ export function parseProjectPolicy(raw) {
66
99
  const policy = JSON.parse(raw);
67
100
  if (policy.version !== 1 || !Array.isArray(policy.grants) || !Array.isArray(policy.denies) || policy.grants.length + policy.denies.length > 512) throw new Error("Invalid project policy schema");
68
101
  for (const entry of [...policy.grants, ...policy.denies]) {
@@ -145,14 +178,16 @@ export function updateProjectPolicy(file, edit) {
145
178
  const handle = fs.openSync(lock, "wx", 0o600);
146
179
  const temporary = `${file}.pending-${randomUUID()}`;
147
180
  try {
148
- const exists = fs.existsSync(file);
149
- const policy = exists ? readProjectPolicy(file).policy : { version: 1, grants: [], denies: [] };
181
+ const original = readRegularConfigFile(file, { missing: true, label: "Project policy" });
182
+ const exists = original !== null;
183
+ const policy = exists ? parseProjectPolicy(original).policy : { version: 1, grants: [], denies: [] };
150
184
  const before = JSON.stringify(policy);
151
185
  edit(policy);
152
186
  if (exists && JSON.stringify(policy) === before) return policy;
153
187
  fs.writeFileSync(temporary, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx", mode: 0o600 });
154
188
  readProjectPolicy(temporary);
155
- if (exists) fs.copyFileSync(file, `${file}.backup-${randomUUID()}`, fs.constants.COPYFILE_EXCL);
189
+ if (readRegularConfigFile(file, { missing: true }) !== original) throw new Error("Project policy changed concurrently");
190
+ if (exists) backupRegularConfigFile(file, `${file}.backup-${randomUUID()}`, original);
156
191
  fs.renameSync(temporary, file);
157
192
  return policy;
158
193
  } finally {
@@ -10,9 +10,10 @@ export function scanRollout(file, { maxRecordBytes, onRecord, onChunk = () => {}
10
10
  if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 1) throw new Error("The rollout record read limit is invalid");
11
11
  const named = fs.lstatSync(file);
12
12
  if (!named.isFile() || named.isSymbolicLink()) throw new Error("The rollout is not a regular file");
13
- const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
13
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
14
14
  try {
15
15
  const before = fs.fstatSync(descriptor);
16
+ if (!before.isFile()) throw new Error("The opened rollout is not a regular file");
16
17
  if (!sameVersion(named, before)) throw new Error("The rollout changed while opening");
17
18
  if (before.size === 0) throw new Error("The rollout is empty (0 bytes)");
18
19
  let parts = [], length = 0, start = 0, position = 0;
package/src/telemetry.mjs CHANGED
@@ -51,11 +51,12 @@ async function locked(directory, action) {
51
51
  throw new Error('Telemetry settings are busy. Locks never expire automatically; inspect active telemetry processes before repairing a leftover lock.');
52
52
  }
53
53
  let owned;
54
- try { owned = await lock.stat(); return await action(); }
54
+ const token = randomUUID();
55
+ try { await lock.writeFile(token); owned = await lock.stat({ bigint: true }); return await action(); }
55
56
  finally {
56
57
  try {
57
- const current = await lstat(filename).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
58
- if (owned && current?.isFile() && !current.isSymbolicLink() && current.nlink === 1 && owned.dev === current.dev && owned.ino === current.ino && owned.birthtimeMs === current.birthtimeMs) await unlink(filename);
58
+ const current = await lstat(filename, { bigint: true }).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
59
+ if (owned && current?.isFile() && !current.isSymbolicLink() && current.nlink === 1n && owned.dev === current.dev && owned.ino === current.ino && owned.birthtimeNs === current.birthtimeNs && owned.ctimeNs === current.ctimeNs && owned.size === current.size) await unlink(filename);
59
60
  } finally { await lock.close(); }
60
61
  }
61
62
  }