diff --git a/cli/src/commands/hatch.ts b/cli/src/commands/hatch.ts index 394dfd86299..86f61b96fb5 100644 --- a/cli/src/commands/hatch.ts +++ b/cli/src/commands/hatch.ts @@ -22,6 +22,7 @@ import type { PollResult, WatchHatchingResult } from "../lib/gcp"; import { startLocalDaemon, startGateway, stopLocalProcesses } from "../lib/local"; import { isProcessAlive } from "../lib/process"; import { generateRandomSuffix } from "../lib/random-name"; +import { validateAssistantName } from "../lib/retire-archive"; import { exec } from "../lib/step-runner"; export type { PollResult, WatchHatchingResult } from "../lib/gcp"; @@ -169,6 +170,12 @@ function parseArgs(): HatchArgs { console.error("Error: --name requires a value"); process.exit(1); } + try { + validateAssistantName(next); + } catch { + console.error(`Error: --name contains invalid characters (path separators or traversal segments are not allowed)`); + process.exit(1); + } name = next; i++; } else if (arg === "--remote") { diff --git a/cli/src/commands/recover.ts b/cli/src/commands/recover.ts new file mode 100644 index 00000000000..e9dd04f87c0 --- /dev/null +++ b/cli/src/commands/recover.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync, unlinkSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +import { saveAssistantEntry } from "../lib/assistant-config"; +import type { AssistantEntry } from "../lib/assistant-config"; +import { startLocalDaemon, startGateway } from "../lib/local"; +import { getArchivePath, getMetadataPath } from "../lib/retire-archive"; +import { exec } from "../lib/step-runner"; + +export async function recover(): Promise { + const name = process.argv[3]; + if (!name) { + console.error("Usage: vellum-cli recover "); + process.exit(1); + } + + const archivePath = getArchivePath(name); + const metadataPath = getMetadataPath(name); + + // 1. Verify archive exists + if (!existsSync(archivePath) || !existsSync(metadataPath)) { + console.error(`No retired archive found for '${name}'.`); + process.exit(1); + } + + // 2. Check ~/.vellum doesn't already exist + const vellumDir = join(homedir(), ".vellum"); + if (existsSync(vellumDir)) { + console.error( + "Error: ~/.vellum already exists. Retire the current assistant first." + ); + process.exit(1); + } + + // 3. Extract archive + await exec("tar", ["xzf", archivePath, "-C", homedir()]); + + // 4. Restore lockfile entry + const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8")); + saveAssistantEntry(entry); + + // 5. Clean up archive + unlinkSync(archivePath); + unlinkSync(metadataPath); + + // 6. Start daemon + gateway (same as wake) + await startLocalDaemon(); + if (!process.env.VELLUM_DESKTOP_APP) { + await startGateway(); + } + + console.log(`✅ Recovered assistant '${name}'.`); +} diff --git a/cli/src/commands/retire.ts b/cli/src/commands/retire.ts index bb0306759a4..03bb0ee348d 100644 --- a/cli/src/commands/retire.ts +++ b/cli/src/commands/retire.ts @@ -1,13 +1,14 @@ import { spawn } from "child_process"; -import { rmSync } from "fs"; +import { rmSync, writeFileSync } from "fs"; import { homedir } from "os"; -import { join } from "path"; +import { basename, dirname, join } from "path"; import { findAssistantByName, removeAssistantEntry } from "../lib/assistant-config"; import type { AssistantEntry } from "../lib/assistant-config"; import { retireInstance as retireAwsInstance } from "../lib/aws"; import { retireInstance as retireGcpInstance } from "../lib/gcp"; import { stopProcessByPidFile } from "../lib/process"; +import { getArchivePath, getMetadataPath } from "../lib/retire-archive"; import { exec } from "../lib/step-runner"; function resolveCloud(entry: AssistantEntry): string { @@ -32,7 +33,7 @@ function extractHostFromUrl(url: string): string { } } -async function retireLocal(): Promise { +async function retireLocal(name: string, entry: AssistantEntry): Promise { console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n"); const vellumDir = join(homedir(), ".vellum"); @@ -61,6 +62,18 @@ async function retireLocal(): Promise { } catch {} } + // Archive ~/.vellum before deleting + try { + const archivePath = getArchivePath(name); + const metadataPath = getMetadataPath(name); + await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]); + writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n"); + console.log(`📦 Archived to ${archivePath}`); + } catch (err) { + console.warn(`⚠️ Failed to archive: ${err instanceof Error ? err.message : err}`); + console.warn("Proceeding with permanent deletion."); + } + rmSync(vellumDir, { recursive: true, force: true }); console.log("\u2705 Local instance retired."); @@ -142,7 +155,7 @@ export async function retire(): Promise { } await retireAwsInstance(name, region, source); } else if (cloud === "local") { - await retireLocal(); + await retireLocal(name, entry); } else if (cloud === "custom") { await retireCustom(entry); } else { diff --git a/cli/src/index.ts b/cli/src/index.ts index 98acf636148..d68c9de1612 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,6 +9,7 @@ import { client } from "./commands/client"; import { email } from "./commands/email"; import { hatch } from "./commands/hatch"; import { ps } from "./commands/ps"; +import { recover } from "./commands/recover"; import { retire } from "./commands/retire"; import { sleep } from "./commands/sleep"; import { ssh } from "./commands/ssh"; @@ -19,6 +20,7 @@ const commands = { email, hatch, ps, + recover, retire, sleep, ssh, @@ -65,6 +67,7 @@ async function main() { console.log(" email Email operations (status, create inbox)"); console.log(" hatch Create a new assistant instance"); console.log(" ps List assistants (or processes for a specific assistant)"); + console.log(" recover Restore a previously retired local assistant"); console.log(" retire Delete an assistant instance"); console.log(" sleep Stop the daemon process"); console.log(" ssh SSH into a remote assistant instance"); diff --git a/cli/src/lib/retire-archive.ts b/cli/src/lib/retire-archive.ts new file mode 100644 index 00000000000..55a5120b880 --- /dev/null +++ b/cli/src/lib/retire-archive.ts @@ -0,0 +1,43 @@ +import { mkdirSync } from "fs"; +import { homedir } from "os"; +import { basename, join, resolve } from "path"; + +export function getRetiredDir(): string { + const xdgData = + process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"); + const dir = join(xdgData, "vellum", "retired"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Throws if the name contains path separators or traversal segments. */ +export function validateAssistantName(name: string): void { + if ( + !name || + name.includes("/") || + name.includes("\\") || + name === ".." || + name === "." + ) { + throw new Error(`Invalid assistant name: '${name}'`); + } +} + +function safeName(assistantId: string): string { + validateAssistantName(assistantId); + // Canonicalize and verify the result stays inside the retired directory + const retiredDir = getRetiredDir(); + const candidate = resolve(retiredDir, basename(assistantId)); + if (!candidate.startsWith(retiredDir + "/")) { + throw new Error(`Invalid assistant name: '${assistantId}'`); + } + return basename(assistantId); +} + +export function getArchivePath(assistantId: string): string { + return join(getRetiredDir(), `${safeName(assistantId)}.tar.gz`); +} + +export function getMetadataPath(assistantId: string): string { + return join(getRetiredDir(), `${safeName(assistantId)}.json`); +}