import fs from "node:fs/promises"; import path from "node:path"; import { readConfigFileSnapshot, resolveConfigPath, resolveOAuthDir, resolveStateDir, } from "../config/config.js"; import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js"; import { pathExists, shortenHomePath } from "../utils.js"; import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js"; export type BackupAssetKind = "state" | "config" | "credentials" | "workspace"; export type BackupSkipReason = "covered" | "missing"; export type BackupAsset = { kind: BackupAssetKind; sourcePath: string; displayPath: string; archivePath: string; }; export type SkippedBackupAsset = { kind: BackupAssetKind; sourcePath: string; displayPath: string; reason: BackupSkipReason; coveredBy?: string; }; export type BackupPlan = { stateDir: string; configPath: string; oauthDir: string; workspaceDirs: string[]; included: BackupAsset[]; skipped: SkippedBackupAsset[]; }; type BackupAssetCandidate = { kind: BackupAssetKind; sourcePath: string; canonicalPath: string; exists: boolean; }; function backupAssetPriority(kind: BackupAssetKind): number { switch (kind) { case "state": return 0; case "config": return 1; case "credentials": return 2; case "workspace": return 3; } } export function buildBackupArchiveRoot(nowMs = Date.now()): string { return `${formatSessionArchiveTimestamp(nowMs)}-openclaw-backup`; } export function buildBackupArchiveBasename(nowMs = Date.now()): string { return `${buildBackupArchiveRoot(nowMs)}.tar.gz`; } export function encodeAbsolutePathForBackupArchive(sourcePath: string): string { 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); } export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): string { return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath)); } function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { 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: string): Promise { try { return await fs.realpath(targetPath); } catch { return path.resolve(targetPath); } } export async function resolveBackupPlanFromDisk( params: { includeWorkspace?: boolean; onlyConfig?: boolean; nowMs?: number; } = {}, ): Promise { const includeWorkspace = params.includeWorkspace ?? true; const onlyConfig = params.onlyConfig ?? false; const stateDir = resolveStateDir(); const configPath = resolveConfigPath(); const oauthDir = resolveOAuthDir(); const archiveRoot = buildBackupArchiveRoot(params.nowMs); if (onlyConfig) { const resolvedConfigPath = path.resolve(configPath); if (!(await pathExists(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 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, }); const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : []; const rawCandidates: Array> = [ { kind: "state", sourcePath: path.resolve(stateDir) }, ...(cleanupPlan.configInsideState ? [] : [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]), ...(cleanupPlan.oauthInsideState ? [] : [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]), ...(includeWorkspace ? workspaceDirs.map((workspaceDir) => ({ kind: "workspace" as const, sourcePath: path.resolve(workspaceDir), })) : []), ]; const candidates: BackupAssetCandidate[] = await Promise.all( rawCandidates.map(async (candidate) => { const exists = await pathExists(candidate.sourcePath); return { ...candidate, exists, canonicalPath: exists ? await canonicalizeExistingPath(candidate.sourcePath) : path.resolve(candidate.sourcePath), }; }), ); const uniqueCandidates: BackupAssetCandidate[] = []; const seenCanonicalPaths = new Set(); for (const candidate of [...candidates].toSorted(compareCandidates)) { if (seenCanonicalPaths.has(candidate.canonicalPath)) { continue; } seenCanonicalPaths.add(candidate.canonicalPath); uniqueCandidates.push(candidate); } const included: BackupAsset[] = []; const skipped: SkippedBackupAsset[] = []; 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, }; }