import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveHomeDir, resolveUserPath, shortenHomeInString } from "../utils.js"; export type RemovalResult = { ok: boolean; skipped?: boolean; }; export type CleanupResolvedPaths = { stateDir: string; configPath: string; oauthDir: string; configInsideState: boolean; oauthInsideState: boolean; }; export function collectWorkspaceDirs(cfg: OpenClawConfig | undefined): string[] { const dirs = new Set(); const defaults = cfg?.agents?.defaults; if (typeof defaults?.workspace === "string" && defaults.workspace.trim()) { dirs.add(resolveUserPath(defaults.workspace)); } const list = Array.isArray(cfg?.agents?.list) ? cfg?.agents?.list : []; for (const agent of list) { const workspace = (agent as { workspace?: unknown }).workspace; if (typeof workspace === "string" && workspace.trim()) { dirs.add(resolveUserPath(workspace)); } } if (dirs.size === 0) { dirs.add(resolveDefaultAgentWorkspaceDir()); } return [...dirs]; } export function buildCleanupPlan(params: { cfg: OpenClawConfig | undefined; stateDir: string; configPath: string; oauthDir: string; }): { configInsideState: boolean; oauthInsideState: boolean; workspaceDirs: string[]; } { return { configInsideState: isPathWithin(params.configPath, params.stateDir), oauthInsideState: isPathWithin(params.oauthDir, params.stateDir), workspaceDirs: collectWorkspaceDirs(params.cfg), }; } export function isPathWithin(child: string, parent: string): boolean { const relative = path.relative(parent, child); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function isUnsafeRemovalTarget(target: string): boolean { if (!target.trim()) { return true; } const resolved = path.resolve(target); const root = path.parse(resolved).root; if (resolved === root) { return true; } const home = resolveHomeDir(); if (home && resolved === path.resolve(home)) { return true; } return false; } export async function removePath( target: string, runtime: RuntimeEnv, opts?: { dryRun?: boolean; label?: string }, ): Promise { if (!target?.trim()) { return { ok: false, skipped: true }; } 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 (opts?.dryRun) { runtime.log(`[dry-run] remove ${displayLabel}`); return { ok: true, skipped: true }; } try { 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 }; } } export async function removeStateAndLinkedPaths( cleanup: CleanupResolvedPaths, runtime: RuntimeEnv, opts?: { dryRun?: boolean }, ): Promise { await removePath(cleanup.stateDir, runtime, { dryRun: opts?.dryRun, label: cleanup.stateDir, }); if (!cleanup.configInsideState) { await removePath(cleanup.configPath, runtime, { dryRun: opts?.dryRun, label: cleanup.configPath, }); } if (!cleanup.oauthInsideState) { await removePath(cleanup.oauthDir, runtime, { dryRun: opts?.dryRun, label: cleanup.oauthDir, }); } } export async function removeWorkspaceDirs( workspaceDirs: readonly string[], runtime: RuntimeEnv, opts?: { dryRun?: boolean }, ): Promise { for (const workspace of workspaceDirs) { await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace, }); } } export async function listAgentSessionDirs(stateDir: string): Promise { const root = path.join(stateDir, "agents"); try { const entries = await fs.readdir(root, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) .map((entry) => path.join(root, entry.name, "sessions")); } catch { return []; } }