UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

249 lines (248 loc) • 12 kB
import { t as formatErrorMessage } from "../errors-O33D01XW.mjs"; import { lstatSync, readdirSync } from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { endianness } from "node:os"; import { createHash } from "node:crypto"; import { FsSafeError } from "@openclaw/fs-safe/errors"; import { openLocalFileSafely } from "@openclaw/fs-safe/root"; import { text } from "node:stream/consumers"; //#region src/infra/state-migrations.snapshot.worker.ts const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0", "utf8"); const SQLITE_SHM_REGION_BYTES = 32768; const SQLITE_WAL_INDEX_HEADER_COPY_BYTES = 48; const SQLITE_WAL_INDEX_HEADER_BYTES = 96; const SQLITE_WAL_INDEX_VERSION = 3007e3; const SQLITE_WAL_HEADER_BYTES = 32; const SQLITE_WAL_MAGIC_LE = 931071618; const SQLITE_WAL_MAGIC_BE = 931071619; const SQLITE_SHM_VOLATILE_RANGES = [[96, 120], [128, 132]]; const SNAPSHOT_HASH_BUFFER_BYTES = 1048576; function readUint32(buffer, offset, byteOrder) { return byteOrder === "LE" ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); } function sqliteWalChecksum(buffer, byteOrder) { let first = 0; let second = 0; for (let offset = 0; offset < buffer.length; offset += 8) { first = first + readUint32(buffer, offset, byteOrder) + second >>> 0; second = second + readUint32(buffer, offset + 4, byteOrder) + first >>> 0; } return [first, second]; } async function digestFile(filePath, observedEntries, hashPrefix = "") { const before = await fs$1.lstat(filePath); if (!before.isFile()) throw new Error(`Snapshot path is not a regular file: ${filePath}`); if (before.nlink !== 1) throw new Error(`Snapshot path has unbound hard links: ${filePath}`); observedEntries.push({ path: filePath, stat: before }); const handle = await openSnapshotRegularFile(filePath); if (!handle) throw new Error(`Snapshot path is not a regular file: ${filePath}`); return `sha256:${await digestSnapshotFileHandle(filePath, handle, before, [], hashPrefix)}`; } async function openSnapshotRegularFile(filePath) { try { return (await openLocalFileSafely({ filePath })).handle; } catch (error) { if (error instanceof FsSafeError) { if (error.code === "not-found" || error.code === "not-file" || error.code === "symlink") return; if (error.code === "path-mismatch") throw new Error(`Snapshot file changed while opening: ${filePath}`, { cause: error }); } throw error; } } async function openSqliteSharedMemoryEntry(directory, entry) { if (!entry.endsWith("-shm")) return; const databasePath = path.join(directory, entry.slice(0, -4)); const sharedMemoryPath = path.join(directory, entry); let databaseHandle; try { databaseHandle = await openSnapshotRegularFile(databasePath); if (!databaseHandle) return; const header = Buffer.alloc(SQLITE_FILE_HEADER.length); const { bytesRead } = await databaseHandle.read(header, 0, header.length, 0); if (bytesRead !== header.length || !header.equals(SQLITE_FILE_HEADER)) return; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return; throw error; } finally { await databaseHandle?.close(); } let sharedMemoryStat; try { sharedMemoryStat = await fs$1.lstat(sharedMemoryPath); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return; throw error; } if (!sharedMemoryStat.isFile() || sharedMemoryStat.size === 0 || sharedMemoryStat.size % SQLITE_SHM_REGION_BYTES !== 0) return; let sharedMemoryHandle; let walHandle; try { sharedMemoryHandle = await openSnapshotRegularFile(sharedMemoryPath); if (!sharedMemoryHandle) return; const header = Buffer.alloc(SQLITE_WAL_INDEX_HEADER_BYTES); const { bytesRead } = await sharedMemoryHandle.read(header, 0, header.length, 0); if (bytesRead !== header.length) return; const first = header.subarray(0, SQLITE_WAL_INDEX_HEADER_COPY_BYTES); const second = header.subarray(SQLITE_WAL_INDEX_HEADER_COPY_BYTES); const nativeByteOrder = endianness(); const [indexChecksumFirst, indexChecksumSecond] = sqliteWalChecksum(first.subarray(0, 40), nativeByteOrder); if (readUint32(first, 0, nativeByteOrder) !== SQLITE_WAL_INDEX_VERSION || readUint32(first, 4, nativeByteOrder) !== 0 || first[12] !== 1 || readUint32(first, 40, nativeByteOrder) !== indexChecksumFirst || readUint32(first, 44, nativeByteOrder) !== indexChecksumSecond || !first.equals(second)) return; walHandle = await openSnapshotRegularFile(`${databasePath}-wal`); if (!walHandle) return; const walHeader = Buffer.alloc(SQLITE_WAL_HEADER_BYTES); if ((await walHandle.read(walHeader, 0, walHeader.length, 0)).bytesRead !== walHeader.length) return; const walMagic = walHeader.readUInt32BE(0); const walByteOrder = walMagic === SQLITE_WAL_MAGIC_LE ? "LE" : "BE"; if (walMagic !== SQLITE_WAL_MAGIC_LE && walMagic !== SQLITE_WAL_MAGIC_BE || walHeader.readUInt32BE(4) !== SQLITE_WAL_INDEX_VERSION) return; const [walChecksumFirst, walChecksumSecond] = sqliteWalChecksum(walHeader.subarray(0, 24), walByteOrder); if (walHeader.readUInt32BE(24) === walChecksumFirst && walHeader.readUInt32BE(28) === walChecksumSecond && first.subarray(32, 40).equals(walHeader.subarray(16, 24))) { const verifiedHandle = sharedMemoryHandle; sharedMemoryHandle = void 0; return verifiedHandle; } return; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return; throw error; } finally { await sharedMemoryHandle?.close(); await walHandle?.close(); } } async function digestSnapshotFileHandle(filePath, handle, expectedStat, volatileRanges = [], hashPrefix = "") { const hash = createHash("sha256").update(hashPrefix); const buffer = Buffer.alloc(SNAPSHOT_HASH_BUFFER_BYTES); let position = 0; try { const opened = await handle.stat(); if (expectedStat.dev !== opened.dev || expectedStat.ino !== opened.ino || expectedStat.size !== opened.size || expectedStat.mtimeMs !== opened.mtimeMs) throw new Error(`Snapshot file changed before hashing: ${filePath}`); for (;;) { const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); if (bytesRead === 0) break; const chunk = Buffer.from(buffer.subarray(0, bytesRead)); for (const [rangeStart, rangeEnd] of volatileRanges) { const overlapStart = Math.max(position, rangeStart); const overlapEnd = Math.min(position + bytesRead, rangeEnd); if (overlapStart < overlapEnd) chunk.fill(0, overlapStart - position, overlapEnd - position); } hash.update(chunk); position += bytesRead; } const after = await handle.stat(); if (expectedStat.dev !== after.dev || expectedStat.ino !== after.ino || expectedStat.size !== after.size || expectedStat.mtimeMs !== after.mtimeMs) throw new Error(`Snapshot file changed while hashing: ${filePath}`); } finally { await handle.close(); } return hash.digest("hex"); } async function digestDirectory(directory, observedEntries) { if (!(await fs$1.lstat(directory)).isDirectory()) throw new Error(`Snapshot state path is not a directory: ${directory}`); const hash = createHash("sha256"); const hardLinks = /* @__PURE__ */ new Map(); const visit = async (current, relative) => { const stat = await fs$1.lstat(current); if (stat.isSymbolicLink()) throw new Error(`Snapshot tree contains a symbolic link: ${current}`); const portableRelative = relative.split(path.sep).join("/"); if (stat.isFile()) { observedEntries.push({ path: current, stat }); const hardLinkKey = `${stat.dev}:${stat.ino}`; const existingHardLink = hardLinks.get(hardLinkKey); if (existingHardLink) { if (existingHardLink.expected !== stat.nlink) throw new Error(`Snapshot hard-link count changed while hashing: ${current}`); existingHardLink.observed += 1; hash.update("hardlink\0").update(portableRelative).update("\0").update(existingHardLink.firstRelative).update("\0"); return; } hardLinks.set(hardLinkKey, { firstPath: current, firstRelative: portableRelative, observed: 1, expected: stat.nlink }); const sharedMemoryHandle = await openSqliteSharedMemoryEntry(path.dirname(current), path.basename(current)); const handle = sharedMemoryHandle ?? await openSnapshotRegularFile(current); if (!handle) throw new Error(`Snapshot file changed before hashing: ${current}`); const fileDigest = await digestSnapshotFileHandle(current, handle, stat, sharedMemoryHandle ? SQLITE_SHM_VOLATILE_RANGES : []); hash.update("file\0").update(portableRelative).update("\0").update(fileDigest).update("\0"); return; } if (!stat.isDirectory()) throw new Error(`Snapshot tree contains a non-file entry: ${current}`); hash.update("directory\0").update(portableRelative).update("\0"); const entries = (await fs$1.readdir(current)).toSorted(); observedEntries.push({ path: current, stat, children: entries }); for (const entry of entries) await visit(path.join(current, entry), path.join(relative, entry)); }; await visit(directory, ""); for (const hardLink of hardLinks.values()) if (hardLink.observed !== hardLink.expected) throw new Error(`Snapshot file has links outside copied state: ${hardLink.firstPath}`); return `sha256:${hash.digest("hex")}`; } function verifySnapshotEntries(observedEntries) { for (const entry of observedEntries) { const after = lstatSync(entry.path); const before = entry.stat; if (before.isFile() !== after.isFile() || before.isDirectory() !== after.isDirectory() || before.dev !== after.dev || before.ino !== after.ino || before.nlink !== after.nlink || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`Snapshot entry changed while hashing: ${entry.path}`); if (entry.children) { const children = readdirSync(entry.path).toSorted(); if (entry.children.length !== children.length || entry.children.some((child, index) => child !== children[index])) throw new Error(`Snapshot directory changed while hashing: ${entry.path}`); } } } async function captureLegacyStateSnapshotIdentityInProcess(params) { const warnings = []; const observedEntries = []; const capture = async (label, pathname, read) => { try { return await read(); } catch (error) { const message = error instanceof Error ? error.message : String(error); warnings.push(`Could not bind copied ${label} at ${pathname}: ${message}`); return; } }; const configPath = path.resolve(params.configPath); const stateDir = path.resolve(params.stateDir); let configDigest = await capture("config", configPath, async () => { const rootDigest = await digestFile(configPath, observedEntries); if (params.configInputHashes) { if (rootDigest !== `sha256:${params.configInputHashes.root}`) throw new Error(`Snapshot config changed while planning: ${configPath}`); for (const input of params.configInputHashes.includes) if (await digestFile(input.path, observedEntries, "present\0") !== `sha256:${input.hash}`) throw new Error(`Snapshot config input changed while planning: ${input.path}`); } return rootDigest; }); let stateDigest = await capture("state", stateDir, () => digestDirectory(stateDir, observedEntries)); if (warnings.length === 0) try { verifySnapshotEntries(observedEntries); } catch (error) { warnings.push(`Could not bind copied snapshot: ${formatErrorMessage(error)}`); configDigest = void 0; stateDigest = void 0; } return { ...configDigest ? { configDigest } : {}, ...stateDigest ? { stateDigest } : {}, warnings }; } async function runSnapshotWorker() { try { const params = JSON.parse(await text(process.stdin)); process.stdout.write(JSON.stringify(await captureLegacyStateSnapshotIdentityInProcess(params))); } catch (error) { process.exitCode = 1; process.stderr.write(formatErrorMessage(error)); } } if (process.argv[2] === "--openclaw-state-snapshot") runSnapshotWorker(); //#endregion export { captureLegacyStateSnapshotIdentityInProcess };