openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
473 lines (472 loc) • 21.2 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { n as safeParseJsonRecord } from "./json-coercion-AulM0PZ6.js";
import { Ln as strictObject, Rn as string, dn as literal } from "./schemas-zxit8y5H.js";
import { n as ok, t as err } from "./result-BQGgYouL.js";
import { s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js";
import { t as pathMayExistSync } from "./path-existence-CzRtGGnO.js";
import { f as tryParsePersistedExecApprovals, l as resolveExecApprovalsPath, o as normalizeExecApprovalsInternal, s as parsePersistedExecApprovals } from "./exec-approvals-config-D1lCGl0_.js";
import { c as serializeExecApprovals, s as readExecApprovalsConfigRow, u as writeExecApprovalsConfigRow } from "./exec-approvals-sqlite-BK42k6JF.js";
import { t as withLegacyMigrationStateLock } from "./state-migrations.lock-C0ywqz7v.js";
import { i as recordLegacyMigrationReceipt, r as readLegacyMigrationReceiptFromDatabase, s as resolveLegacyMigrationSourceKey, t as markLegacyMigrationSourceRemoved } from "./state-migrations.receipts-BIGn8ljb.js";
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { root } from "@openclaw/fs-safe";
//#region src/infra/state-migrations.source-snapshot.ts
/** Keep every claim operation bound to the same trusted owner root and source inode. */
var LegacyMigrationSourceClaim = class {
constructor(params) {
this.params = params;
this.sourcePath = params.sourcePath;
this.claimPath = `${params.sourcePath}${params.claimSuffix ?? ".doctor-importing"}`;
this.sourceRelativePath = resolveLegacyMigrationRelativePath(params.stateDir, this.sourcePath, params.label, params.includeFilePath);
this.claimRelativePath = resolveLegacyMigrationRelativePath(params.stateDir, this.claimPath, params.label, params.includeFilePath);
}
async exists(claimed = false) {
return await this.params.stateRoot.exists(claimed ? this.claimRelativePath : this.sourceRelativePath);
}
async read(claimed = false) {
return await this.params.readSnapshot(claimed ? this.claimPath : this.sourcePath);
}
async recover(conflictMessage) {
if (!await this.exists(true)) return;
const claimed = await this.read(true);
if (!await this.exists()) {
await this.params.stateRoot.move(this.claimRelativePath, this.sourceRelativePath);
return;
}
if (!legacyMigrationSourceContentMatches(claimed, await this.read())) throw new Error(conflictMessage);
await this.params.stateRoot.remove(this.claimRelativePath);
}
async restore() {
try {
if (!await this.exists(true)) return null;
if (await this.exists()) return `source path already exists: ${this.sourcePath}`;
await this.params.stateRoot.move(this.claimRelativePath, this.sourceRelativePath);
return null;
} catch (error) {
return this.params.formatError?.(error) ?? String(error);
}
}
async claim(params) {
params.beforeClaim?.();
await this.params.stateRoot.move(this.sourceRelativePath, this.claimRelativePath);
const claimed = await this.read(true);
if (!legacyMigrationSourceSnapshotsMatch(claimed, params.snapshot)) throw new Error(params.mismatchMessage);
return claimed;
}
async remove(params = {}) {
if (!params.skipSourceCheck && await this.exists()) throw new Error(params.sourceReappearedMessage ?? `legacy source reappeared during import: ${this.sourcePath}`);
if (params.removeSource) await params.removeSource(this.claimPath);
else await this.params.stateRoot.remove(this.claimRelativePath);
const sourceRemainingMessage = params.sourceRemainingMessage ?? params.remainingMessage;
if (sourceRemainingMessage && await this.exists()) throw new Error(sourceRemainingMessage);
const claimRemainingMessage = params.claimRemainingMessage ?? params.remainingMessage;
if (claimRemainingMessage && await this.exists(true)) throw new Error(claimRemainingMessage);
}
};
/** Restore claimed sources in reverse order so a failed multi-file import remains atomic. */
async function restoreLegacyMigrationSourceClaims(claims) {
const errors = [];
for (const claim of claims.toReversed()) {
const error = await claim.restore();
if (error) errors.push(error);
}
return errors;
}
/** Claim every source before SQLite writes; restore the full batch on the first mismatch. */
async function claimLegacyMigrationSourceClaims(claims, params) {
params.beforeClaim?.();
const claimed = [];
try {
for (const { claim, snapshot } of claims) {
claimed.push(claim);
await claim.claim({
snapshot,
mismatchMessage: params.mismatchMessage
});
}
} catch (error) {
const restoreErrors = await restoreLegacyMigrationSourceClaims(claimed);
throw new Error(`${String(error)}${restoreErrors.length > 0 ? `; restore failures: ${restoreErrors.join("; ")}` : ""}`, { cause: error });
}
}
function legacyMigrationSourceOrClaimMayExist(sourcePath, claimSuffix = ".doctor-importing") {
return pathMayExistSync(sourcePath) || pathMayExistSync(`${sourcePath}${claimSuffix}`);
}
/** Constrain migration reads and moves to the original trusted state root. */
function resolveLegacyMigrationRelativePath(stateDir, filePath, label, includeFilePath = true) {
const relativePath = path.relative(path.resolve(stateDir), path.resolve(filePath));
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error(`legacy ${label} path is outside the state directory${includeFilePath ? `: ${filePath}` : ""}`);
return relativePath;
}
/** Hash the exact bounded bytes returned by the symlink/hardlink-safe root. */
async function readLegacyMigrationSourceSnapshot(params) {
const opened = await params.stateRoot.read(resolveLegacyMigrationRelativePath(params.stateDir, params.sourcePath, params.label), {
hardlinks: "reject",
maxBytes: params.maxBytes,
symlinks: "reject"
});
if (!opened.stat.isFile() || opened.stat.size !== opened.buffer.byteLength) throw new Error(`legacy ${params.label} source is not a stable regular file`);
const raw = opened.buffer.toString("utf8");
return {
buffer: opened.buffer,
dev: opened.stat.dev,
ino: opened.stat.ino,
mtimeMs: opened.stat.mtimeMs,
raw,
sha256: createHash("sha256").update(params.hashDecodedText ? raw : opened.buffer).digest("hex"),
size: opened.stat.size,
sourcePath: params.sourcePath
};
}
/** Pin synchronous legacy files before and after parsing; never follow new links. */
function readLegacyMigrationSourceSnapshotSync(params) {
const stat = params.followSymlinks ? fs.statSync : fs.lstatSync;
const before = stat(params.sourcePath);
if (!before.isFile() || !params.followSymlinks && before.isSymbolicLink()) throw new Error(`legacy ${params.label} source is not a regular${params.followSymlinks ? "" : " non-symlink"} file`);
if (params.maxBytes !== void 0 && before.size > params.maxBytes) throw new Error(`legacy ${params.label} source exceeds the metadata size limit`);
const raw = fs.readFileSync(params.sourcePath, "utf8");
const after = stat(params.sourcePath);
if (!after.isFile() || !params.followSymlinks && after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error(`legacy ${params.label} source changed while doctor was reading it`);
return {
buffer: Buffer.from(raw),
dev: after.dev,
ino: after.ino,
mtimeMs: after.mtimeMs,
raw,
sha256: createHash("sha256").update(raw).digest("hex"),
size: after.size,
sourcePath: params.sourcePath
};
}
/** Check source identity again before committing or deleting a verified import. */
function assertLegacyMigrationSourceUnchanged(params) {
if (!legacyMigrationSourceSnapshotsMatch(readLegacyMigrationSourceSnapshotSync(params), params.snapshot)) throw new Error(`legacy ${params.label} source changed after doctor loaded it`);
}
/** Restore a claimed legacy source when verified cleanup cannot complete. */
function claimAndRemoveLegacyMigrationSource(params) {
params.beforeClaim?.();
const claimPath = `${params.sourcePath}.doctor-importing-${process.pid}-${randomUUID()}`;
fs.renameSync(params.sourcePath, claimPath);
try {
if (!legacyMigrationSourceSnapshotsMatch(readLegacyMigrationSourceSnapshotSync({
...params,
sourcePath: claimPath
}), params.snapshot)) throw new Error(`legacy ${params.label} source changed before doctor could claim it`);
(params.removeSource ?? fs.unlinkSync)(claimPath);
} catch (error) {
let restoreFailure = "";
if (fs.existsSync(claimPath) && !fs.existsSync(params.sourcePath)) try {
fs.renameSync(claimPath, params.sourcePath);
} catch (restoreError) {
restoreFailure = `; the claimed source remains at ${claimPath} because restore also failed: ${String(restoreError)}`;
}
throw new Error(`${String(error)}${restoreFailure}`, { cause: error });
}
}
function legacyMigrationSourceSnapshotsMatch(left, right) {
return left.dev === right.dev && left.ino === right.ino && left.mtimeMs === right.mtimeMs && left.sha256 === right.sha256 && left.size === right.size;
}
function legacyMigrationSourceContentMatches(left, right) {
return left.sha256 === right.sha256 && left.size === right.size;
}
//#endregion
//#region src/infra/state-migrations.exec-approvals.ts
const DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
const MAX_LEGACY_EXEC_APPROVALS_BYTES = 4194304;
const MIGRATION_KIND = "legacy-exec-approvals-json";
const TARGET_TABLE = "exec_approvals_config";
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const emptyLegacyExecApprovalsSchema = strictObject({
version: literal(1).optional(),
defaults: strictObject({}),
agents: strictObject({}),
socket: strictObject({
path: string().optional(),
token: string().optional()
}).optional()
});
function normalizeLegacyNullableUsageMetadata(raw) {
const parsed = safeParseJsonRecord(raw);
if (!parsed || !isRecord(parsed.agents)) return raw;
let changed = false;
for (const agent of Object.values(parsed.agents)) {
if (!isRecord(agent) || !Array.isArray(agent.allowlist)) continue;
for (const entry of agent.allowlist) {
if (!isRecord(entry)) continue;
for (const key of ["lastUsedAt", "lastUsedCommand"]) if (entry[key] === null) {
delete entry[key];
changed = true;
}
}
}
return changed ? JSON.stringify(parsed) : raw;
}
/** Detect retired approvals only when an explicit Doctor flow opts in. */
function detectLegacyExecApprovals(params) {
const env = {
...process.env,
OPENCLAW_STATE_DIR: params.stateDir
};
const sourcePath = resolveExecApprovalsPath(env);
const sourcePresent = legacyMigrationSourceOrClaimMayExist(sourcePath, DOCTOR_CLAIM_SUFFIX);
return {
sourcePath,
hasLegacy: params.doctorOnlyStateMigrations === true && sourcePresent
};
}
async function readLegacySourceSnapshot(stateRoot, stateDir, sourcePath) {
const snapshot = await readLegacyMigrationSourceSnapshot({
stateRoot,
stateDir,
sourcePath,
maxBytes: MAX_LEGACY_EXEC_APPROVALS_BYTES,
label: "exec approvals"
});
let raw = null;
try {
raw = utf8Decoder.decode(snapshot.buffer);
} catch {}
return {
...snapshot,
raw
};
}
function decideAndRecordMigration(params) {
const sourceKey = resolveLegacyMigrationSourceKey("exec-approvals-json", params.sourcePath);
const runId = `${sourceKey}:${params.snapshot.sha256.slice(0, 16)}`;
const now = Date.now();
const legacy = params.emptyStub ? ok(params.emptyStub.file) : params.snapshot.raw === null ? err("invalid UTF-8 encoding") : parsePersistedExecApprovals(normalizeLegacyNullableUsageMetadata(params.snapshot.raw));
const legacyFile = legacy.ok ? legacy.value : null;
return runOpenClawStateWriteTransaction(({ db }) => {
const canonical = readExecApprovalsConfigRow(db);
const canonicalFile = canonical ? tryParsePersistedExecApprovals(canonical.raw_json) : null;
const importedRaw = legacyFile ? serializeExecApprovals(legacyFile) : null;
const receipt = readLegacyMigrationReceiptFromDatabase(db, sourceKey);
let receiptImportedSameSource = false;
if (receipt?.sourceSha256 === params.snapshot.sha256) try {
const report = JSON.parse(receipt.reportJson);
receiptImportedSameSource = report.decision === "legacy-imported" || report.decision === "invalid-canonical-repaired" || report.decision === "receipt-authoritative";
} catch {}
let decision;
let removeSource = false;
if (params.emptyStub && (canonical || !legacyFile?.socket?.path && !legacyFile?.socket?.token)) {
decision = "empty-legacy-retired";
removeSource = true;
} else if (!legacyFile || params.snapshot.raw === null) decision = "malformed-legacy-preserved";
else if (receiptImportedSameSource && canonicalFile) {
decision = "receipt-authoritative";
removeSource = true;
} else if (!canonical) {
writeExecApprovalsConfigRow({
db,
file: legacyFile,
raw: importedRaw ?? void 0,
now
});
decision = "legacy-imported";
removeSource = true;
} else if (!canonicalFile) {
writeExecApprovalsConfigRow({
db,
file: legacyFile,
raw: importedRaw ?? void 0,
now
});
decision = "invalid-canonical-repaired";
removeSource = true;
} else {
decision = "canonical-preserved";
removeSource = canonical.raw_json === params.snapshot.raw;
}
if (decision === "legacy-imported" || decision === "invalid-canonical-repaired") {
if (!legacyFile) throw new Error("exec approvals import decisions require a parsed legacy file");
const verified = readExecApprovalsConfigRow(db);
const verifiedFile = verified ? tryParsePersistedExecApprovals(verified.raw_json) : null;
const rawMatches = verified?.raw_json === importedRaw;
const fileMatches = verifiedFile && isDeepStrictEqual(JSON.parse(serializeExecApprovals(verifiedFile)), JSON.parse(serializeExecApprovals(legacyFile)));
if (!rawMatches || !fileMatches) throw new Error(`SQLite verification failed for the exec approvals migration (raw=${rawMatches}, parsed=${Boolean(fileMatches)})`);
}
const reportJson = JSON.stringify({
source: MIGRATION_KIND,
target: TARGET_TABLE,
decision,
sourceSha256: params.snapshot.sha256,
sourceValid: legacyFile !== null,
...params.emptyStub ? { archivePath: params.emptyStub.archivePath } : {},
importedRecordCount: decision === "legacy-imported" || decision === "invalid-canonical-repaired" ? 1 : 0,
preservedSqliteRecordCount: canonical && decision !== "legacy-imported" && decision !== "invalid-canonical-repaired" && decision !== "malformed-legacy-preserved" ? 1 : 0,
removesSource: removeSource
});
recordLegacyMigrationReceipt(db, {
sourceKey,
migrationKind: MIGRATION_KIND,
sourcePath: params.sourcePath,
targetTable: TARGET_TABLE,
sourceSha256: params.snapshot.sha256,
sourceSizeBytes: params.snapshot.size,
sourceRecordCount: legacyFile && decision !== "empty-legacy-retired" ? 1 : 0,
runId,
now,
reportJson,
upsert: true
});
return {
message: decisionMessage(decision, removeSource) + (legacy.ok ? "" : ` First problem: ${legacy.error}. Repair exec-approvals.json locally, then rerun \`openclaw doctor --fix\` with the same OPENCLAW_STATE_DIR.`),
removeSource,
sourceKey
};
}, { env: params.env }, { operationLabel: "state-migration.exec-approvals" });
}
function decisionMessage(decision, removeSource) {
switch (decision) {
case "empty-legacy-retired": return "Archived empty legacy exec approvals without changing SQLite policy.";
case "legacy-imported": return "Imported legacy exec approvals into shared SQLite state.";
case "invalid-canonical-repaired": return "Replaced an invalid SQLite exec approvals row with validated legacy state.";
case "canonical-preserved": return removeSource ? "Preserved byte-identical canonical SQLite exec approvals." : "Preserved canonical SQLite exec approvals and retained conflicting legacy JSON.";
case "malformed-legacy-preserved": return "Preserved malformed legacy exec approvals for operator recovery.";
case "receipt-authoritative": return "Completed cleanup for previously imported legacy exec approvals.";
}
return decision;
}
async function migrateWithExclusiveStateOwnership(params) {
const sourcePath = params.detected.sourcePath;
const source = new LegacyMigrationSourceClaim({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath,
label: "exec approvals",
includeFilePath: false,
claimSuffix: DOCTOR_CLAIM_SUFFIX,
readSnapshot: (snapshotPath) => readLegacySourceSnapshot(params.stateRoot, params.stateDir, snapshotPath)
});
try {
await source.recover("legacy exec approvals source and interrupted claim both exist");
} catch (error) {
return {
changes: [],
warnings: [`Failed recovering a legacy exec approvals Doctor claim: ${String(error)}`]
};
}
if (!await source.exists()) return {
changes: [],
warnings: []
};
let snapshot;
try {
snapshot = await source.read();
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy exec approvals: ${String(error)}`]
};
}
try {
params.beforeVerify?.();
if (!legacyMigrationSourceSnapshotsMatch(await source.read(), snapshot)) throw new Error("legacy exec approvals changed after migration loaded them");
await source.claim({
snapshot,
mismatchMessage: "legacy exec approvals changed before migration could claim them",
beforeClaim: params.beforeClaim
});
} catch (error) {
const restoreError = await source.restore();
return {
changes: [],
warnings: [`Failed claiming legacy exec approvals: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`]
};
}
let result;
let emptyStub;
try {
const parsedStub = emptyLegacyExecApprovalsSchema.safeParse(snapshot.raw === null ? null : safeParseJsonRecord(snapshot.raw));
if (parsedStub.success) {
const archiveSuffix = `.migrated.${snapshot.sha256}.${randomUUID()}`;
const archivePath = `${sourcePath}${archiveSuffix}`;
await params.stateRoot.create(`${source.sourceRelativePath}${archiveSuffix}`, snapshot.buffer, { mode: 384 });
if ((await readLegacySourceSnapshot(params.stateRoot, params.stateDir, archivePath)).sha256 !== snapshot.sha256) throw new Error("legacy exec approvals archive differs from the claimed source");
emptyStub = {
archivePath,
file: normalizeExecApprovalsInternal({
...parsedStub.data,
version: 1
})
};
}
result = decideAndRecordMigration({
env: params.env,
sourcePath,
snapshot,
emptyStub
});
} catch (error) {
const restoreError = await source.restore();
return {
changes: [],
warnings: [`Failed migrating legacy exec approvals: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`]
};
}
if (!result.removeSource) {
const restoreError = await source.restore();
return {
changes: [],
warnings: [`${result.message}${restoreError ? ` Claim restore failed: ${restoreError}` : ""}`]
};
}
try {
await source.remove({
removeSource: params.removeSource,
sourceReappearedMessage: "legacy exec approvals reappeared during migration cleanup",
remainingMessage: "legacy exec approvals remain after migration cleanup"
});
} catch (error) {
return {
changes: [],
warnings: [`Legacy exec approvals cleanup failed: ${String(error)}`]
};
}
const warnings = [];
try {
markLegacyMigrationSourceRemoved(result.sourceKey, params.env, "state-migration.exec-approvals.receipt");
} catch (error) {
warnings.push(`Legacy exec approvals were removed, but their receipt could not be finalized: ${String(error)}`);
}
return {
changes: [result.message],
warnings,
notices: [...emptyStub ? [`Archived empty legacy exec approvals at ${emptyStub.archivePath}.`] : [], "Removed retired exec approvals JSON after recording its migration decision."]
};
}
/** Import or retire the old file under exclusive state ownership. */
async function migrateLegacyExecApprovals(params) {
const detected = params.detected;
if (!detected?.hasLegacy) return {
changes: [],
warnings: []
};
return await withLegacyMigrationStateLock({
stateDir: params.stateDir,
env: params.env,
label: "legacy exec approvals",
releaseLabel: "Exec approvals",
errorLabel: "Failed reading legacy exec approvals",
retryGuidance: "Stop the Gateway, then run `openclaw doctor --fix` again.",
run: async (env) => {
const stateRoot = await root(params.stateDir, {
hardlinks: "reject",
maxBytes: MAX_LEGACY_EXEC_APPROVALS_BYTES,
symlinks: "reject"
});
return await migrateWithExclusiveStateOwnership({
...params,
detected,
env,
stateRoot
});
}
});
}
//#endregion
export { claimAndRemoveLegacyMigrationSource as a, legacyMigrationSourceSnapshotsMatch as c, resolveLegacyMigrationRelativePath as d, restoreLegacyMigrationSourceClaims as f, assertLegacyMigrationSourceUnchanged as i, readLegacyMigrationSourceSnapshot as l, migrateLegacyExecApprovals as n, claimLegacyMigrationSourceClaims as o, LegacyMigrationSourceClaim as r, legacyMigrationSourceOrClaimMayExist as s, detectLegacyExecApprovals as t, readLegacyMigrationSourceSnapshotSync as u };