UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

390 lines (389 loc) • 18.3 kB
import { l as movePathToTrash } from "./fs-safe-CS1DrPTK.mjs"; import { d as resolveHomeDir, f as shortenHomeInString, p as shortenHomePath } from "./utils-DUAwk2p5.mjs"; import { t as resolveIdentityPathViaExistingAncestorSync } from "./boundary-path-CAv_plX7.mjs"; import { r as isMissingPathError } from "./errno-FR4CRyya.mjs"; import { v as resolveGatewayLockDir } from "./paths-FklbMVOH.mjs"; import { t as formatErrorMessage } from "./errors-BSdPMFao.mjs"; import { s as resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths-BvBEgHyD.mjs"; import { p as prepareOpenClawStateDatabaseRemoval } from "./openclaw-state-db-cache-BEArcjbO.mjs"; import { d as resolveAgentWorkspaceDir } from "./agent-scope-config-BU4qc9bA.mjs"; import { i as listAgentIds } from "./agent-roster-B45JERT5.mjs"; import { t as resolveDefaultAgentWorkspaceDir } from "./workspace-default-path-BG9KN4ds.mjs"; import "./workspace-default-CQbD5G-j.mjs"; import { n as GatewayLockError, r as acquireGatewayLock } from "./gateway-lock-BISyyIm_.mjs"; import { i as prepareWorkspaceStateDeletion, n as deleteWorkspaceState } from "./workspace-state-store-Dlp6lK_p.mjs"; import { d as removeLegacyWorkspaceStateForReset, u as prepareLegacyWorkspaceStateReset } from "./workspace-legacy-state-DJRN0alI.mjs"; import path from "node:path"; import fs from "node:fs/promises"; import { hasNodeErrorCode, isPathInside } from "@openclaw/fs-safe/path"; //#region src/commands/cleanup-utils.ts const STATE_CLEANUP_LOCK_TIMEOUT_MS = 250; const STATE_CLEANUP_LOCK_POLL_INTERVAL_MS = 25; function trashFailure(pathname, error, runtime) { runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`); return { failed: { path: pathname, reason: formatErrorMessage(error) } }; } async function moveToTrashResult(pathname, runtime, assertCurrent) { if (!pathname) return { failed: { path: pathname, reason: "path is empty" } }; let isSymbolicLink; try { isSymbolicLink = (await fs.lstat(pathname)).isSymbolicLink(); } catch (error) { return isMissingPathError(error) ? { removed: { path: pathname, method: "missing" } } : trashFailure(pathname, error, runtime); } try { const sourcePath = await resolveMoveToTrashSourcePath(path.resolve(pathname)); const allowedRoots = trashAllowedRoots([sourcePath], isSymbolicLink ? await fs.realpath(sourcePath).catch(() => void 0) : void 0); assertCurrent?.(); await movePathToTrash(sourcePath, { allowedRoots }); runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`); return { removed: { path: pathname, method: "trash" } }; } catch (error) { return trashFailure(pathname, error, runtime); } } /** Moves a path to Trash when it exists, logging a manual-delete fallback on failure. */ async function moveToTrash(pathname, runtime, assertCurrent) { return "removed" in await moveToTrashResult(pathname, runtime, assertCurrent); } /** * Allowed Trash roots for OpenClaw-owned paths: each declared path's own parent, plus the * resolved parent when the moved path is a symlink (fs-safe checks the link target, and * moving a link never touches the directory behind it). fs-safe's default roots (home + tmp) * alone refuse every path of a state dir on a volume such as `/data`. */ function trashAllowedRoots(declaredPaths, resolvedLinkPath) { const roots = declaredPaths.map((declaredPath) => path.dirname(declaredPath)); if (resolvedLinkPath !== void 0) roots.push(path.dirname(resolvedLinkPath)); return [...new Set(roots)]; } async function resolveMoveToTrashSourcePath(targetPath) { return path.join(await fs.realpath(path.dirname(targetPath)), path.basename(targetPath)); } function collectWorkspaceDirs(cfg) { const dirs = /* @__PURE__ */ new Set(); if (!cfg) { dirs.add(resolveDefaultAgentWorkspaceDir()); return [...dirs]; } for (const agentId of listAgentIds(cfg)) dirs.add(resolveAgentWorkspaceDir(cfg, agentId)); return [...dirs]; } /** Determine which config, credential, and workspace paths cleanup should consider. */ function buildCleanupPlan(params) { return { configInsideState: isPathInside(params.stateDir, params.configPath), oauthInsideState: isPathInside(params.stateDir, params.oauthDir), workspaceDirs: collectWorkspaceDirs(params.cfg) }; } function isUnsafeRemovalTarget(target) { if (!target.trim()) return true; const resolved = path.resolve(target); if (resolved === path.parse(resolved).root) return true; const home = resolveHomeDir(); if (home && resolved === path.resolve(home)) return true; if (isPathInside(resolved, path.resolve(process.cwd()))) return true; return false; } /** Remove one path after rejecting empty/root/home targets and honoring dry-run mode. */ async function removePath(target, runtime, opts) { return removePathPreserving(target, [], runtime, opts); } async function pathExists(target) { try { await fs.lstat(target); return true; } catch (error) { if (hasNodeErrorCode(error, "ENOENT")) return false; throw error; } } async function existingPaths(paths) { const existing = []; for (const target of paths) { if (!target?.trim()) continue; const resolved = path.resolve(target); try { await fs.lstat(resolved); existing.push(resolved); } catch {} } return existing; } async function acquireStateCleanupOwnership(cleanup) { const env = { ...process.env, OPENCLAW_CONFIG_PATH: cleanup.configPath, OPENCLAW_STATE_DIR: cleanup.stateDir }; let lock; try { lock = await acquireGatewayLock({ allowInTests: true, env, pollIntervalMs: STATE_CLEANUP_LOCK_POLL_INTERVAL_MS, role: "sqlite-maintenance", timeoutMs: STATE_CLEANUP_LOCK_TIMEOUT_MS }); } catch (error) { if (error instanceof GatewayLockError) throw new Error("Cannot remove OpenClaw state while the Gateway or another state maintenance command owns this state directory. Stop the Gateway and retry.", { cause: error }); throw error; } if (!lock) throw new Error("Cannot remove OpenClaw state without exclusive state ownership."); return lock; } async function removePathPreserving(target, preservePaths, runtime, opts) { if (!target?.trim()) return { ok: false }; const resolved = path.resolve(target); const label = opts?.label ?? resolved; const displayLabel = shortenHomeInString(label); if (isUnsafeRemovalTarget(resolved)) { runtime.error(`Refusing to remove unsafe path: ${displayLabel}`); return { ok: false }; } if (preservePaths.some((preservePath) => isPathInside(preservePath, resolved))) return { ok: true }; const nestedPreservedPaths = preservePaths.filter((preservePath) => isPathInside(resolved, preservePath)); if (opts?.dryRun) { const suffix = nestedPreservedPaths.length ? ` preserving ${nestedPreservedPaths.map((preservePath) => shortenHomeInString(preservePath)).join(", ")}` : ""; runtime.log(`[dry-run] remove ${displayLabel}${suffix}`); return { ok: true }; } try { if (nestedPreservedPaths.length > 0 && (await fs.lstat(resolved)).isDirectory()) { for (const entry of await fs.readdir(resolved)) { const result = await removePathPreserving(path.join(resolved, entry), preservePaths, runtime); if (!result.ok) return result; } runtime.log(`Removed contents of ${displayLabel}`); } else { await fs.rm(resolved, { recursive: true, force: true }); runtime.log(`Removed ${displayLabel}`); } return { ok: true }; } catch (err) { runtime.error(`Failed to remove ${displayLabel}: ${String(err)}`); return { ok: false }; } } async function removeStateDirectoryAlias(requestedStateDir, stateDir, assertCurrent) { if (requestedStateDir === stateDir) return; try { if ((await fs.lstat(requestedStateDir)).isSymbolicLink()) { assertCurrent(); await fs.unlink(requestedStateDir); } } catch (error) { if (!hasNodeErrorCode(error, "ENOENT")) throw error; } } function stateCleanupInterrupted() { return /* @__PURE__ */ new Error("OpenClaw state cleanup was interrupted by a new state operation. Stop other OpenClaw commands and retry."); } async function captureStateCleanupAncestors(lockDirs, stateDir) { const directories = /* @__PURE__ */ new Map(); for (const lockDir of lockDirs) for (let current = lockDir; isPathInside(stateDir, current); current = path.dirname(current)) { if (!directories.has(current)) { const observed = await fs.lstat(current, { bigint: true }); if (!observed.isDirectory()) throw new Error(`Cannot remove OpenClaw state because its active lock directory is redirected or not a real directory: ${shortenHomeInString(current)}. Restore a real lock directory and retry.`); directories.set(current, { path: current, dev: observed.dev, ino: observed.ino }); } if (current === stateDir) break; } return [...directories.values()].toSorted((left, right) => right.path.length - left.path.length); } async function removeEmptyStateAncestors(directories) { for (const expected of directories) try { const observed = await fs.lstat(expected.path, { bigint: true }); if (!observed.isDirectory() || observed.dev !== expected.dev || observed.ino !== expected.ino) throw stateCleanupInterrupted(); await fs.rmdir(expected.path); } catch (error) { if (hasNodeErrorCode(error, "ENOENT")) continue; if (hasNodeErrorCode(error, "ENOTEMPTY") || hasNodeErrorCode(error, "EEXIST")) continue; throw error; } } function linkedCleanupPaths(cleanup) { return [cleanup.configInsideState ? void 0 : cleanup.configPath, cleanup.oauthInsideState ? void 0 : cleanup.oauthDir].filter((target) => target !== void 0); } async function resolveLinkedCleanupPath(target, lockPaths) { if (!await pathExists(target)) return; const entry = await resolveMoveToTrashSourcePath(path.resolve(target)); if (lockPaths.some((lockPath) => isPathInside(entry, lockPath))) throw new Error(`Cannot remove linked cleanup path ${shortenHomeInString(target)} because it contains an active state lock. Move the linked path outside the lock directory and retry.`); return entry; } async function removeLinkedCleanupPaths(cleanup, runtime, lockPaths, assertCurrent) { for (const target of linkedCleanupPaths(cleanup)) { const entry = await resolveLinkedCleanupPath(target, lockPaths); assertCurrent(); if (entry && !(await removePath(entry, runtime, { label: target })).ok) throw new Error(`Failed to remove linked cleanup path: ${shortenHomeInString(target)}`); } } /** Remove state plus config/OAuth paths, preserving selected paths nested inside state. */ async function removeStateAndLinkedPaths(cleanup, runtime, opts) { const requestedStateDir = path.resolve(cleanup.stateDir); const requestedPreservePaths = opts?.dryRun ? (opts.preservePaths ?? []).map((target) => path.resolve(target)) : await existingPaths(opts?.preservePaths ?? []); if (opts?.dryRun) { const preservePaths = requestedPreservePaths.filter((target) => isPathInside(requestedStateDir, target)); const stateRemoval = await removePathPreserving(preservePaths.length > 0 ? requestedStateDir : cleanup.stateDir, preservePaths, runtime, { dryRun: true, label: cleanup.stateDir }); const configRemoval = cleanup.configInsideState ? { ok: true } : await removePath(cleanup.configPath, runtime, { dryRun: true, label: cleanup.configPath }); const oauthRemoval = cleanup.oauthInsideState ? { ok: true } : await removePath(cleanup.oauthDir, runtime, { dryRun: true, label: cleanup.oauthDir }); return stateRemoval.ok && configRemoval.ok && oauthRemoval.ok; } if (isUnsafeRemovalTarget(requestedStateDir)) { runtime.error(`Refusing to remove unsafe path: ${shortenHomeInString(cleanup.stateDir)}`); return false; } const lock = await acquireStateCleanupOwnership(cleanup); let lockHeld = true; let removalAdmission; const releaseLock = async () => { if (!lockHeld) return; lockHeld = false; await lock.release(); }; const releaseRemovalAdmission = () => { const held = removalAdmission; removalAdmission = void 0; held?.release(); }; try { const stateDir = lock.stateDir; if (isUnsafeRemovalTarget(stateDir)) throw new Error(`Refusing to remove unsafe path: ${shortenHomeInString(stateDir)}`); const lockPaths = await Promise.all([.../* @__PURE__ */ new Set([lock.lockPath, lock.stateLockPath])].map(resolveMoveToTrashSourcePath)); const lockDirs = [...new Set(lockPaths.map((lockPath) => path.dirname(lockPath)))]; for (const target of linkedCleanupPaths(cleanup)) await resolveLinkedCleanupPath(target, lockPaths); lock.assertCurrent(); const databasePath = resolveOpenClawStateSqlitePath({ ...process.env, OPENCLAW_STATE_DIR: stateDir }); if (resolveIdentityPathViaExistingAncestorSync(databasePath) !== databasePath) throw new Error("Cannot remove OpenClaw state because its active database path is redirected. Select the actual state directory before retrying cleanup."); const directories = await captureStateCleanupAncestors([ ...lockDirs, path.dirname(lock.stateLockPath), ...process.platform === "win32" ? [] : [resolveGatewayLockDir(stateDir)] ], stateDir); lock.assertCurrent(); removalAdmission = await lock.run(() => prepareOpenClawStateDatabaseRemoval(databasePath, lock.assertCurrent)); const preservePaths = requestedPreservePaths.map((target) => isPathInside(requestedStateDir, target) ? path.join(stateDir, path.relative(requestedStateDir, target)) : target).filter((target) => isPathInside(stateDir, target)); const overlappingPreservePath = preservePaths.find((target) => lockDirs.some((lockDir) => isPathInside(lockDir, target) || isPathInside(target, lockDir))); if (overlappingPreservePath) throw new Error(`Cannot remove OpenClaw state while preserving ${shortenHomeInString(overlappingPreservePath)} because it overlaps the active state lock. Move the workspace outside the lock directory and retry.`); removalAdmission.assertCurrent(); if (!(await removePathPreserving(stateDir, [...preservePaths, ...lockPaths], runtime, { label: cleanup.stateDir })).ok) throw new Error("Failed to remove non-preserved OpenClaw state while ownership was held."); removalAdmission.assertCurrent(); await removeLinkedCleanupPaths(cleanup, runtime, lockPaths, lock.assertCurrent); if (preservePaths.length === 0) { removalAdmission.assertCurrent(); await removeStateDirectoryAlias(requestedStateDir, stateDir, lock.assertCurrent); } removalAdmission.assertCurrent(); await releaseLock(); await removeEmptyStateAncestors(directories); if ((await Promise.all([...lockPaths, ...lockDirs.filter((lockDir) => isPathInside(stateDir, lockDir))].map(pathExists))).some(Boolean) || preservePaths.length === 0 && await pathExists(stateDir)) throw stateCleanupInterrupted(); return true; } finally { try { releaseRemovalAdmission(); } finally { await releaseLock(); } } } /** Remove all workspace directories selected by the cleanup plan. */ async function removeWorkspaceDirs(workspaceDirs, runtime, opts) { const failures = /* @__PURE__ */ new Set(); const attempt = async (label, action) => { try { return await action(); } catch (error) { failures.add(label); runtime.error?.(`Failed to clean up ${shortenHomeInString(label)}: ${String(error)}`); return; } }; for (const workspace of workspaceDirs) { const legacyLabel = `${workspace} (retired workspace state)`; const stateLabel = `${workspace} (workspace state)`; const legacyPlan = await attempt(legacyLabel, () => prepareLegacyWorkspaceStateReset(workspace)); const statePlan = opts?.removeStateRows ? await attempt(stateLabel, () => prepareWorkspaceStateDeletion(workspace)) : void 0; if (!(opts?.preserveWorkspace ? { ok: true } : opts?.removeWorkspace ? { ok: await attempt(workspace, () => opts.removeWorkspace(workspace)) === true } : await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace })).ok) { failures.add(workspace); continue; } if (legacyPlan) { const legacyCleanup = await attempt(legacyLabel, () => removeLegacyWorkspaceStateForReset(legacyPlan, opts?.dryRun ? { dryRun: true } : void 0)); if (legacyCleanup) { if (opts?.dryRun) for (const removedPath of legacyCleanup.removedPaths) runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`); for (const warning of legacyCleanup.warnings) { (opts?.removeWorkspace ? runtime.log : runtime.error)(warning); failures.add(warning); } } } if (!opts?.dryRun && statePlan) await attempt(stateLabel, () => deleteWorkspaceState(statePlan)); } return [...failures]; } /** Reset canonical session history while preserving each database's unrelated state. */ async function removeAgentSessions(cleanup, runtime, opts) { const { previewSessionStoreReset, resetSessionStore } = await import("./session-accessor.sqlite-reset-C90cvHoj.mjs"); const { resolveAllAgentSessionStoreTargetsSync } = await import("./targets-CiqcUql-.mjs"); const lock = opts?.dryRun ? void 0 : await acquireStateCleanupOwnership(cleanup); const resetStores = async () => { const failures = []; for (const target of resolveAllAgentSessionStoreTargetsSync(cleanup.cfg)) try { const preview = previewSessionStoreReset(target); const label = shortenHomePath(preview.databasePath); if (opts?.dryRun) { runtime.log(`[dry-run] remove session history from ${label}: ${preview.sessionKeys.length} sessions, ${preview.transcriptCount} transcripts, ${preview.archiveCount} retained archives`); for (const sessionKey of preview.sessionKeys) runtime.log(`[dry-run] remove session ${sessionKey}`); for (const artifact of preview.artifactPaths) runtime.log(`[dry-run] remove ${shortenHomePath(artifact)}`); } else { await resetSessionStore(target); runtime.log(`Removed session history from ${label}`); } } catch (error) { failures.push(`${shortenHomePath(target.storePath)}: ${String(error)}`); } if (failures.length > 0) throw new Error(failures.join("\n")); }; try { await (lock ? lock.run(resetStores) : resetStores()); } finally { await lock?.release(); } } //#endregion export { removePath as a, trashAllowedRoots as c, removeAgentSessions as i, moveToTrash as n, removeStateAndLinkedPaths as o, moveToTrashResult as r, removeWorkspaceDirs as s, buildCleanupPlan as t };