@minhspark/codex-mcp-bridge 1.19.1 → 1.19.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.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/claude-sender-context.mjs +37 -6
- package/src/index.mjs +10 -2
- package/src/mcp-supervisor.mjs +21 -7
- package/src/native-relay-companion.mjs +1 -1
- package/src/release-snapshot.mjs +94 -12
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.19.2] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Start both bridges from project-local npm installations. When npm hoists the bridge's dependencies into the project's `node_modules`, the package has no `node_modules` of its own, and preparing the immutable runtime failed with `ENOENT` (`scandir …node_modules`) since 1.15.0; only global installs, which keep a nested `node_modules`, started. The runtime now copies the dependency tree Node resolves from the package (hoisted, nested, scoped, aliased, linked and peer dependencies) and nothing else from the project, so it stays isolated from later installation changes. Its revision also covers the project's npm lock, so an `npm install` that changes those dependencies reloads the bridge.
|
|
12
|
+
- Stop the Codex bridge cleanly when its MCP client disconnects. The bridge ends the Windows PowerShell caller checks it started and waits for them to close, and the supervisor gives its worker up to 6 seconds to exit before stopping it. Before, the supervisor stopped the worker after 1 second without waiting, which left those checks running with the bridge's working directory in use. On Windows, deleting that directory right after a test closed its bridge then failed with `EPERM`; the tests no longer retry that deletion.
|
|
13
|
+
|
|
7
14
|
## [1.19.1] - 2026-09-29
|
|
8
15
|
|
|
9
16
|
### Fixed
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.19.
|
|
25
|
+
const VERSION = "1.19.2";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
|
|
|
26
26
|
|
|
27
27
|
exitForVersionRequest(import.meta.url);
|
|
28
28
|
|
|
29
|
-
const VERSION = "1.19.
|
|
29
|
+
const VERSION = "1.19.2";
|
|
30
30
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
31
31
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
32
32
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -7,6 +7,13 @@ import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
|
|
|
7
7
|
const execute = promisify(execFile);
|
|
8
8
|
const MAX_ANCESTORS = 8;
|
|
9
9
|
const INSPECTION_OUTPUT_LIMIT = 16384;
|
|
10
|
+
const activeInspectors = new Set();
|
|
11
|
+
let inspectorsStopping = false;
|
|
12
|
+
|
|
13
|
+
export async function stopProcessInspectors() {
|
|
14
|
+
inspectorsStopping = true;
|
|
15
|
+
await Promise.all([...activeInspectors].map((inspector) => inspector.stop()));
|
|
16
|
+
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* The five-second deadline is what keeps the caller check fail-closed: a read
|
|
@@ -148,6 +155,7 @@ function parseWindowsAncestry(stdout, parentPid, limit) {
|
|
|
148
155
|
* elapsedMs so tests can report where the time went.
|
|
149
156
|
*/
|
|
150
157
|
export function runProcessInspector(command, args, { timeout, startupTimeout, readyMarker, maxBuffer = Infinity, windowsHide = true } = {}) {
|
|
158
|
+
if (inspectorsStopping) return Promise.reject(Object.assign(new Error("Process inspection is shutting down"), { code: "ABORT_ERR" }));
|
|
151
159
|
return new Promise((resolve, reject) => {
|
|
152
160
|
const started = performance.now();
|
|
153
161
|
const child = spawn(command, args, { windowsHide, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -156,36 +164,59 @@ export function runProcessInspector(command, args, { timeout, startupTimeout, re
|
|
|
156
164
|
let readyAfterMs = null;
|
|
157
165
|
let settled = false;
|
|
158
166
|
let timer;
|
|
167
|
+
let failure;
|
|
168
|
+
let killTimer;
|
|
169
|
+
let closeTimer;
|
|
170
|
+
let resolveClosed;
|
|
171
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
172
|
+
const inspector = { stop: () => {
|
|
173
|
+
fail(Object.assign(new Error("Process inspection cancelled during shutdown"), { code: "ABORT_ERR" }));
|
|
174
|
+
return closed.then((error) => { if (error) throw error; });
|
|
175
|
+
} };
|
|
176
|
+
activeInspectors.add(inspector);
|
|
159
177
|
const text = (stream) => Buffer.concat(output[stream]).toString("utf8");
|
|
160
178
|
const settle = (error) => {
|
|
161
179
|
if (settled) return;
|
|
162
180
|
settled = true;
|
|
163
181
|
clearTimeout(timer);
|
|
164
|
-
|
|
182
|
+
clearTimeout(killTimer);
|
|
183
|
+
clearTimeout(closeTimer);
|
|
184
|
+
activeInspectors.delete(inspector);
|
|
185
|
+
resolveClosed(error?.cleanupFailed ? error : null);
|
|
165
186
|
const result = { stdout: text("stdout"), stderr: text("stderr"), pid: child.pid, readyAfterMs, elapsedMs: Math.round(performance.now() - started) };
|
|
166
187
|
if (error) reject(Object.assign(error, result));
|
|
167
188
|
else resolve(result);
|
|
168
189
|
};
|
|
190
|
+
const fail = (error) => {
|
|
191
|
+
if (settled || failure) return;
|
|
192
|
+
failure = error;
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
closeTimer = setTimeout(() => settle(Object.assign(failure, { cleanupFailed: true, message: `${failure.message}; helper ${child.pid} did not close within 5000 ms after termination` })), 5000);
|
|
195
|
+
if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
|
|
196
|
+
child.kill();
|
|
197
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
169
200
|
const arm = (phase, ms) => {
|
|
170
201
|
clearTimeout(timer);
|
|
171
|
-
timer = setTimeout(() =>
|
|
202
|
+
timer = setTimeout(() => fail(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
|
|
172
203
|
};
|
|
173
204
|
if (readyMarker) arm("startup", startupTimeout);
|
|
174
205
|
else arm("inspection", timeout);
|
|
175
206
|
for (const stream of ["stdout", "stderr"]) {
|
|
176
207
|
child[stream].on("data", (chunk) => {
|
|
177
|
-
if (settled) return;
|
|
208
|
+
if (settled || failure) return;
|
|
178
209
|
output[stream].push(chunk);
|
|
179
210
|
size[stream] += chunk.length;
|
|
180
|
-
if (size[stream] > maxBuffer) return
|
|
211
|
+
if (size[stream] > maxBuffer) return fail(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
|
|
181
212
|
if (stream === "stderr" && readyMarker && readyAfterMs === null && text("stderr").includes(readyMarker)) {
|
|
182
213
|
readyAfterMs = Math.round(performance.now() - started);
|
|
183
214
|
arm("inspection", timeout);
|
|
184
215
|
}
|
|
185
216
|
});
|
|
186
217
|
}
|
|
187
|
-
child.on("error",
|
|
188
|
-
child.on("close", (code, signal) => settle(code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal })));
|
|
218
|
+
child.on("error", fail);
|
|
219
|
+
child.on("close", (code, signal) => settle(failure ?? (code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal }))));
|
|
189
220
|
});
|
|
190
221
|
}
|
|
191
222
|
|
package/src/index.mjs
CHANGED
|
@@ -29,12 +29,12 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
29
29
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
30
30
|
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
31
31
|
import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
32
|
-
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
32
|
+
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext, stopProcessInspectors } from "./claude-sender-context.mjs";
|
|
33
33
|
import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.19.
|
|
37
|
+
const VERSION = "1.19.2";
|
|
38
38
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
39
39
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
40
40
|
|
|
@@ -1011,5 +1011,13 @@ registerTool(
|
|
|
1011
1011
|
|
|
1012
1012
|
const transport = new StdioServerTransport();
|
|
1013
1013
|
await server.connect(transport);
|
|
1014
|
+
let shutdownPromise;
|
|
1015
|
+
const shutdown = () => shutdownPromise ??= (async () => {
|
|
1016
|
+
client?.close();
|
|
1017
|
+
await server.close();
|
|
1018
|
+
await stopProcessInspectors();
|
|
1019
|
+
})();
|
|
1020
|
+
process.stdin.once("end", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
|
|
1021
|
+
process.once("disconnect", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
|
|
1014
1022
|
reload.listen();
|
|
1015
1023
|
log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -44,6 +44,7 @@ class Worker {
|
|
|
44
44
|
env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
|
|
45
45
|
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
46
46
|
});
|
|
47
|
+
this.exited = new Promise((resolve) => this.process.once("exit", resolve));
|
|
47
48
|
this.process.stderr.on("data", (data) => process.stderr.write(data));
|
|
48
49
|
this.process.stdin.on("error", (error) => this.fail(error));
|
|
49
50
|
this.process.on("error", (error) => this.fail(error));
|
|
@@ -110,13 +111,23 @@ class Worker {
|
|
|
110
111
|
|
|
111
112
|
async stop() {
|
|
112
113
|
this.retiring = true;
|
|
113
|
-
if (this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
if (this.stopping) return this.stopping;
|
|
115
|
+
this.stopping = (async () => {
|
|
116
|
+
let timer;
|
|
117
|
+
const wait = async (ms) => {
|
|
118
|
+
try {
|
|
119
|
+
return await Promise.race([this.exited.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]);
|
|
120
|
+
} finally { clearTimeout(timer); }
|
|
121
|
+
};
|
|
117
122
|
this.process.stdin.end();
|
|
118
123
|
if (this.process.connected) this.process.disconnect();
|
|
119
|
-
|
|
124
|
+
if (await wait(6000)) return;
|
|
125
|
+
if (this.process.exitCode === null && this.process.signalCode === null) {
|
|
126
|
+
this.process.kill("SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
if (!await wait(5000)) throw new Error(`Bridge worker ${this.process.pid} did not close after termination`);
|
|
129
|
+
})();
|
|
130
|
+
return this.stopping;
|
|
120
131
|
}
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -248,13 +259,16 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
248
259
|
reloads++;
|
|
249
260
|
reason = null;
|
|
250
261
|
if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
251
|
-
await previous.stop();
|
|
262
|
+
await previous.stop().catch((error) => log(`retired ${entry} worker: ${error.message}`));
|
|
252
263
|
workers.delete(previous);
|
|
253
264
|
log(`activated ${entry} revision ${revision.slice(0, 12)}; client connection retained`);
|
|
254
265
|
} catch (error) {
|
|
255
266
|
reason = `Update deferred: ${error.message}`;
|
|
256
267
|
retryAt = Date.now() + Math.max(pollMs, 5000);
|
|
257
|
-
if (candidate) {
|
|
268
|
+
if (candidate) {
|
|
269
|
+
await candidate.stop().catch((stopError) => log(`rejected ${entry} candidate: ${stopError.message}`));
|
|
270
|
+
workers.delete(candidate);
|
|
271
|
+
}
|
|
258
272
|
if (quiesced && active === previous && !previous.closed) {
|
|
259
273
|
try { await previous.control("resume"); }
|
|
260
274
|
catch (resumeError) { failure = `Previous runtime could not resume: ${resumeError.message}`; }
|
|
@@ -34,7 +34,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.19.
|
|
37
|
+
const VERSION = "1.19.2";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
package/src/release-snapshot.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
5
6
|
|
|
6
7
|
function digestFiles(root, files) {
|
|
7
8
|
const hash = createHash("sha256");
|
|
@@ -49,14 +50,14 @@ function digestMetadata(cache) {
|
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
return {
|
|
52
|
-
digest(directory, files) {
|
|
53
|
+
digest(directory, files, sources) {
|
|
53
54
|
const previous = directories.get(directory) ?? load(directory);
|
|
54
55
|
const records = new Map();
|
|
55
56
|
const hash = createHash("sha256");
|
|
56
57
|
if (previous.size !== files.length) dirty.add(directory);
|
|
57
58
|
for (const file of files) {
|
|
58
59
|
if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
|
|
59
|
-
const fullPath = path.join(directory, file);
|
|
60
|
+
const fullPath = sources?.get(file) ?? path.join(directory, file);
|
|
60
61
|
const before = fs.statSync(fullPath, { bigint: true });
|
|
61
62
|
if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
|
|
62
63
|
const current = signature(before);
|
|
@@ -105,25 +106,101 @@ function digestMetadata(cache) {
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
function inventory(root, prefix = "") {
|
|
109
|
+
function inventory(root, prefix = "", omitModules = false) {
|
|
109
110
|
const files = [];
|
|
110
111
|
for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
111
112
|
if (entry.name === ".bin") continue;
|
|
113
|
+
if (omitModules && !prefix && entry.name === "node_modules") continue;
|
|
112
114
|
const relative = path.join(prefix, entry.name);
|
|
113
115
|
const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
|
|
114
|
-
if (stat.isDirectory()) files.push(...inventory(root, relative));
|
|
116
|
+
if (stat.isDirectory()) files.push(...inventory(root, relative, omitModules));
|
|
115
117
|
else if (stat.isFile()) files.push(relative);
|
|
116
118
|
else throw new Error(`Unsupported release file: ${relative}`);
|
|
117
119
|
}
|
|
118
120
|
return files;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
const INSTALLATION_LOCKS = ".bridge-installation-locks.json";
|
|
124
|
+
const dependencySearchPaths = (root, name) => createRequire(path.join(root, "package.json")).resolve.paths(name) ?? [];
|
|
125
|
+
|
|
126
|
+
function installationLocks(root) {
|
|
127
|
+
const saved = path.join(root, INSTALLATION_LOCKS);
|
|
128
|
+
if (fs.existsSync(saved)) return fs.readFileSync(saved, "utf8");
|
|
129
|
+
const local = path.join(root, "node_modules");
|
|
130
|
+
const hashes = [];
|
|
131
|
+
for (const directory of dependencySearchPaths(root, "bridge-dependency-lock")) {
|
|
132
|
+
if (directory === local) continue;
|
|
133
|
+
const lock = path.join(directory, ".package-lock.json");
|
|
134
|
+
if (fs.existsSync(lock)) hashes.push(createHash("sha256").update(fs.readFileSync(lock)).digest("hex"));
|
|
135
|
+
}
|
|
136
|
+
return JSON.stringify(hashes);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dependencyFiles(root) {
|
|
140
|
+
const files = new Map();
|
|
141
|
+
if (fs.existsSync(path.join(root, "node_modules"))) {
|
|
142
|
+
for (const file of inventory(root, "node_modules")) files.set(file, path.join(root, file));
|
|
143
|
+
}
|
|
144
|
+
const queue = [{ source: root, destination: "" }];
|
|
145
|
+
const visited = new Set();
|
|
146
|
+
const packageSources = new Map();
|
|
147
|
+
const packageAt = (directory) => {
|
|
148
|
+
if (packageSources.has(directory)) return packageSources.get(directory);
|
|
149
|
+
const manifest = files.get(path.join(directory, "package.json"));
|
|
150
|
+
if (!manifest) return null;
|
|
151
|
+
const source = fs.realpathSync.native(path.dirname(manifest));
|
|
152
|
+
packageSources.set(directory, source);
|
|
153
|
+
return source;
|
|
154
|
+
};
|
|
155
|
+
for (let index = 0; index < queue.length; index++) {
|
|
156
|
+
const { source, destination } = queue[index];
|
|
157
|
+
if (visited.has(destination)) continue;
|
|
158
|
+
visited.add(destination);
|
|
159
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
|
|
160
|
+
const names = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
|
|
161
|
+
for (const name of [...names].sort()) {
|
|
162
|
+
if (!/^(?:@[a-zA-Z0-9._~-]+\/)?[a-zA-Z0-9._~-]+$/.test(name) || name === "." || name === "..") throw new Error(`Unsafe dependency name: ${name}`);
|
|
163
|
+
let resolved;
|
|
164
|
+
for (const modules of dependencySearchPaths(source, name)) {
|
|
165
|
+
const candidate = path.join(modules, name);
|
|
166
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) { resolved = fs.realpathSync.native(candidate); break; }
|
|
167
|
+
}
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
if (Object.hasOwn(manifest.optionalDependencies ?? {}, name) || manifest.peerDependenciesMeta?.[name]?.optional) continue;
|
|
170
|
+
throw new Error(`Cannot resolve required dependency ${name} from ${source}`);
|
|
171
|
+
}
|
|
172
|
+
let placement;
|
|
173
|
+
let current = destination;
|
|
174
|
+
while (true) {
|
|
175
|
+
if (path.basename(current) !== "node_modules") {
|
|
176
|
+
const candidate = path.join(current, "node_modules", name);
|
|
177
|
+
const installed = packageAt(candidate);
|
|
178
|
+
if (installed) { if (installed === resolved) placement = candidate; break; }
|
|
179
|
+
}
|
|
180
|
+
if (!current) break;
|
|
181
|
+
const parent = path.dirname(current);
|
|
182
|
+
current = parent === "." ? "" : parent;
|
|
183
|
+
}
|
|
184
|
+
if (!placement) {
|
|
185
|
+
const hoisted = path.join("node_modules", name);
|
|
186
|
+
placement = packageAt(hoisted) ? path.join(destination, "node_modules", name) : hoisted;
|
|
187
|
+
const occupied = packageAt(placement);
|
|
188
|
+
if (occupied && occupied !== resolved) throw new Error(`Conflicting dependency resolution for ${name}`);
|
|
189
|
+
for (const file of inventory(resolved, "", true)) files.set(path.join(placement, file), path.join(resolved, file));
|
|
190
|
+
packageSources.set(placement, resolved);
|
|
191
|
+
}
|
|
192
|
+
queue.push({ source: resolved, destination: placement });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return new Map([...files].sort(([left], [right]) => left.localeCompare(right, "en")));
|
|
196
|
+
}
|
|
197
|
+
|
|
121
198
|
export function sourceRevision(root) {
|
|
122
199
|
const files = ["package.json", ...inventory(root, "src")];
|
|
123
200
|
for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
|
|
124
201
|
if (fs.existsSync(path.join(root, file))) files.push(file);
|
|
125
202
|
}
|
|
126
|
-
return digestFiles(root, files);
|
|
203
|
+
return createHash("sha256").update(digestFiles(root, files)).update(installationLocks(root)).digest("hex");
|
|
127
204
|
}
|
|
128
205
|
|
|
129
206
|
export function snapshotRoot(env = process.env) {
|
|
@@ -153,31 +230,36 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
|
|
|
153
230
|
if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
|
|
154
231
|
cache = fs.realpathSync.native(cache);
|
|
155
232
|
const metadata = digestMetadata(cache);
|
|
156
|
-
const dependencies =
|
|
157
|
-
const dependencyRevision = metadata.digest(root, dependencies);
|
|
233
|
+
const dependencies = dependencyFiles(root);
|
|
234
|
+
const dependencyRevision = metadata.digest(root, [...dependencies.keys()], dependencies);
|
|
158
235
|
const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
|
|
159
236
|
const target = path.join(cache, key);
|
|
160
237
|
const validate = (directory) => {
|
|
161
238
|
if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
|
|
162
|
-
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
|
|
239
|
+
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules").sort((left, right) => left.localeCompare(right, "en"))) === dependencyRevision;
|
|
163
240
|
};
|
|
164
241
|
if (fs.existsSync(target)) {
|
|
165
242
|
if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
|
|
166
|
-
|
|
243
|
+
const currentDependencies = dependencyFiles(root);
|
|
244
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision) {
|
|
245
|
+
throw new Error("The installation changed during snapshot verification");
|
|
246
|
+
}
|
|
167
247
|
metadata.flush();
|
|
168
248
|
return { directory: target, revision: expectedRevision, key };
|
|
169
249
|
}
|
|
170
250
|
const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
|
|
171
251
|
try {
|
|
172
252
|
fs.mkdirSync(temporary, { mode: 0o700 });
|
|
173
|
-
const files = ["package.json", ...inventory(root, "src"), ...dependencies];
|
|
253
|
+
const files = ["package.json", ...inventory(root, "src"), ...dependencies.keys()];
|
|
174
254
|
if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
|
|
175
255
|
const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
|
|
176
256
|
for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
|
|
177
257
|
for (const file of files) {
|
|
178
|
-
fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
258
|
+
fs.copyFileSync(dependencies.get(file) ?? path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
179
259
|
}
|
|
180
|
-
|
|
260
|
+
fs.writeFileSync(path.join(temporary, INSTALLATION_LOCKS), installationLocks(root), { flag: "wx", mode: 0o600 });
|
|
261
|
+
const currentDependencies = dependencyFiles(root);
|
|
262
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision || !validate(temporary)) {
|
|
181
263
|
throw new Error("The installation changed while its immutable runtime was being prepared");
|
|
182
264
|
}
|
|
183
265
|
try {
|