openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
700 lines (699 loc) • 27.5 kB
JavaScript
import { S as resolveDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { _ as resolveOAuthDir, s as resolveConfigPath, y as resolveStateDir } from "./paths-mvMm5bYV.js";
import { f as resolveHomeDir, g as shortenHomePath, p as resolveUserPath, u as pathExists$1 } from "./utils-CCC-BEJH.js";
import { s as resolveRuntimeServiceVersion } from "./version-Crcn9X9T.js";
import { m as writeJson } from "./json-files-2umMHm0W.js";
import { c as resolveOpenClawStateSqlitePath } from "./openclaw-state-db-ULtH_b7x.js";
import { n as requireNodeSqlite } from "./sqlite-wal-CP9DVd__.js";
import { u as readConfigFileSnapshot } from "./io-Gi7-pyU-.js";
import "./config-C9RxTsn1.js";
import { n as isPathWithin, t as buildCleanupPlan } from "./cleanup-utils-CER_zAek.js";
import { constants } from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import os from "node:os";
import { randomUUID } from "node:crypto";
//#region src/commands/backup-shared.ts
function backupAssetPriority(kind) {
switch (kind) {
case "state": return 0;
case "config": return 1;
case "credentials": return 2;
case "workspace": return 3;
}
throw new Error("Unsupported backup asset kind");
}
/** Format a filesystem-safe local timestamp with explicit UTC offset for backup names. */
function formatBackupArchiveTimestamp(nowMs = Date.now(), offsetMinutes = -new Date(nowMs).getTimezoneOffset()) {
const shifted = nowMs + offsetMinutes * 6e4;
const local = new Date(shifted);
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0");
return `${String(local.getUTCFullYear()).padStart(4, "0")}-${String(local.getUTCMonth() + 1).padStart(2, "0")}-${String(local.getUTCDate()).padStart(2, "0")}T${String(local.getUTCHours()).padStart(2, "0")}-${String(local.getUTCMinutes()).padStart(2, "0")}-${String(local.getUTCSeconds()).padStart(2, "0")}.${String(local.getUTCMilliseconds()).padStart(3, "0")}${sign}${offsetHours}-${offsetMins}`;
}
/** Build the root directory name stored inside a backup tarball. */
function buildBackupArchiveRoot(nowMs = Date.now()) {
return `${formatBackupArchiveTimestamp(nowMs)}-openclaw-backup`;
}
/** Build the default `.tar.gz` filename for a backup archive. */
function buildBackupArchiveBasename(nowMs = Date.now()) {
return `${buildBackupArchiveRoot(nowMs)}.tar.gz`;
}
/** Encode an absolute or relative source path into a traversal-safe archive payload path. */
function encodeAbsolutePathForBackupArchive(sourcePath) {
const normalized = sourcePath.replaceAll("\\", "/");
const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (windowsMatch) {
const drive = windowsMatch[1]?.toUpperCase() ?? "UNKNOWN";
const rest = windowsMatch[2] ?? "";
return path.posix.join("windows", drive, rest);
}
if (normalized.startsWith("/")) return path.posix.join("posix", normalized.slice(1));
return path.posix.join("relative", normalized);
}
/** Build the archive-relative payload path for one source path. */
function buildBackupArchivePath(archiveRoot, sourcePath) {
return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath));
}
/** Resolve a backup plan from explicit paths, deduplicating assets already covered by parents. */
async function resolveBackupPlanFromPaths(params) {
const includeWorkspace = params.includeWorkspace ?? true;
const onlyConfig = params.onlyConfig ?? false;
const stateDir = params.stateDir;
const configPath = params.configPath;
const oauthDir = params.oauthDir;
const archiveRoot = buildBackupArchiveRoot(params.nowMs);
const workspaceDirs = includeWorkspace ? params.workspaceDirs ?? [] : [];
const configInsideState = params.configInsideState ?? false;
const oauthInsideState = params.oauthInsideState ?? false;
if (onlyConfig) {
const resolvedConfigPath = path.resolve(configPath);
if (!await pathExists$1(resolvedConfigPath)) return {
stateDir,
configPath,
oauthDir,
workspaceDirs: [],
included: [],
skipped: [{
kind: "config",
sourcePath: resolvedConfigPath,
displayPath: shortenHomePath(resolvedConfigPath),
reason: "missing"
}]
};
const canonicalConfigPath = await canonicalizeExistingPath(resolvedConfigPath);
return {
stateDir,
configPath,
oauthDir,
workspaceDirs: [],
included: [{
kind: "config",
sourcePath: canonicalConfigPath,
displayPath: shortenHomePath(canonicalConfigPath),
archivePath: buildBackupArchivePath(archiveRoot, canonicalConfigPath)
}],
skipped: []
};
}
const rawCandidates = [
{
kind: "state",
sourcePath: path.resolve(stateDir)
},
...configInsideState ? [] : [{
kind: "config",
sourcePath: path.resolve(configPath)
}],
...oauthInsideState ? [] : [{
kind: "credentials",
sourcePath: path.resolve(oauthDir)
}],
...workspaceDirs.map((workspaceDir) => ({
kind: "workspace",
sourcePath: path.resolve(workspaceDir)
}))
];
const candidates = await Promise.all(rawCandidates.map(async (candidate) => {
const exists = await pathExists$1(candidate.sourcePath);
return Object.assign({}, candidate, {
exists,
canonicalPath: exists ? await canonicalizeExistingPath(candidate.sourcePath) : path.resolve(candidate.sourcePath)
});
}));
const uniqueCandidates = [];
const seenCanonicalPaths = /* @__PURE__ */ new Set();
for (const candidate of [...candidates].toSorted(compareCandidates)) {
if (seenCanonicalPaths.has(candidate.canonicalPath)) continue;
seenCanonicalPaths.add(candidate.canonicalPath);
uniqueCandidates.push(candidate);
}
const included = [];
const skipped = [];
for (const candidate of uniqueCandidates) {
if (!candidate.exists) {
skipped.push({
kind: candidate.kind,
sourcePath: candidate.sourcePath,
displayPath: shortenHomePath(candidate.sourcePath),
reason: "missing"
});
continue;
}
const coveredBy = included.find((asset) => isPathWithin(candidate.canonicalPath, asset.sourcePath));
if (coveredBy) {
skipped.push({
kind: candidate.kind,
sourcePath: candidate.canonicalPath,
displayPath: shortenHomePath(candidate.canonicalPath),
reason: "covered",
coveredBy: coveredBy.displayPath
});
continue;
}
included.push({
kind: candidate.kind,
sourcePath: candidate.canonicalPath,
displayPath: shortenHomePath(candidate.canonicalPath),
archivePath: buildBackupArchivePath(archiveRoot, candidate.canonicalPath)
});
}
return {
stateDir,
configPath,
oauthDir,
workspaceDirs: workspaceDirs.map((entry) => path.resolve(entry)),
included,
skipped
};
}
function compareCandidates(left, right) {
const depthDelta = left.canonicalPath.length - right.canonicalPath.length;
if (depthDelta !== 0) return depthDelta;
const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind);
if (priorityDelta !== 0) return priorityDelta;
return left.canonicalPath.localeCompare(right.canonicalPath);
}
async function canonicalizeExistingPath(targetPath) {
try {
return await fs$1.realpath(targetPath);
} catch {
return path.resolve(targetPath);
}
}
/** Resolve the backup plan from the current OpenClaw state/config/workspace paths on disk. */
async function resolveBackupPlanFromDisk(params = {}) {
const includeWorkspace = params.includeWorkspace ?? true;
const onlyConfig = params.onlyConfig ?? false;
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const oauthDir = resolveOAuthDir();
const configSnapshot = await readConfigFileSnapshot();
if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) throw new Error(`Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`);
const cleanupPlan = buildCleanupPlan({
cfg: configSnapshot.config,
stateDir,
configPath,
oauthDir
});
return await resolveBackupPlanFromPaths({
stateDir,
configPath,
oauthDir,
workspaceDirs: includeWorkspace ? cleanupPlan.workspaceDirs : [],
includeWorkspace,
onlyConfig,
configInsideState: cleanupPlan.configInsideState,
oauthInsideState: cleanupPlan.oauthInsideState,
nowMs: params.nowMs
});
}
//#endregion
//#region src/infra/backup-volatile-filter.ts
/**
* Paths that are known to change during a live backup and commonly trigger
* tar EOF errors. These files are actively appended to (logs, sockets, pid
* markers) while `tar.c()` is reading them, which races with the size recorded
* at `lstat()` time.
*
* Skipping them is safe: they are either recreated on startup, are transient
* by nature, or have durable equivalents elsewhere in state. Snapshotting a
* partial tail of a live log has no restoration value.
*/
const STATE_TRANSIENT_EXTENSIONS = new Set([
".sock",
".pid",
".tmp"
]);
function normalizePosix(input) {
if (!input) return input;
return path.posix.normalize(input.replaceAll("\\", "/"));
}
function isUnder(childPosix, parentPosix) {
if (!parentPosix) return false;
const p = parentPosix.endsWith("/") ? parentPosix : `${parentPosix}/`;
return childPosix === parentPosix || childPosix.startsWith(p);
}
function hasExtension(filePosix, extensions) {
const ext = path.posix.extname(filePosix).toLowerCase();
return extensions.includes(ext);
}
function hasExtensionInSet(filePosix, extensions) {
return extensions.has(path.posix.extname(filePosix).toLowerCase());
}
function isAgentSessionTranscriptPath(filePosix, stateDirPosix) {
const agentsRoot = path.posix.join(stateDirPosix, "agents");
if (!isUnder(filePosix, agentsRoot)) return false;
const parts = path.posix.relative(agentsRoot, filePosix).split("/").filter(Boolean);
return parts.length >= 3 && parts[1] === "sessions";
}
function filePathCandidates(input) {
const normalized = normalizePosix(input);
if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) return [normalized];
return [normalized, normalizePosix(`/${normalized}`)];
}
/**
* Returns true if the given absolute path should be skipped during backup
* because it is a live-mutation target.
*
* Rules:
* - `{stateDir}/sessions/**`/`*.{jsonl,log}` (legacy)
* - `{stateDir}/agents/<agentId>/sessions/**`/`*.{jsonl,log}`
* - `{stateDir}/cron/runs/**`/`*.{jsonl,log}`
* - `{stateDir}/logs/**`/`*.{jsonl,log}`
* - `{stateDir}/{delivery-queue,session-delivery-queue}/**`/`*.{json,delivered,tmp}`
* - `{stateDir}/**`/`*.{sock,pid,tmp}`
*/
function isVolatileBackupPath(absolutePath, plan) {
if (!absolutePath) return false;
const candidates = filePathCandidates(absolutePath);
for (const stateDir of plan.stateDirs) {
if (!stateDir) continue;
const stateDirPosix = normalizePosix(stateDir);
for (const filePosix of candidates) {
if (isUnder(filePosix, path.posix.join(stateDirPosix, "sessions")) && hasExtension(filePosix, [".jsonl", ".log"])) return true;
if (isAgentSessionTranscriptPath(filePosix, stateDirPosix) && hasExtension(filePosix, [".jsonl", ".log"])) return true;
if (isUnder(filePosix, path.posix.join(stateDirPosix, "cron", "runs")) && hasExtension(filePosix, [".jsonl", ".log"])) return true;
if (isUnder(filePosix, path.posix.join(stateDirPosix, "logs")) && hasExtension(filePosix, [".jsonl", ".log"])) return true;
for (const queueDir of ["delivery-queue", "session-delivery-queue"]) if (isUnder(filePosix, path.posix.join(stateDirPosix, queueDir)) && hasExtension(filePosix, [
".json",
".delivered",
".tmp"
])) return true;
if (isUnder(filePosix, stateDirPosix) && hasExtensionInSet(filePosix, STATE_TRANSIENT_EXTENSIONS)) return true;
}
}
return false;
}
//#endregion
//#region src/infra/backup-create.ts
let tarRuntimePromise;
function loadTarRuntime() {
tarRuntimePromise ??= import("tar");
return tarRuntimePromise;
}
var BackupLinkCache = class extends Map {
get(_key) {}
set(_key, _value) {
return this;
}
};
const BACKUP_TAR_MAX_ATTEMPTS = 3;
const BACKUP_TAR_BACKOFF_MS = [1e4, 2e4];
function isTarEofRaceError(err) {
if (!err || typeof err !== "object") return false;
if (err.code === "EOF") return true;
const message = err.message ?? "";
return /(did not encounter expected|encountered unexpected) EOF|TAR_BAD_ARCHIVE/i.test(message);
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function writeTarArchiveWithRetry(params) {
const sleepFn = params.sleepMs ?? sleep;
let lastErr;
for (let attempt = 1; attempt <= BACKUP_TAR_MAX_ATTEMPTS; attempt += 1) try {
await params.runTar();
return;
} catch (err) {
lastErr = err;
if (!isTarEofRaceError(err) || attempt === BACKUP_TAR_MAX_ATTEMPTS) break;
try {
await fs$1.rm(params.tempArchivePath, { force: true });
} catch (cleanupErr) {
const code = cleanupErr.code;
if (code && code !== "ENOENT") params.log?.(`Backup archiver could not remove temp archive ${params.tempArchivePath} between retries: ${code}. Continuing.`);
}
const backoff = BACKUP_TAR_BACKOFF_MS[attempt - 1] ?? 0;
const offendingPath = err.path;
params.log?.(`Backup archiver hit a live-write race${offendingPath ? ` on ${offendingPath}` : ""} (attempt ${attempt}/${BACKUP_TAR_MAX_ATTEMPTS}); retrying in ${Math.round(backoff / 1e3)}s.`);
await sleepFn(backoff);
}
const final = lastErr instanceof Error ? lastErr : new Error(String(lastErr));
const offendingPath = lastErr?.path;
const suffix = offendingPath ? ` (last offending path: ${offendingPath}, after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)` : ` (after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)`;
throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final });
}
async function resolveOutputPath(params) {
const basename = buildBackupArchiveBasename(params.nowMs);
const rawOutput = params.output?.trim();
if (!rawOutput) {
const cwd = path.resolve(process.cwd());
const canonicalCwd = await fs$1.realpath(cwd).catch(() => cwd);
const defaultDir = params.includedAssets.some((asset) => isPathWithin(canonicalCwd, asset.sourcePath)) ? resolveHomeDir() ?? path.dirname(params.stateDir) : cwd;
return path.resolve(defaultDir, basename);
}
const resolved = resolveUserPath(rawOutput);
if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) return path.join(resolved, basename);
try {
if ((await fs$1.stat(resolved)).isDirectory()) return path.join(resolved, basename);
} catch {}
return resolved;
}
async function assertOutputPathReady(outputPath) {
try {
await fs$1.access(outputPath);
throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`);
} catch (err) {
if (err?.code === "ENOENT") return;
throw err;
}
}
function buildTempArchivePath(outputPath) {
return `${outputPath}.${randomUUID()}.tmp`;
}
async function pathExists(targetPath) {
try {
await fs$1.access(targetPath);
return true;
} catch {
return false;
}
}
async function chooseBackupTempRoot(params) {
const systemTmp = os.tmpdir();
const canonicalSystemTmp = await canonicalizePathForContainment(systemTmp);
if (!params.assets.some((asset) => isPathWithin(canonicalSystemTmp, asset.sourcePath))) return systemTmp;
const fallback = path.dirname(params.outputPath);
const canonicalFallback = await canonicalizePathForContainment(fallback);
const fallbackInsideAsset = params.assets.find((asset) => isPathWithin(canonicalFallback, asset.sourcePath));
if (fallbackInsideAsset) throw new Error(`Backup temp root cannot be placed outside every source path: ${systemTmp} and ${fallback} both overlap ${fallbackInsideAsset.sourcePath}.`);
return fallback;
}
function isLinkUnsupportedError(code) {
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
}
async function publishTempArchive(params) {
try {
await fs$1.link(params.tempArchivePath, params.outputPath);
} catch (err) {
const code = err?.code;
if (code === "EEXIST") throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { cause: err });
if (!isLinkUnsupportedError(code)) throw err;
try {
await fs$1.copyFile(params.tempArchivePath, params.outputPath, constants.COPYFILE_EXCL);
} catch (copyErr) {
const copyCode = copyErr?.code;
if (copyCode !== "EEXIST") await fs$1.rm(params.outputPath, { force: true }).catch(() => void 0);
if (copyCode === "EEXIST") throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { cause: copyErr });
throw copyErr;
}
}
await fs$1.rm(params.tempArchivePath, { force: true });
}
async function canonicalizePathForContainment(targetPath) {
const resolved = path.resolve(targetPath);
const suffix = [];
let probe = resolved;
while (true) try {
const realProbe = await fs$1.realpath(probe);
return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed());
} catch {
const parent = path.dirname(probe);
if (parent === probe) return resolved;
suffix.push(path.basename(probe));
probe = parent;
}
}
function buildManifest(params) {
return {
schemaVersion: 1,
createdAt: params.createdAt,
archiveRoot: params.archiveRoot,
runtimeVersion: resolveRuntimeServiceVersion(),
platform: process.platform,
nodeVersion: process.version,
options: {
includeWorkspace: params.includeWorkspace,
onlyConfig: params.onlyConfig
},
paths: {
stateDir: params.stateDir,
configPath: params.configPath,
oauthDir: params.oauthDir,
workspaceDirs: params.workspaceDirs
},
assets: params.assets.map((asset) => ({
kind: asset.kind,
sourcePath: asset.sourcePath,
archivePath: asset.archivePath
})),
skipped: params.skipped.map((entry) => ({
kind: entry.kind,
sourcePath: entry.sourcePath,
reason: entry.reason,
coveredBy: entry.coveredBy
}))
};
}
function formatBackupCreateSummary(result) {
const lines = [`Backup archive: ${result.archivePath}`];
lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`);
for (const asset of result.assets) lines.push(`- ${asset.kind}: ${asset.displayPath}`);
if (result.skipped.length > 0) {
lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`);
for (const entry of result.skipped) if (entry.reason === "covered" && entry.coveredBy) lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`);
else lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`);
}
if (result.dryRun) lines.push("Dry run only; archive was not written.");
else {
lines.push(`Created ${result.archivePath}`);
if (result.skippedVolatileCount > 0) lines.push(`Skipped ${result.skippedVolatileCount} volatile file${result.skippedVolatileCount === 1 ? "" : "s"} (live sessions, cron logs, queues, sockets, pid/tmp).`);
if (result.verified) lines.push("Archive verification: passed");
}
return lines;
}
function remapArchiveEntryPath(params) {
const normalizedEntry = path.resolve(params.entryPath);
if (normalizedEntry === params.manifestPath) return path.posix.join(params.archiveRoot, "manifest.json");
const remappedSourcePath = params.sourcePathRemaps?.get(normalizedEntry);
if (remappedSourcePath) return buildBackupArchivePath(params.archiveRoot, remappedSourcePath);
return buildBackupArchivePath(params.archiveRoot, normalizedEntry);
}
function normalizeBackupFilterPath(value) {
return value.replaceAll("\\", "/").replace(/\/+$/u, "");
}
function buildExtensionsNodeModulesFilter(stateDir) {
const extensionsPrefix = `${normalizeBackupFilterPath(stateDir)}/extensions/`;
return (filePath) => {
const normalizedFilePath = normalizeBackupFilterPath(filePath);
if (!normalizedFilePath.startsWith(extensionsPrefix)) return true;
return !normalizedFilePath.slice(extensionsPrefix.length).split("/").includes("node_modules");
};
}
function tableExistsSql(db, tableName) {
return db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName)?.ok === 1;
}
async function createSanitizedStateSqliteBackupAsset(params) {
const archiveSourcePath = resolveOpenClawStateSqlitePath({
...process.env,
OPENCLAW_STATE_DIR: params.stateDir
});
if (!await pathExists(archiveSourcePath)) return;
const sqlite = requireNodeSqlite();
const source = new sqlite.DatabaseSync(archiveSourcePath, { readOnly: true });
const sourcePath = path.join(params.tempDir, "openclaw-state-backup.sqlite");
try {
source.exec("PRAGMA busy_timeout = 30000;");
source.prepare("VACUUM INTO ?").run(sourcePath);
} finally {
source.close();
}
await fs$1.chmod(sourcePath, 384);
const snapshot = new sqlite.DatabaseSync(sourcePath);
try {
if (tableExistsSql(snapshot, "delivery_queue_entries")) {
snapshot.prepare("DELETE FROM delivery_queue_entries").run();
snapshot.exec("VACUUM;");
}
} finally {
snapshot.close();
}
return {
sourcePath,
archiveSourcePath,
skippedSourcePaths: new Set([
path.resolve(archiveSourcePath),
path.resolve(`${archiveSourcePath}-wal`),
path.resolve(`${archiveSourcePath}-shm`)
])
};
}
async function listAgentSqlitePaths(stateDir) {
const agentsDir = path.join(stateDir, "agents");
const found = [];
async function visit(dir) {
let entries;
try {
entries = await fs$1.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) await visit(entryPath);
else if (entry.isFile() && entry.name === "openclaw-agent.sqlite") found.push(entryPath);
}
}
await visit(agentsDir);
return found;
}
async function createAgentSqliteBackupAssets(params) {
const sqlitePaths = await listAgentSqlitePaths(params.stateDir);
if (sqlitePaths.length === 0) return [];
const sqlite = requireNodeSqlite();
const snapshots = [];
for (const archiveSourcePath of sqlitePaths) {
const source = new sqlite.DatabaseSync(archiveSourcePath, { readOnly: true });
const sourcePath = path.join(params.tempDir, `openclaw-agent-backup-${snapshots.length}.sqlite`);
try {
source.exec("PRAGMA busy_timeout = 30000;");
source.prepare("VACUUM INTO ?").run(sourcePath);
} finally {
source.close();
}
await fs$1.chmod(sourcePath, 384);
snapshots.push({
sourcePath,
archiveSourcePath,
skippedSourcePaths: new Set([
path.resolve(archiveSourcePath),
path.resolve(`${archiveSourcePath}-wal`),
path.resolve(`${archiveSourcePath}-shm`)
])
});
}
return snapshots;
}
async function createBackupArchive(opts = {}) {
const nowMs = resolveDateTimestampMs(opts.nowMs);
const archiveRoot = buildBackupArchiveRoot(nowMs);
const onlyConfig = Boolean(opts.onlyConfig);
const includeWorkspace = onlyConfig ? false : opts.includeWorkspace ?? true;
const plan = await resolveBackupPlanFromDisk({
includeWorkspace,
onlyConfig,
nowMs
});
const outputPath = await resolveOutputPath({
output: opts.output,
nowMs,
includedAssets: plan.included,
stateDir: plan.stateDir
});
if (plan.included.length === 0) throw new Error(onlyConfig ? "No OpenClaw config file was found to back up." : "No local OpenClaw state was found to back up.");
const canonicalOutputPath = await canonicalizePathForContainment(outputPath);
const overlappingAsset = plan.included.find((asset) => isPathWithin(canonicalOutputPath, asset.sourcePath));
if (overlappingAsset) throw new Error(`Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`);
if (!opts.dryRun) await assertOutputPathReady(outputPath);
const createdAt = new Date(nowMs).toISOString();
const result = {
createdAt,
archiveRoot,
archivePath: outputPath,
dryRun: Boolean(opts.dryRun),
includeWorkspace,
onlyConfig,
verified: false,
assets: plan.included,
skipped: plan.skipped,
skippedVolatileCount: 0
};
if (opts.dryRun) return result;
await fs$1.mkdir(path.dirname(outputPath), { recursive: true });
const tempRoot = await chooseBackupTempRoot({
assets: result.assets,
outputPath
});
await fs$1.mkdir(tempRoot, { recursive: true });
const tempDir = await fs$1.mkdtemp(path.join(tempRoot, "openclaw-backup-"));
const manifestPath = path.join(tempDir, "manifest.json");
const tempArchivePath = buildTempArchivePath(outputPath);
const stateAsset = result.assets.find((asset) => asset.kind === "state");
try {
const sanitizedStateSqlite = stateAsset ? await createSanitizedStateSqliteBackupAsset({
stateDir: stateAsset.sourcePath,
tempDir
}) : void 0;
const agentSqliteSnapshots = stateAsset ? await createAgentSqliteBackupAssets({
stateDir: stateAsset.sourcePath,
tempDir
}) : [];
const sourcePathRemaps = /* @__PURE__ */ new Map();
if (sanitizedStateSqlite) sourcePathRemaps.set(path.resolve(sanitizedStateSqlite.sourcePath), sanitizedStateSqlite.archiveSourcePath);
for (const snapshot of agentSqliteSnapshots) sourcePathRemaps.set(path.resolve(snapshot.sourcePath), snapshot.archiveSourcePath);
await writeJson(manifestPath, buildManifest({
createdAt,
archiveRoot,
includeWorkspace,
onlyConfig,
assets: result.assets,
skipped: result.skipped,
stateDir: plan.stateDir,
configPath: plan.configPath,
oauthDir: plan.oauthDir,
workspaceDirs: plan.workspaceDirs
}), { trailingNewline: true });
const tar = await loadTarRuntime();
const extensionsFilter = stateAsset ? buildExtensionsNodeModulesFilter(stateAsset.sourcePath) : void 0;
const volatilePlan = { stateDirs: [stateAsset?.sourcePath ?? plan.stateDir] };
let skippedVolatileCount = 0;
const tarFilter = (entryPath) => {
if (path.resolve(entryPath) === manifestPath) return true;
if (sanitizedStateSqlite?.skippedSourcePaths.has(path.resolve(entryPath)) || agentSqliteSnapshots.some((snapshot) => snapshot.skippedSourcePaths.has(path.resolve(entryPath)))) return false;
if (extensionsFilter && !extensionsFilter(entryPath)) return false;
if (isVolatileBackupPath(entryPath, volatilePlan)) {
skippedVolatileCount += 1;
return false;
}
return true;
};
await writeTarArchiveWithRetry({
tempArchivePath,
log: opts.log,
runTar: () => {
skippedVolatileCount = 0;
return tar.c({
file: tempArchivePath,
gzip: true,
portable: true,
preservePaths: true,
linkCache: new BackupLinkCache(),
filter: tarFilter,
onWriteEntry: (entry) => {
entry.path = remapArchiveEntryPath({
entryPath: entry.path,
manifestPath,
archiveRoot,
sourcePathRemaps
});
}
}, [
manifestPath,
...sanitizedStateSqlite ? [sanitizedStateSqlite.sourcePath] : [],
...agentSqliteSnapshots.map((snapshot) => snapshot.sourcePath),
...result.assets.map((asset) => asset.sourcePath)
]);
}
});
result.skippedVolatileCount = skippedVolatileCount;
if (skippedVolatileCount > 0) opts.log?.(`Backup skipped ${skippedVolatileCount} volatile file${skippedVolatileCount === 1 ? "" : "s"} (live sessions, cron logs, queues, sockets, pid/tmp).`);
await publishTempArchive({
tempArchivePath,
outputPath
});
} finally {
await fs$1.rm(tempArchivePath, { force: true }).catch(() => void 0);
await fs$1.rm(tempDir, {
recursive: true,
force: true
}).catch(() => void 0);
}
return result;
}
//#endregion
export { formatBackupCreateSummary as n, createBackupArchive as t };