From c6e594f572fe1a46a59a0e97dd42467c644f8006 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 12:25:32 -0700 Subject: [PATCH] feat(local): clean uninstall that restores configs to pre-spawn state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users run `spawn local`, spawn writes config files (e.g. `~/.claude/settings.json`, `~/.codex/config.toml`) directly to the user's machine. Previously `spawn uninstall` left those edits in place — survey feedback called this out as a missing piece. This snapshots config files before spawn overwrites them and adds a restore path: - `local/backup.ts` — manifest at `~/.config/spawn/local-backups/` recording `{destPath, backupPath, existed, agent}` per write - `local/local.ts` `uploadFile()` snapshots its destination first (gated on a `setBackupAgent()` call so sandbox/Docker mode skips it) - `local/main.ts` snapshots shell rc files and a per-agent list of paths spawn writes via raw shell (e.g. `~/.claude.json`) before install begins - New `spawn local-restore [agent]` command reverts the writes: pre-existing files are restored byte-for-byte; spawn-created files are removed - `spawn uninstall` now lists "Restore local agent configs" as a default-on checkbox when snapshots exist Bumps CLI to 1.1.0 (new user-facing command). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/package.json | 2 +- .../cli/src/__tests__/local-backup.test.ts | 156 +++++++++++ packages/cli/src/commands/help.ts | 1 + packages/cli/src/commands/index.ts | 2 + packages/cli/src/commands/local-restore.ts | 71 +++++ packages/cli/src/commands/uninstall.ts | 44 +++- packages/cli/src/index.ts | 10 + packages/cli/src/local/backup.ts | 242 ++++++++++++++++++ packages/cli/src/local/local.ts | 14 + packages/cli/src/local/main.ts | 11 + 10 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/__tests__/local-backup.test.ts create mode 100644 packages/cli/src/commands/local-restore.ts create mode 100644 packages/cli/src/local/backup.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 2bf9a7897..95af7202e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.12", + "version": "1.1.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/local-backup.test.ts b/packages/cli/src/__tests__/local-backup.test.ts new file mode 100644 index 000000000..224b8d873 --- /dev/null +++ b/packages/cli/src/__tests__/local-backup.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getBackupRoot, listBackups, restoreBackups, snapshotBeforeWrite, snapshotPaths } from "../local/backup.js"; + +const HOME = process.env.HOME ?? ""; + +function reset(): void { + rmSync(getBackupRoot(), { + recursive: true, + force: true, + }); +} + +describe("local backup primitives", () => { + beforeEach(() => { + reset(); + }); + + afterEach(() => { + reset(); + }); + + it("snapshots an existing file before overwrite and restores its content", () => { + const target = join(HOME, ".claude", "settings.json"); + mkdirSync(join(HOME, ".claude"), { + recursive: true, + }); + writeFileSync(target, '{"theme":"light"}'); + + snapshotBeforeWrite(target, "claude"); + + // Simulate spawn overwriting the file + writeFileSync(target, '{"theme":"dark","spawn":true}'); + + const summary = restoreBackups(); + + expect(summary.restored).toContain(target); + expect(summary.removed).toEqual([]); + expect(summary.failed).toEqual([]); + expect(readFileSync(target, "utf-8")).toBe('{"theme":"light"}'); + }); + + it("removes a file spawn created when no original existed", () => { + const target = join(HOME, ".codex", "config.toml"); + expect(existsSync(target)).toBe(false); + + snapshotBeforeWrite(target, "codex"); + + // Simulate spawn creating the file + mkdirSync(join(HOME, ".codex"), { + recursive: true, + }); + writeFileSync(target, "model = openrouter/auto"); + + const summary = restoreBackups(); + + expect(summary.removed).toContain(target); + expect(summary.restored).toEqual([]); + expect(existsSync(target)).toBe(false); + }); + + it("is idempotent: a second snapshot does not clobber the first", () => { + const target = join(HOME, ".claude.json"); + writeFileSync(target, "ORIGINAL"); + + snapshotBeforeWrite(target, "claude"); + writeFileSync(target, "INTERMEDIATE"); + snapshotBeforeWrite(target, "claude"); + writeFileSync(target, "FINAL"); + + const summary = restoreBackups(); + + expect(readFileSync(target, "utf-8")).toBe("ORIGINAL"); + expect(summary.restored).toEqual([ + target, + ]); + }); + + it("filters by agent when an agent is passed to restoreBackups", () => { + const claudeFile = join(HOME, ".claude", "settings.json"); + const codexFile = join(HOME, ".codex", "config.toml"); + mkdirSync(join(HOME, ".claude"), { + recursive: true, + }); + mkdirSync(join(HOME, ".codex"), { + recursive: true, + }); + writeFileSync(claudeFile, "claude-original"); + writeFileSync(codexFile, "codex-original"); + + snapshotBeforeWrite(claudeFile, "claude"); + snapshotBeforeWrite(codexFile, "codex"); + + writeFileSync(claudeFile, "claude-modified"); + writeFileSync(codexFile, "codex-modified"); + + const summary = restoreBackups("claude"); + + expect(readFileSync(claudeFile, "utf-8")).toBe("claude-original"); + expect(readFileSync(codexFile, "utf-8")).toBe("codex-modified"); + expect(summary.remaining).toBe(1); + + const remaining = listBackups(); + expect(remaining).toHaveLength(1); + expect(remaining[0].agent).toBe("codex"); + }); + + it("snapshotPaths walks a list and skips paths it has already snapshotted", () => { + const a = join(HOME, ".bashrc"); + const b = join(HOME, ".zshrc"); + writeFileSync(a, "a-original"); + writeFileSync(b, "b-original"); + + snapshotPaths( + [ + a, + b, + a, + ], + "claude", + ); + + expect(listBackups()).toHaveLength(2); + + writeFileSync(a, "a-modified"); + writeFileSync(b, "b-modified"); + + restoreBackups(); + + expect(readFileSync(a, "utf-8")).toBe("a-original"); + expect(readFileSync(b, "utf-8")).toBe("b-original"); + }); + + it("treats a corrupt manifest as empty (no crash)", () => { + mkdirSync(getBackupRoot(), { + recursive: true, + }); + writeFileSync(join(getBackupRoot(), "manifest.json"), "{not valid json"); + + expect(listBackups()).toEqual([]); + expect(() => restoreBackups()).not.toThrow(); + }); + + it("removes the backup root once the manifest is empty", () => { + const target = join(HOME, ".claude.json"); + writeFileSync(target, "ORIG"); + snapshotBeforeWrite(target, "claude"); + + expect(existsSync(getBackupRoot())).toBe(true); + + restoreBackups(); + + expect(existsSync(getBackupRoot())).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 164c009ec..ed39fada0 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -52,6 +52,7 @@ function getHelpUsageSection(): string { spawn history export Dump history as JSON to stdout spawn feedback "message" Send feedback to the Spawn team spawn uninstall Uninstall spawn CLI and optionally remove data + spawn local-restore [agent] Restore local agent configs to pre-spawn state spawn update Check for CLI updates spawn version Show version (or --version, -v) spawn help Show this help message (or --help, -h)`; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 309a56362..3b57ec812 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -32,6 +32,8 @@ export { cmdListClear, formatRelativeTime, } from "./list.js"; +// local-restore.ts — cmdLocalRestore (revert local agent config writes) +export { cmdLocalRestore } from "./local-restore.js"; // pick.ts — cmdPick export { cmdPick } from "./pick.js"; // pull-history.ts — cmdPullHistory (recursive child history pull) diff --git a/packages/cli/src/commands/local-restore.ts b/packages/cli/src/commands/local-restore.ts new file mode 100644 index 000000000..3e5df836a --- /dev/null +++ b/packages/cli/src/commands/local-restore.ts @@ -0,0 +1,71 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { listBackups, restoreBackups } from "../local/backup.js"; + +/** + * Restore agent config files spawn wrote to the user's machine in `local` + * mode back to their pre-spawn state. Files spawn created from scratch are + * removed; pre-existing files are overwritten with their original contents. + * + * @param agent — optional agent key to filter the restore (e.g. "claude"). + */ +export async function cmdLocalRestore(agent?: string): Promise { + p.intro(pc.bold("Restore local agent configs")); + + const entries = listBackups(); + if (entries.length === 0) { + p.log.info("Nothing to restore — spawn has no local config snapshots on this machine."); + p.outro("Done"); + return; + } + + const filtered = agent ? entries.filter((e) => e.agent === agent) : entries; + if (filtered.length === 0) { + p.log.info(`No local snapshots found for agent ${pc.bold(agent ?? "")}.`); + const tracked = [ + ...new Set(entries.map((e) => e.agent)), + ].sort(); + if (tracked.length > 0) { + p.log.info(`Tracked agents: ${tracked.join(", ")}`); + } + p.outro("Done"); + return; + } + + p.log.step("The following will be reverted:"); + for (const e of filtered) { + const verb = e.existed ? "restore" : "remove"; + p.log.info(` ${verb}: ${e.destPath} ${pc.dim(`(${e.agent})`)}`); + } + + const confirmed = await p.confirm({ + message: agent + ? `Restore ${filtered.length} file(s) for ${pc.bold(agent)}?` + : `Restore ${filtered.length} file(s) across all tracked agents?`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.outro("Cancelled"); + return; + } + + const summary = restoreBackups(agent); + + if (summary.restored.length > 0) { + p.log.success(`Restored ${summary.restored.length} file(s) to pre-spawn state.`); + } + if (summary.removed.length > 0) { + p.log.success(`Removed ${summary.removed.length} file(s) spawn created.`); + } + if (summary.failed.length > 0) { + p.log.warn(`${summary.failed.length} file(s) could not be restored:`); + for (const dest of summary.failed) { + p.log.warn(` ${dest}`); + } + } + if (summary.restored.length === 0 && summary.removed.length === 0 && summary.failed.length === 0) { + p.log.info("Nothing to do — files already match their pre-spawn state."); + } + + p.outro("spawn local configs reverted"); +} diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 36785015a..9269b28fa 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { listBackups, restoreBackups } from "../local/backup.js"; import { getCacheDir, getSpawnDir, @@ -110,8 +111,10 @@ export async function cmdUninstall(): Promise { const cacheExists = fs.existsSync(cacheDir); const spawnDirExists = fs.existsSync(spawnDir); const configDirExists = fs.existsSync(configDir); + const localBackups = listBackups(); + const hasLocalBackups = localBackups.length > 0; - if (!binaryExists && !symlinkExists && !cacheExists && !spawnDirExists && !configDirExists) { + if (!binaryExists && !symlinkExists && !cacheExists && !spawnDirExists && !configDirExists && !hasLocalBackups) { p.log.info("Nothing to uninstall — spawn does not appear to be installed."); p.outro("Done"); return; @@ -123,6 +126,16 @@ export async function cmdUninstall(): Promise { label: string; hint: string; }[] = []; + if (hasLocalBackups) { + const tracked = [ + ...new Set(localBackups.map((e) => e.agent)), + ].sort(); + options.push({ + value: "restore-local", + label: "Restore local agent configs to pre-spawn state", + hint: `${localBackups.length} file(s) across ${tracked.join(", ") || "agents"}`, + }); + } if (spawnDirExists) { options.push({ value: "history", @@ -140,12 +153,19 @@ export async function cmdUninstall(): Promise { let removeHistory = false; let removeConfig = false; + let restoreLocal = false; if (options.length > 0) { + const initialValues = hasLocalBackups + ? [ + "restore-local", + ] + : []; const selected = await p.multiselect({ message: "Also remove data? (space to toggle, enter to continue)", options, required: false, + initialValues, }); if (p.isCancel(selected)) { p.outro("Cancelled"); @@ -154,6 +174,7 @@ export async function cmdUninstall(): Promise { const selections = selected; removeHistory = selections.includes("history"); removeConfig = selections.includes("config"); + restoreLocal = selections.includes("restore-local"); } // Summary of what will be removed @@ -168,6 +189,9 @@ export async function cmdUninstall(): Promise { p.log.info(` Cache: ${cacheDir}`); } p.log.info(" Shell RC: spawn PATH entries"); + if (restoreLocal) { + p.log.info(` Restore: ${localBackups.length} local agent config file(s)`); + } if (removeHistory) { p.log.info(` History: ${spawnDir}`); } @@ -216,6 +240,24 @@ export async function cmdUninstall(): Promise { removed.push(`Cache: ${cacheDir}`); } + // Optional: restore local agent configs (must run before config dir removal, + // because the backup manifest lives under ~/.config/spawn/local-backups). + if (restoreLocal) { + const summary = restoreBackups(); + if (summary.restored.length > 0) { + removed.push(`Restored: ${summary.restored.length} local config file(s)`); + } + if (summary.removed.length > 0) { + removed.push(`Reverted: ${summary.removed.length} spawn-created file(s)`); + } + if (summary.failed.length > 0) { + p.log.warn(`Could not revert ${summary.failed.length} file(s):`); + for (const dest of summary.failed) { + p.log.warn(` ${dest}`); + } + } + } + // Shell RC files const cleanedFiles: string[] = []; for (const rcFile of RC_FILES) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e81f5f925..d80de6148 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,6 +21,7 @@ import { cmdLink, cmdList, cmdListClear, + cmdLocalRestore, cmdMatrix, cmdPick, cmdPullHistory, @@ -805,6 +806,15 @@ async function dispatchCommand( await cmdLink(filteredArgs); return; } + if (cmd === "local-restore" || cmd === "restore") { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + const agentArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; + await cmdLocalRestore(agentArg); + return; + } if (VERB_ALIASES.has(cmd)) { await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat); return; diff --git a/packages/cli/src/local/backup.ts b/packages/cli/src/local/backup.ts new file mode 100644 index 000000000..be679cd84 --- /dev/null +++ b/packages/cli/src/local/backup.ts @@ -0,0 +1,242 @@ +// local/backup.ts — Snapshot config files before spawn overwrites them so +// `spawn local-restore` (and `spawn uninstall`) can put the user's machine +// back to how it was before spawn ever ran on it. + +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import * as v from "valibot"; +import { parseJsonWith } from "../shared/parse.js"; +import { getUserHome } from "../shared/paths.js"; +import { isFileError, tryCatchIf } from "../shared/result.js"; + +/** + * A single recorded write. We keep enough context to restore the file + * (or remove it, if it didn't exist before) on uninstall. + */ +const BackupEntrySchema = v.object({ + destPath: v.string(), + backupPath: v.string(), + existed: v.boolean(), + agent: v.string(), + timestamp: v.number(), +}); + +const BackupManifestSchema = v.object({ + entries: v.array(BackupEntrySchema), +}); + +export type BackupEntry = v.InferOutput; +export type BackupManifest = v.InferOutput; + +/** Manifest is a single file under the spawn config dir. */ +export function getBackupRoot(): string { + return join(getUserHome(), ".config", "spawn", "local-backups"); +} + +function getManifestPath(): string { + return join(getBackupRoot(), "manifest.json"); +} + +function getFilesDir(): string { + return join(getBackupRoot(), "files"); +} + +/** Treat a missing or malformed manifest as empty. */ +function loadManifest(): BackupManifest { + const path = getManifestPath(); + if (!existsSync(path)) { + return { + entries: [], + }; + } + const text = readFileSync(path, "utf-8"); + const parsed = parseJsonWith(text, BackupManifestSchema); + if (!parsed) { + return { + entries: [], + }; + } + return parsed; +} + +function saveManifest(m: BackupManifest): void { + mkdirSync(getBackupRoot(), { + recursive: true, + }); + writeFileSync(getManifestPath(), JSON.stringify(m, null, 2), { + mode: 0o600, + }); +} + +/** Stable encoded filename for a destination path. */ +function encodeName(absPath: string, timestamp: number): string { + const slug = absPath.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 120); + return `${slug}-${timestamp}`; +} + +/** + * Snapshot a single absolute path before it is about to be overwritten. + * Idempotent — if we've already snapshotted this path, the call is a no-op. + * Directories are snapshotted as a tarball-equivalent: we record their + * existence only, and on restore remove them if they weren't there before. + * We keep this conservative because directory snapshots can be huge. + */ +export function snapshotBeforeWrite(absDestPath: string, agent: string): void { + const m = loadManifest(); + // Idempotent: keep the *first* snapshot, since that captures the true + // pre-spawn state. Subsequent writes go to the same file. + if (m.entries.some((e) => e.destPath === absDestPath)) { + return; + } + + const existed = existsSync(absDestPath); + let isFile = false; + if (existed) { + const result = tryCatchIf(isFileError, () => statSync(absDestPath)); + if (result.ok) { + isFile = result.data.isFile(); + } + } + + const timestamp = Date.now(); + let backupPath = ""; + if (existed && isFile) { + backupPath = join(getFilesDir(), encodeName(absDestPath, timestamp)); + mkdirSync(dirname(backupPath), { + recursive: true, + }); + copyFileSync(absDestPath, backupPath); + } + + m.entries.push({ + destPath: absDestPath, + backupPath, + existed: existed && isFile, + agent, + timestamp, + }); + saveManifest(m); +} + +/** + * Snapshot a list of paths up front (before any agent install starts). + * Useful for files spawn writes via raw shell (e.g. `printf > ~/.claude.json`) + * that uploadFile() never sees. + */ +export function snapshotPaths(paths: ReadonlyArray, agent: string): void { + for (const p of paths) { + snapshotBeforeWrite(p, agent); + } +} + +export interface RestoreSummary { + restored: string[]; + removed: string[]; + failed: string[]; + remaining: number; +} + +/** + * Restore every backed-up path (optionally filtered by agent) to its pre-spawn + * state: copy original contents back, or remove the file if it didn't exist. + * Entries that succeed are dropped from the manifest. + */ +export function restoreBackups(agent?: string): RestoreSummary { + const m = loadManifest(); + const restored: string[] = []; + const removed: string[] = []; + const failed: string[] = []; + const keep: BackupEntry[] = []; + + for (const e of m.entries) { + if (agent && e.agent !== agent) { + keep.push(e); + continue; + } + const op = tryCatchIf(isFileError, () => { + if (e.existed && e.backupPath && existsSync(e.backupPath)) { + mkdirSync(dirname(e.destPath), { + recursive: true, + }); + copyFileSync(e.backupPath, e.destPath); + restored.push(e.destPath); + tryCatchIf(isFileError, () => unlinkSync(e.backupPath)); + } else if (existsSync(e.destPath)) { + // Spawn created this file — remove it. + unlinkSync(e.destPath); + removed.push(e.destPath); + } + }); + if (!op.ok) { + failed.push(e.destPath); + keep.push(e); + } + } + + if (keep.length === 0) { + // Wipe the entire backup dir when nothing's left to track. + tryCatchIf(isFileError, () => + rmSync(getBackupRoot(), { + recursive: true, + force: true, + }), + ); + } else { + m.entries = keep; + saveManifest(m); + } + + return { + restored, + removed, + failed, + remaining: keep.length, + }; +} + +/** Read-only view of the manifest, for `spawn local-restore` summaries. */ +export function listBackups(): BackupEntry[] { + return loadManifest().entries; +} + +/** + * Paths spawn touches via raw shell (not uploadFile) for each agent. + * Snapshotting these up front lets `local-restore` revert them too. + * Keep this list conservative — only files we *know* spawn writes. + */ +export const SHELL_LEVEL_PATHS: Record> = { + claude: [ + "/.claude.json", + "/.claude/CLAUDE.md", + ], +}; + +/** Shell rc files spawn agents may append PATH lines to. */ +export const SHELL_RC_FILES: ReadonlyArray = [ + "/.bashrc", + "/.zshrc", + "/.profile", + "/.bash_profile", +]; + +/** Resolve agent-specific shell-level paths against the user's home dir. */ +export function resolveShellLevelPaths(agent: string): string[] { + const home = getUserHome(); + const list = SHELL_LEVEL_PATHS[agent] ?? []; + return list.map((rel) => join(home, rel)); +} + +/** Resolve shell rc paths against the user's home dir. */ +export function resolveShellRcPaths(): string[] { + const home = getUserHome(); + return SHELL_RC_FILES.map((rel) => join(home, rel)); +} diff --git a/packages/cli/src/local/local.ts b/packages/cli/src/local/local.ts index 98d048d5b..d93ed5248 100644 --- a/packages/cli/src/local/local.ts +++ b/packages/cli/src/local/local.ts @@ -7,6 +7,17 @@ import { getUserHome } from "../shared/paths.js"; import { getLocalShell } from "../shared/shell.js"; import { spawnInteractive } from "../shared/ssh.js"; import { logInfo, logStep } from "../shared/ui.js"; +import { snapshotBeforeWrite } from "./backup.js"; + +// Set by local/main.ts before agent install begins so uploadFile() snapshots +// destination paths under the right agent label. Empty string disables it +// (e.g. when the runner is wrapped in Docker for sandbox mode). +let backupAgent = ""; + +/** Tag subsequent uploadFile() calls with the agent that triggered them. */ +export function setBackupAgent(agent: string): void { + backupAgent = agent; +} // ─── Validation ───────────────────────────────────────────────────────────── @@ -107,6 +118,9 @@ export async function runLocalArgs(args: ReadonlyArray): Promise { /** Copy a file locally, expanding ~ in the destination path. */ export function uploadFile(localPath: string, remotePath: string): void { const validated = validateLocalPath(remotePath); + if (backupAgent) { + snapshotBeforeWrite(validated, backupAgent); + } mkdirSync(dirname(validated), { recursive: true, }); diff --git a/packages/cli/src/local/main.ts b/packages/cli/src/local/main.ts index 5a92819a2..8010cca33 100644 --- a/packages/cli/src/local/main.ts +++ b/packages/cli/src/local/main.ts @@ -10,6 +10,7 @@ import { createCloudAgents } from "../shared/agent-setup.js"; import { makeDockerRunner, runOrchestration } from "../shared/orchestrate.js"; import { logWarn } from "../shared/ui.js"; import { agents, resolveAgent } from "./agents.js"; +import { resolveShellLevelPaths, resolveShellRcPaths, snapshotPaths } from "./backup.js"; import { cleanupContainer, dockerInteractiveSession, @@ -18,6 +19,7 @@ import { interactiveSession, pullAndStartContainer, runLocal, + setBackupAgent, uploadFile, } from "./local.js"; @@ -51,6 +53,15 @@ async function main() { await ensureDocker(); } + // Snapshot config files spawn is about to overwrite so `spawn local-restore` + // (and `spawn uninstall`) can put the user's machine back to how it was. + // Skip in sandbox mode — writes happen inside the container, not on the host. + if (!useSandbox) { + setBackupAgent(agentName); + snapshotPaths(resolveShellRcPaths(), agentName); + snapshotPaths(resolveShellLevelPaths(agentName), agentName); + } + // Warn about security implications of installing OpenClaw locally // (skip warning in sandbox mode — the container provides isolation) if (agentName === "openclaw" && !useSandbox && process.env.SPAWN_NON_INTERACTIVE !== "1") {