mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-22 06:59:03 +00:00
* fix(runtime): require Node releases that reject compromised CA keys Require Node 24.22.1 or 26.12.1 even when SQLite capability checks pass. Align active managed runtime selectors, archive checksums, image digests, native macOS selection, diagnostics, and coupled fixtures while preserving update recovery. * fix(tooling): use pnpm that rejects compromised CA keys * test(runtime): align pinned Node fixtures and generated guidance * test(runtime): qualify service and update fixtures at patched Node floor * fix(security): update Alpine test trust and aligned workflow contracts * test: align guest runtime fixture and preserve mock exports
178 lines
6.7 KiB
JavaScript
178 lines
6.7 KiB
JavaScript
import { isSupportedOpenClawNodeVersion, SUPPORTED_NODE_VERSION_RANGE } from "./node-version.mjs";
|
|
|
|
// Self-contained so a different runtime can execute exactly the same probe with -e.
|
|
function probeSqlite(DatabaseSync) {
|
|
const result = { available: false, version: null, text: false, blob: false, json: false };
|
|
let database;
|
|
try {
|
|
database = new DatabaseSync(":memory:");
|
|
result.available = true;
|
|
const version = database.prepare("SELECT sqlite_version() AS version").get()?.version;
|
|
result.version = typeof version === "string" ? version : null;
|
|
const text = "a\u0000b\u0000";
|
|
const bytes = Buffer.from(text, "utf8");
|
|
const json = JSON.stringify({ value: text });
|
|
database.exec("CREATE TABLE probe (text_value TEXT, blob_value BLOB, json_value TEXT)");
|
|
database.prepare("INSERT INTO probe VALUES (?, ?, ?)").run(text, bytes, json);
|
|
const row = database.prepare("SELECT text_value, blob_value, json_value FROM probe").get();
|
|
result.text =
|
|
typeof row?.text_value === "string" &&
|
|
row.text_value.length === text.length &&
|
|
Buffer.from(row.text_value, "utf8").equals(bytes);
|
|
result.blob =
|
|
row?.blob_value instanceof Uint8Array && Buffer.from(row.blob_value).equals(bytes);
|
|
result.json = row?.json_value === json && JSON.parse(row.json_value).value === text;
|
|
} catch (error) {
|
|
result.error = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
database?.close();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The launcher and bundled runtime chunks must share one process-local result.
|
|
const capabilityCacheKey = Symbol.for("openclaw.sqliteCapabilities");
|
|
function unavailableSqliteCapabilities(error) {
|
|
return {
|
|
available: false,
|
|
version: null,
|
|
text: false,
|
|
blob: false,
|
|
json: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
function isSqliteCapabilities(value) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === "object" &&
|
|
typeof value.available === "boolean" &&
|
|
(value.version === null || typeof value.version === "string") &&
|
|
typeof value.text === "boolean" &&
|
|
typeof value.blob === "boolean" &&
|
|
typeof value.json === "boolean" &&
|
|
(value.error === undefined || typeof value.error === "string")
|
|
);
|
|
}
|
|
|
|
async function probeCurrentSqlite() {
|
|
let worker;
|
|
try {
|
|
const DatabaseSync = process.getBuiltinModule?.("node:sqlite")?.DatabaseSync;
|
|
if (typeof DatabaseSync !== "function") {
|
|
return unavailableSqliteCapabilities(new Error("node:sqlite is unavailable"));
|
|
}
|
|
// Keep the real capability check when Node's permission policy forbids workers.
|
|
if (!process.versions.bun && process.permission?.has("worker") === false) {
|
|
return probeSqlite(DatabaseSync);
|
|
}
|
|
const env = Object.fromEntries(
|
|
Object.entries(process.env).filter(([name]) => !/^(NODE_OPTIONS|BUN_OPTIONS)$/i.test(name)),
|
|
);
|
|
env.NODE_NO_WARNINGS = "1";
|
|
// Older diagnostic runtimes need SQLite enabled, but must not replay entry preloads.
|
|
const execArgv =
|
|
!process.versions.bun && process.allowedNodeEnvironmentFlags?.has("--experimental-sqlite")
|
|
? ["--experimental-sqlite"]
|
|
: [];
|
|
const { Worker } = await import("node:worker_threads");
|
|
worker = new Worker(
|
|
`const { parentPort } = require("node:worker_threads");
|
|
parentPort.postMessage(${SQLITE_CAPABILITY_PROBE});
|
|
parentPort.close();`,
|
|
{ eval: true, env, execArgv },
|
|
);
|
|
} catch (error) {
|
|
return unavailableSqliteCapabilities(error);
|
|
}
|
|
return new Promise((resolve) => {
|
|
let result;
|
|
let failure;
|
|
let retirement;
|
|
const fail = (error) => {
|
|
failure ??= { error };
|
|
retirement ??= Promise.resolve()
|
|
.then(() => worker.terminate())
|
|
.catch((error) => {
|
|
failure ??= { error };
|
|
});
|
|
};
|
|
const onMessage = (value) => {
|
|
if (result !== undefined || !isSqliteCapabilities(value)) {
|
|
fail(new Error("SQLite capability worker returned an invalid result"));
|
|
return;
|
|
}
|
|
result = value;
|
|
};
|
|
const onExit = (code) => {
|
|
worker.off("message", onMessage);
|
|
worker.off("error", fail);
|
|
worker.off("messageerror", fail);
|
|
if (failure) {
|
|
resolve(unavailableSqliteCapabilities(failure.error));
|
|
} else if (code !== 0 || result === undefined) {
|
|
resolve(
|
|
unavailableSqliteCapabilities(
|
|
new Error(`SQLite capability worker exited without a complete result (code ${code})`),
|
|
),
|
|
);
|
|
} else {
|
|
// The scratch database and worker must both close before startup is admitted.
|
|
resolve(result);
|
|
}
|
|
};
|
|
worker.on("message", onMessage);
|
|
worker.on("error", fail);
|
|
worker.on("messageerror", fail);
|
|
worker.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
export function detectCurrentSqliteCapabilities() {
|
|
// Publish the Promise before Worker construction can notify another startup caller.
|
|
globalThis[capabilityCacheKey] ??= Promise.resolve().then(probeCurrentSqlite);
|
|
return Promise.resolve(globalThis[capabilityCacheKey]);
|
|
}
|
|
|
|
export function isSqliteWalResetSafeVersion(value) {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value.trim());
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
const [major, minor, patch] = match.slice(1).map(Number);
|
|
if (![major, minor, patch].every(Number.isSafeInteger)) {
|
|
return false;
|
|
}
|
|
return (
|
|
major > 3 ||
|
|
(major === 3 &&
|
|
(minor > 51 ||
|
|
(minor === 51 && patch >= 3) ||
|
|
(minor === 50 && patch >= 7) ||
|
|
(minor === 44 && patch >= 6)))
|
|
);
|
|
}
|
|
|
|
export function nodeRuntimeFailure(version, probe) {
|
|
const label = `Node ${version ?? "unknown"}`;
|
|
if (!probe.available) {
|
|
return `${label}: node:sqlite is unavailable; use 24.22.1+/26.12.1+ or a build with the fix.`;
|
|
}
|
|
if (!probe.text && !probe.error) {
|
|
return `${label}: node:sqlite truncates TEXT at embedded NUL (nodejs/node#61954); use 24.22.1+/26.12.1+ or a build with the fix`;
|
|
}
|
|
if (probe.error || !probe.text || !probe.blob || !probe.json) {
|
|
return `${label}: node:sqlite NUL round-trip capability check failed; use 24.22.1+/26.12.1+ or a build with the fix.`;
|
|
}
|
|
if (!probe.version || !isSqliteWalResetSafeVersion(probe.version)) {
|
|
return `${label}: SQLite ${probe.version ?? "unknown"} is not WAL-reset-safe; use SQLite 3.51.3+, 3.50.7+, or 3.44.6+ on its patched release line.`;
|
|
}
|
|
// SQLite capabilities cannot establish that the TLS verifier rejects compromised keys.
|
|
if (!isSupportedOpenClawNodeVersion(version)) {
|
|
return `${label}: openclaw requires Node ${SUPPORTED_NODE_VERSION_RANGE.replace(" || ", ", or ")}.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const SQLITE_CAPABILITY_PROBE = `(${probeSqlite.toString()})(require("node:sqlite").DatabaseSync)`;
|