openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
642 lines (641 loc) • 23.8 kB
JavaScript
import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs";
import "./errors-O33D01XW.mjs";
import { t as createSubsystemLogger } from "./subsystem-I-uqdx4f.mjs";
import { i as UPDATE_RUNNER_TIMEOUT_MS } from "./update-run-timeouts-Byb-PlTk.mjs";
import { n as awaitWithinDeadline, t as ABSOLUTE_DEADLINE_EXPIRED } from "./absolute-deadline-DoGHpXtD.mjs";
import { i as readPackageVersion } from "./package-json-DuhFvY5l.mjs";
import { constants } from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import { availableParallelism } from "node:os";
import { createHash } from "node:crypto";
import { Worker } from "node:worker_threads";
//#region src/infra/package-update-integrity-hasher.ts
const log$1 = createSubsystemLogger("update/package-integrity");
const workerSource = String.raw`
const fs = require("node:fs");
const { createHash } = require("node:crypto");
const { parentPort, workerData: { cancel } } = require("node:worker_threads");
const buffer = Buffer.allocUnsafe(64 * 1024);
const fields = ["dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs", "ctimeNs"];
const unchanged = (job, stat) => job.ino !== 0n && fields.every(key => job[key] === stat[key]);
const cancelled = Symbol("cancelled");
// The owner sets cancellation when its deadline expires; start no further I/O.
const admit = () => {
if (Atomics.load(cancel, 0)) throw cancelled;
};
function hash(job) {
admit();
const fd = fs.openSync(job.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
try {
admit();
if (!unchanged(job, fs.fstatSync(fd, { bigint: true }))) {
throw new Error("Package rollback file changed before reading");
}
const digest = createHash("sha256");
const size = Number(job.size);
let position = 0;
while (position < size) {
admit();
const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - position), position);
if (count === 0) {
throw new Error("Package rollback file changed while reading");
}
position += count;
digest.update(buffer.subarray(0, count));
}
admit();
if (!unchanged(job, fs.fstatSync(fd, { bigint: true }))) {
throw new Error("Package rollback file changed while reading");
}
return { digest: digest.digest("hex") };
} finally {
try { fs.closeSync(fd); } catch {}
}
}
parentPort.on("message", jobs => {
if (jobs === "close") {
parentPort.close();
return;
}
const results = jobs.map(job => {
try {
return hash(job);
} catch (error) {
if (error === cancelled) return { cancelled: true };
const { message, code, errno, syscall, path } = error;
return { error: { message, code, errno, syscall, path } };
}
});
parentPort.postMessage(results);
});
`;
/** One walk owns its workers, queue, and fallback; no file bytes survive a job. */
function createPackageFileHasher(fallback) {
const width = Math.min(4, availableParallelism());
const batchSize = 16;
const cancel = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
const slots = [];
const queued = [];
let closed = false;
let flushing = false;
let inProcess = false;
let fallbackRunning = 0;
function retire(slot) {
if (slot.closing) return;
slot.closing = true;
if (slot.tasks.length) {
try {
slot.worker.postMessage("close", []);
} catch {}
slot.worker.unref();
} else slot.worker.terminate().catch(() => {});
}
function useFallback(error) {
if (!inProcess) {
inProcess = true;
Atomics.store(cancel, 0, 1);
try {
log$1.debug("package-file-hasher-fallback", { error });
} catch {}
for (const slot of slots) retire(slot);
}
dispatch();
}
function dispatch() {
if (closed || !queued.length) {
flushing = false;
return;
}
if (inProcess) {
const busy = slots.filter((slot) => slot.tasks.length).length;
while (queued.length && fallbackRunning + busy < 4) {
const task = queued.shift();
fallbackRunning++;
Promise.resolve().then(() => fallback(task.job.file, task.stat)).then(task.resolve, task.reject).finally(() => {
fallbackRunning--;
dispatch();
});
}
return;
}
if (!flushing && queued.length < batchSize) return;
let slot = slots.find((candidate) => !candidate.tasks.length);
if (!slot && slots.length < width) try {
const worker = new Worker(workerSource, {
eval: true,
execArgv: [],
env: {},
workerData: { cancel }
});
slot = {
worker,
tasks: [],
closing: false,
failed: false
};
slots.push(slot);
const owner = slot;
worker.on("message", (results) => {
if (owner.failed) return;
const tasks = owner.tasks;
owner.tasks = [];
for (const [index, task] of tasks.entries()) {
const result = results[index];
if ("cancelled" in result) {
if (closed) task.reject(/* @__PURE__ */ new Error("Package file hashing cancelled"));
else queued.push(task);
} else if ("error" in result) {
const { message, ...fields } = result.error;
task.reject(Object.assign(new Error(message), Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== void 0))));
} else task.resolve(result.digest);
}
dispatch();
});
const failed = (error) => {
owner.failed = true;
useFallback(error);
};
worker.on("error", failed);
worker.on("messageerror", failed);
worker.on("exit", (code) => {
const tasks = owner.tasks;
owner.tasks = [];
if (!owner.closing && !closed) useFallback(/* @__PURE__ */ new Error(`Package file hashing worker exited (${code})`));
if (tasks.length) {
if (closed) for (const task of tasks) task.reject(/* @__PURE__ */ new Error("Package file hashing cancelled"));
else {
queued.push(...tasks);
useFallback(/* @__PURE__ */ new Error(`Package file hashing worker exited (${code})`));
}
}
dispatch();
});
} catch (error) {
useFallback(error);
return;
}
if (slot) {
slot.tasks = queued.splice(0, batchSize);
try {
slot.worker.postMessage(slot.tasks.map((task) => task.job), []);
if (!queued.length) flushing = false;
else dispatch();
} catch (error) {
slot.failed = true;
useFallback(error);
}
}
}
return {
hash(file, stat) {
if (closed) return Promise.reject(/* @__PURE__ */ new Error("Package file hashing cancelled"));
const { dev, ino, mode, uid, gid, nlink, size, mtimeNs, ctimeNs } = stat;
return new Promise((resolve, reject) => {
queued.push({
job: {
file,
dev,
ino,
mode,
uid,
gid,
nlink,
size,
mtimeNs,
ctimeNs
},
stat,
resolve,
reject
});
dispatch();
});
},
flush() {
flushing = true;
dispatch();
},
close() {
closed = true;
Atomics.store(cancel, 0, 1);
for (const task of queued.splice(0)) task.reject(/* @__PURE__ */ new Error("Package file hashing cancelled"));
for (const slot of slots) retire(slot);
}
};
}
//#endregion
//#region src/infra/package-update-integrity.ts
const MAX_TREE_BYTES = 1073741824;
const MAX_TREE_ENTRIES = 5e4;
const MAX_MANIFEST_BYTES = 1048576;
const MAX_LAUNCHER_BYTES = 1048576;
const SETTLED_CTIME_MARGIN_NS = BigInt(5e3) * 1000000n;
const log = createSubsystemLogger("update/package-integrity");
let readerSequence = 0;
const observations = /* @__PURE__ */ new WeakMap();
var PackageIntegrityMismatchError = class extends Error {
constructor(message, differences) {
super(`${message}${differences.length ? ` Drift: ${differences.join("; ")}.` : ""}`);
this.differences = differences;
}
};
function packageIntegrityDifferences(expected, actual) {
const before = observations.get(expected);
const after = observations.get(actual);
const differences = [];
if (before && after) for (const name of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
const left = before.get(name);
const right = after.get(name);
if (left?.retained === right?.retained) continue;
const fields = !left ? ["added"] : !right ? ["removed"] : [.../* @__PURE__ */ new Set([...left.fields.keys(), ...right.fields.keys()])].filter((field) => left.fields.get(field) !== right.fields.get(field));
const entry = name.length > 90 ? `${name.slice(0, 87)}...` : name || ".";
differences.push(`Package rollback entry ${JSON.stringify(entry)}: fields=${fields.join(",")}`);
if (differences.length === 5) break;
}
return differences;
}
function packageLauncherDifferences(expected, actual, options) {
const symlink = expected.type === "symlink" && actual.type === "symlink";
return [
"type",
"mode",
"contents"
].filter((field) => (field !== "mode" || options?.checkMode === true) && expected[field] !== actual[field]).map((field) => field === "contents" && symlink ? `target (expected ${JSON.stringify(expected.contents)}, actual ${JSON.stringify(actual.contents)})` : field);
}
var PackageIntegrityTimeoutError = class extends Error {
constructor(budgetMs) {
super("Package rollback verification timed out");
this.budgetMs = budgetMs;
}
};
/** Resource exhaustion is distinct from a filesystem-integrity failure. */
var PackageIntegrityLimitError = class extends Error {
constructor(resource) {
super(`Package rollback verification ${resource} limit exceeded`);
this.resource = resource;
}
};
function isPackageIntegrityResourceError(error) {
return error instanceof PackageIntegrityTimeoutError || error instanceof PackageIntegrityLimitError;
}
async function readPackageVersionIfPresent(packageRoot) {
return packageRoot ? readPackageVersion(packageRoot) : null;
}
function identity(stat) {
return `${stat.dev}:${stat.ino}`;
}
function metadata(stat) {
return {
"dev:ino": identity(stat),
mode: stat.mode.toString(),
uid: stat.uid.toString(),
gid: stat.gid.toString(),
nlink: stat.nlink.toString(),
size: stat.size.toString(),
mtimeNs: stat.mtimeNs.toString(),
ctimeNs: stat.ctimeNs.toString()
};
}
function packageStatUnchanged(left, right) {
return left.ino !== 0n && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.uid === right.uid && left.gid === right.gid && left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
}
/** Read-only, bounded observations. These do not exclude writers or seal an inode. */
function createPackageIntegrityReader(timeoutMs = UPDATE_RUNNER_TIMEOUT_MS) {
const now = () => performance.now();
const startedAtMonotonicMs = now();
const budget = Number.isFinite(timeoutMs) ? Math.max(1, timeoutMs) : UPDATE_RUNNER_TIMEOUT_MS;
const deadlineAtMonotonicMs = startedAtMonotonicMs + budget;
const timing = {
readerId: `${process.pid}:${++readerSequence}`,
timeOriginUnixMs: performance.timeOrigin,
startedAtMonotonicMs,
budgetMs: budget,
deadlineClock: "monotonic",
deadlineAtMonotonicMs
};
let timeoutObservedAtMonotonicMs;
let pendingIo = 0;
async function trackIo(operation) {
pendingIo++;
try {
return await operation();
} finally {
pendingIo--;
}
}
async function observe(phase, operation) {
const emit = (event, facts) => {
try {
log.debug(event, {
...timing,
phase,
event,
...facts
});
} catch {}
};
emit("reader-started");
let outcome = "failed";
try {
const result = await operation();
outcome = "completed";
return result;
} finally {
const settledAtMonotonicMs = performance.now();
emit("reader-settled", {
settledAtMonotonicMs,
elapsedMs: settledAtMonotonicMs - startedAtMonotonicMs,
outcome: timeoutObservedAtMonotonicMs === void 0 ? outcome : "timed-out",
timeoutObservedAtMonotonicMs,
pendingIo
});
}
}
async function read(operation, closeLate) {
let pending;
const value = await awaitWithinDeadline(() => pending = trackIo(operation), deadlineAtMonotonicMs, now);
if (value === ABSOLUTE_DEADLINE_EXPIRED) {
timeoutObservedAtMonotonicMs ??= performance.now();
if (pending && closeLate) pending.then((late) => trackIo(() => closeLate(late)), () => {}).catch(() => {});
throw new PackageIntegrityTimeoutError(budget);
}
return value;
}
async function close(resource) {
const closing = trackIo(() => resource.close()).catch(() => {});
if (await awaitWithinDeadline(() => closing, deadlineAtMonotonicMs, now) === ABSOLUTE_DEADLINE_EXPIRED) timeoutObservedAtMonotonicMs ??= performance.now();
}
async function entries(directoryPath, limit = MAX_TREE_ENTRIES) {
const directory = await read(() => fs$1.opendir(directoryPath), (late) => late.close());
const children = [];
try {
while (true) {
const child = await read(() => directory.read());
if (!child) break;
if (children.length >= limit) throw new PackageIntegrityLimitError("entry");
children.push(child.name);
}
} finally {
await close(directory);
}
return children.toSorted();
}
async function hashFile(file, stat, remainingBytes, readBuffer) {
if (!stat.isFile()) throw new Error("Package rollback verification byte limit exceeded");
if (stat.size > BigInt(remainingBytes)) throw new PackageIntegrityLimitError("byte");
const handle = await read(() => fs$1.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK), (late) => late.close());
try {
if (!packageStatUnchanged(stat, await read(() => handle.stat({ bigint: true })))) throw new Error("Package rollback file changed before reading");
const hash = createHash("sha256");
const buffer = readBuffer ?? Buffer.allocUnsafe(65536);
const size = Number(stat.size);
let position = 0;
while (position < size) {
const { bytesRead } = await read(() => handle.read(buffer, 0, Math.min(buffer.length, size - position), position));
if (bytesRead === 0) throw new Error("Package rollback file changed while reading");
position += bytesRead;
hash.update(buffer.subarray(0, bytesRead));
}
if (!packageStatUnchanged(stat, await read(() => handle.stat({ bigint: true })))) throw new Error("Package rollback file changed while reading");
return {
digest: hash.digest("hex"),
bytes: position
};
} finally {
await close(handle);
}
}
async function tree(root, originalRoot = root, reuse, legacy = false) {
const prior = reuse ? observations.get(reuse) : void 0;
const digest = createHash("sha256");
const observed = [];
const entriesObserved = /* @__PURE__ */ new Map();
let bytes = 0;
let remainingEntries = 49999;
let device;
let rootIdentity = "";
const pending = [];
const hasher = createPackageFileHasher(async (file, stat) => (await hashFile(file, stat, Number(stat.size))).digest);
const window = 64;
let pendingFiles = 0;
let fileFailed = false;
const appendEntry = ({ relative, fields, retained, reusable }) => {
const retainedEntry = JSON.stringify([relative, retained]);
if (!legacy) digest.update(retainedEntry);
entriesObserved.set(relative, {
fields,
retained: retainedEntry,
reusable
});
};
const settled = (entry) => {
if (pending.length) pending.push({ entry });
else appendEntry(entry);
};
const drainFiles = async (limit = pending.length) => {
let count = limit;
if (!count) return;
const settle = (items) => read(() => {
hasher.flush();
return Promise.all(items.map((item) => "outcome" in item ? item.outcome : Promise.resolve(item)));
});
let outcomes = await settle(pending.slice(0, count));
if (count < pending.length && outcomes.some((outcome) => "error" in outcome)) {
count = pending.length;
outcomes = await settle(pending);
}
for (let next = pending[count]; next && "entry" in next; next = pending[++count]) outcomes.push(next);
pendingFiles -= pending.splice(0, count).filter((item) => "outcome" in item).length;
for (const outcome of outcomes) {
if ("error" in outcome) throw outcome.error;
appendEntry(outcome.entry);
}
};
async function visit(file, relative) {
if (fileFailed) await drainFiles();
const stat = await read(() => fs$1.lstat(file, { bigint: true }));
if (fileFailed) await drainFiles();
if (stat.ino === 0n || device !== void 0 && device !== stat.dev) throw new Error("Package rollback filesystem identity is unavailable");
if (!relative) {
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Package rollback root is not a retained directory");
device = stat.dev;
rootIdentity = identity(stat);
}
observed.push({
file,
stat
});
if (!legacy && stat.isFile() && /(?:^|\/)node_modules\/\.package-lock\.json$/u.test(relative)) return;
const info = metadata(stat);
const fields = new Map(Object.entries(info));
const retained = [
info["dev:ino"],
info.mode,
info.uid,
info.gid
];
if (!stat.isDirectory()) retained.push(info.size, info.mtimeNs);
if (stat.isSymbolicLink()) {
const target = await read(() => fs$1.readlink(file));
const resolved = path.relative(originalRoot, path.resolve(path.dirname(path.join(originalRoot, relative)), target));
let descended = false;
for (const segment of target.split(/[\\/]+/)) {
if (!segment || segment === ".") continue;
if (segment === ".." && descended) throw new Error("Package rollback symlink has ambiguous parent traversal");
descended ||= segment !== "..";
}
if (path.isAbsolute(resolved) || resolved === ".." || resolved.startsWith(`..${path.sep}`)) throw new Error("Package rollback symlink leaves the retained tree");
fields.set("target", target);
retained.push("symlink", target);
} else if (stat.isFile()) {
const remainingBytes = MAX_TREE_BYTES - bytes;
if (stat.size > BigInt(remainingBytes)) throw new PackageIntegrityLimitError("byte");
bytes += Number(stat.size);
const previous = prior?.get(relative);
const previousDigest = previous?.fields.get("sha256");
if (previous?.reusable && previousDigest !== void 0 && Object.entries(info).every(([field, value]) => previous.fields.get(field) === value)) {
fields.set("sha256", previousDigest);
retained.push("file", previousDigest);
settled({
relative,
fields,
retained,
reusable: true
});
return;
}
pendingFiles++;
const admittedAtNs = BigInt(Date.now()) * 1000000n;
const reusable = stat.ctimeNs + SETTLED_CTIME_MARGIN_NS <= admittedAtNs;
pending.push({ outcome: trackIo(() => hasher.hash(file, stat)).then((fileDigest) => {
fields.set("sha256", fileDigest);
retained.push("file", fileDigest);
return { entry: {
relative,
fields,
retained,
reusable
} };
}, (error) => {
fileFailed = true;
return { error };
}) });
if (pendingFiles === window) await drainFiles(pending.findIndex((item) => "outcome" in item) + 1);
return;
} else if (stat.isDirectory()) {
const children = await entries(file, remainingEntries);
remainingEntries -= children.length;
for (const child of children) await visit(path.join(file, child), relative ? `${relative}/${child}` : child);
} else throw new Error("Package rollback contains a non-file entry");
settled({
relative,
fields,
retained,
reusable: false
});
}
try {
try {
await visit(root, "");
await drainFiles();
} catch (error) {
if (!(error instanceof PackageIntegrityTimeoutError)) await drainFiles();
throw error;
}
const version = await read(() => readPackageVersion(root, { maxBytes: MAX_MANIFEST_BYTES }));
if (!version) throw new Error("Package rollback version is unavailable");
for (const entry of observed) if (!packageStatUnchanged(entry.stat, await read(() => fs$1.lstat(entry.file, { bigint: true })))) throw new Error("Package rollback tree changed during verification");
if (legacy) for (const { file, stat } of observed) {
const relative = path.relative(root, file).split(path.sep).join("/");
const fields = entriesObserved.get(relative).fields;
const info = Object.values(metadata(stat));
if (!relative) info.pop();
digest.update(JSON.stringify([relative, info]));
const contents = fields.get("sha256");
if (contents !== void 0) {
const first = stat.nlink > 1n ? observed.find(({ stat: other }) => other.dev === stat.dev && other.ino === stat.ino) : void 0;
const owner = first ? path.relative(root, first.file).split(path.sep).join("/") : null;
digest.update(JSON.stringify([
"file",
owner,
contents
]));
} else if (fields.has("target")) digest.update(JSON.stringify(["symlink", fields.get("target")]));
}
const fingerprint = {
digest: digest.digest("hex"),
identity: rootIdentity,
version
};
observations.set(fingerprint, entriesObserved);
return fingerprint;
} finally {
hasher.close();
}
}
async function rootEntry(root, originalRoot = root, expectedKind) {
const stat = await read(() => fs$1.lstat(root, { bigint: true }));
if (expectedKind && expectedKind !== (stat.isSymbolicLink() ? "link" : "directory")) throw new Error("Package rollback root entry kind changed");
if (!stat.isSymbolicLink()) return {
kind: "directory",
tree: await tree(root, originalRoot)
};
const target = await read(() => fs$1.readlink(root));
if (!packageStatUnchanged(stat, await read(() => fs$1.lstat(root, { bigint: true })))) throw new Error("Package rollback link changed while reading");
return {
kind: "link",
metadata: Object.values(metadata(stat)).slice(0, -1),
target
};
}
async function directoryIdentity(root) {
const stat = await read(() => fs$1.lstat(root, { bigint: true }));
if (stat.isSymbolicLink()) return null;
if (!stat.isDirectory() || stat.ino === 0n) throw new Error("Package rollback filesystem identity is unavailable");
const version = await read(() => readPackageVersion(root, { maxBytes: MAX_MANIFEST_BYTES }));
if (!version || !packageStatUnchanged(stat, await read(() => fs$1.lstat(root, { bigint: true })))) throw new Error("Package rollback identity changed or version is unavailable");
return {
identity: identity(stat),
version
};
}
async function launcher(file) {
let stat = await read(() => fs$1.lstat(file, { bigint: true }));
const symlink = stat.isSymbolicLink();
const contents = symlink ? await read(() => fs$1.readlink(file)) : (await hashFile(file, stat, MAX_LAUNCHER_BYTES)).digest;
const current = await read(() => fs$1.lstat(file, { bigint: true }));
if (symlink && current.isSymbolicLink()) {
const target = await read(() => fs$1.readlink(file));
if (target !== contents) throw new Error(`Package rollback launcher target changed: ${file}; expected ${JSON.stringify(contents)}, actual ${JSON.stringify(target)}`);
stat = current;
} else if (!packageStatUnchanged(stat, current)) throw new Error("Package rollback launcher changed during verification");
return {
type: stat.isSymbolicLink() ? "symlink" : "file",
mode: stat.mode.toString(),
uid: stat.uid.toString(),
gid: stat.gid.toString(),
contents
};
}
async function exists(file) {
try {
await read(() => fs$1.lstat(file));
return true;
} catch (error) {
if (hasErrnoCode(error, "ENOENT")) return false;
throw error;
}
}
return {
tree,
rootEntry,
directoryIdentity,
launcher,
exists,
entries,
observe
};
}
//#endregion
export { packageIntegrityDifferences as a, readPackageVersionIfPresent as c, isPackageIntegrityResourceError as i, PackageIntegrityTimeoutError as n, packageLauncherDifferences as o, createPackageIntegrityReader as r, packageStatUnchanged as s, PackageIntegrityMismatchError as t };