From 5e78d5a21f4be1c06a8ae3a2c3f70c002d070d57 Mon Sep 17 00:00:00 2001 From: sheeek Date: Fri, 9 Jan 2026 09:40:14 +0100 Subject: [PATCH] feat: add sandbox CLI commands for container management Add 'clawd sandbox list' and 'clawd sandbox recreate' commands to manage sandbox containers. This fixes the issue where containers continue using old images/configs after updates. Problem: - When sandbox Docker images or configs are updated, existing containers keep running with old settings - Containers are only recreated after 24h inactivity (pruning) - If agents are used regularly, old containers run indefinitely Solution: - 'clawd sandbox list': Show all containers with status, age, and image match - 'clawd sandbox recreate': Force container removal (recreated on next use) - Supports --all, --session, --agent, --browser filters - Requires confirmation unless --force is used Implementation: - Added helper functions to sandbox.ts (list/remove containers) - Created sandbox-cli.ts following existing CLI patterns - Created commands/sandbox.ts with list and recreate logic - Integrated into program.ts Use case: After updating sandbox images or changing sandbox config, run 'clawd sandbox recreate --all' to ensure fresh containers. --- src/agents/sandbox.ts | 105 ++++++++++++++++ src/cli/program.ts | 2 + src/cli/sandbox-cli.ts | 82 +++++++++++++ src/commands/sandbox.ts | 266 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 src/cli/sandbox-cli.ts create mode 100644 src/commands/sandbox.ts diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 53cc9c1c8..e5c11ed27 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -1145,3 +1145,108 @@ export async function ensureSandboxWorkspaceForSession(params: { containerWorkdir: cfg.docker.workdir, }; } + +// --- Public API for sandbox management --- + +export type SandboxContainerInfo = SandboxRegistryEntry & { + running: boolean; + imageMatch: boolean; +}; + +export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { + running: boolean; + imageMatch: boolean; +}; + +export async function listSandboxContainers(): Promise { + const registry = await readRegistry(); + const results: SandboxContainerInfo[] = []; + + for (const entry of registry.entries) { + const state = await dockerContainerState(entry.containerName); + // Get actual image from container + let actualImage = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualImage = result.stdout.trim(); + } + } catch { + // ignore + } + } + results.push({ + ...entry, + running: state.running, + imageMatch: actualImage === entry.image, + }); + } + + return results; +} + +export async function listSandboxBrowsers(): Promise { + const registry = await readBrowserRegistry(); + const results: SandboxBrowserInfo[] = []; + + for (const entry of registry.entries) { + const state = await dockerContainerState(entry.containerName); + let actualImage = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualImage = result.stdout.trim(); + } + } catch { + // ignore + } + } + results.push({ + ...entry, + running: state.running, + imageMatch: actualImage === entry.image, + }); + } + + return results; +} + +export async function removeSandboxContainer( + containerName: string, +): Promise { + try { + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + await removeRegistryEntry(containerName); +} + +export async function removeSandboxBrowserContainer( + containerName: string, +): Promise { + try { + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + await removeBrowserRegistryEntry(containerName); + + // Stop browser bridge if active + for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) { + if (bridge.containerName === containerName) { + await stopBrowserBridgeServer(bridge.bridge.server).catch( + () => undefined, + ); + BROWSER_BRIDGES.delete(sessionKey); + } + } +} diff --git a/src/cli/program.ts b/src/cli/program.ts index c5c8f6bca..cc463f510 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -46,6 +46,7 @@ import { registerPairingCli } from "./pairing-cli.js"; import { forceFreePort } from "./ports.js"; import { runProviderLogin, runProviderLogout } from "./provider-auth.js"; import { registerProvidersCli } from "./providers-cli.js"; +import { registerSandboxCli } from "./sandbox-cli.js"; import { registerSkillsCli } from "./skills-cli.js"; import { registerTuiCli } from "./tui-cli.js"; @@ -1038,6 +1039,7 @@ Examples: registerLogsCli(program); registerModelsCli(program); registerNodesCli(program); + registerSandboxCli(program); registerTuiCli(program); registerCronCli(program); registerDnsCli(program); diff --git a/src/cli/sandbox-cli.ts b/src/cli/sandbox-cli.ts new file mode 100644 index 000000000..232a1ebd5 --- /dev/null +++ b/src/cli/sandbox-cli.ts @@ -0,0 +1,82 @@ +import type { Command } from "commander"; + +import { + sandboxListCommand, + sandboxRecreateCommand, +} from "../commands/sandbox.js"; +import { defaultRuntime } from "../runtime.js"; + +export function registerSandboxCli(program: Command) { + const sandbox = program + .command("sandbox") + .description("Manage sandbox containers (Docker-based agent isolation)"); + + sandbox + .command("list") + .description("List sandbox containers and their status") + .option("--browser", "List browser containers only", false) + .option("--json", "Output JSON", false) + .action(async (opts) => { + try { + await sandboxListCommand( + { + browser: Boolean(opts.browser), + json: Boolean(opts.json), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + sandbox + .command("recreate") + .description("Recreate sandbox containers (e.g., after image updates)") + .option("--all", "Recreate all sandbox containers", false) + .option("--session ", "Recreate container for specific session") + .option("--agent ", "Recreate containers for specific agent") + .option("--browser", "Only recreate browser containers", false) + .option("--force", "Skip confirmation prompt", false) + .addHelpText( + "after", + ` +Examples: + clawd sandbox recreate --all # Recreate all sandbox containers + clawd sandbox recreate --session main # Recreate container for main session + clawd sandbox recreate --agent mybot # Recreate containers for 'mybot' agent + clawd sandbox recreate --browser # Only recreate browser containers + clawd sandbox recreate --all --force # Skip confirmation + +Use this command after updating sandbox images or changing sandbox configuration +to ensure containers use the latest settings.`, + ) + .action(async (opts) => { + try { + await sandboxRecreateCommand( + { + all: Boolean(opts.all), + session: opts.session as string | undefined, + agent: opts.agent as string | undefined, + browser: Boolean(opts.browser), + force: Boolean(opts.force), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + // Default action shows list + sandbox.action(async () => { + try { + await sandboxListCommand({ browser: false, json: false }, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts new file mode 100644 index 000000000..e7564e801 --- /dev/null +++ b/src/commands/sandbox.ts @@ -0,0 +1,266 @@ +import { confirm as clackConfirm } from "@clack/prompts"; + +import { + listSandboxBrowsers, + listSandboxContainers, + removeSandboxBrowserContainer, + removeSandboxContainer, +} from "../agents/sandbox.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; + +// --- List Command --- + +type SandboxListOptions = { + browser: boolean; + json: boolean; +}; + +export async function sandboxListCommand( + opts: SandboxListOptions, + runtime: RuntimeEnv, +): Promise { + const containers = opts.browser + ? [] + : await listSandboxContainers().catch(() => []); + const browsers = opts.browser + ? await listSandboxBrowsers().catch(() => []) + : []; + + if (opts.json) { + runtime.log( + JSON.stringify( + { containers, browsers }, + null, + 2, + ), + ); + return; + } + + if (opts.browser) { + if (browsers.length === 0) { + runtime.log("No sandbox browser containers found."); + return; + } + + runtime.log("\n🌐 Sandbox Browser Containers:\n"); + for (const browser of browsers) { + const status = browser.running ? "🟢 running" : "⚫ stopped"; + const imageStatus = browser.imageMatch ? "āœ“" : "āš ļø mismatch"; + const age = formatAge(Date.now() - browser.createdAtMs); + const idle = formatAge(Date.now() - browser.lastUsedAtMs); + + runtime.log(` ${browser.containerName}`); + runtime.log(` Status: ${status}`); + runtime.log(` Image: ${browser.image} ${imageStatus}`); + runtime.log(` CDP: ${browser.cdpPort}`); + if (browser.noVncPort) { + runtime.log(` noVNC: ${browser.noVncPort}`); + } + runtime.log(` Age: ${age}`); + runtime.log(` Idle: ${idle}`); + runtime.log(` Session: ${browser.sessionKey}`); + runtime.log(""); + } + } else { + if (containers.length === 0) { + runtime.log("No sandbox containers found."); + return; + } + + runtime.log("\nšŸ“¦ Sandbox Containers:\n"); + for (const container of containers) { + const status = container.running ? "🟢 running" : "⚫ stopped"; + const imageStatus = container.imageMatch ? "āœ“" : "āš ļø mismatch"; + const age = formatAge(Date.now() - container.createdAtMs); + const idle = formatAge(Date.now() - container.lastUsedAtMs); + + runtime.log(` ${container.containerName}`); + runtime.log(` Status: ${status}`); + runtime.log(` Image: ${container.image} ${imageStatus}`); + runtime.log(` Age: ${age}`); + runtime.log(` Idle: ${idle}`); + runtime.log(` Session: ${container.sessionKey}`); + runtime.log(""); + } + } + + // Summary + const totalContainers = containers.length + browsers.length; + const runningCount = + containers.filter((c) => c.running).length + + browsers.filter((b) => b.running).length; + const mismatchCount = + containers.filter((c) => !c.imageMatch).length + + browsers.filter((b) => !b.imageMatch).length; + + runtime.log(`Total: ${totalContainers} (${runningCount} running)`); + if (mismatchCount > 0) { + runtime.log( + `\nāš ļø ${mismatchCount} container(s) with image mismatch detected.`, + ); + runtime.log( + ` Run 'clawd sandbox recreate --all' to update all containers.`, + ); + } +} + +// --- Recreate Command --- + +type SandboxRecreateOptions = { + all: boolean; + session?: string; + agent?: string; + browser: boolean; + force: boolean; +}; + +export async function sandboxRecreateCommand( + opts: SandboxRecreateOptions, + runtime: RuntimeEnv, +): Promise { + // Validation + if (!opts.all && !opts.session && !opts.agent) { + runtime.error( + "Please specify --all, --session , or --agent ", + ); + runtime.exit(1); + return; + } + + if ( + (opts.all && opts.session) || + (opts.all && opts.agent) || + (opts.session && opts.agent) + ) { + runtime.error("Please specify only one of: --all, --session, --agent"); + runtime.exit(1); + return; + } + + // Fetch containers + const allContainers = await listSandboxContainers().catch(() => []); + const allBrowsers = await listSandboxBrowsers().catch(() => []); + + // Filter based on options + let containersToRemove = opts.browser ? [] : allContainers; + let browsersToRemove = opts.browser ? allBrowsers : []; + + if (opts.session) { + containersToRemove = containersToRemove.filter( + (c) => c.sessionKey === opts.session, + ); + browsersToRemove = browsersToRemove.filter( + (b) => b.sessionKey === opts.session, + ); + } else if (opts.agent) { + const agentPrefix = `agent:${opts.agent}`; + containersToRemove = containersToRemove.filter( + (c) => c.sessionKey === agentPrefix || c.sessionKey.startsWith(`${agentPrefix}:`), + ); + browsersToRemove = browsersToRemove.filter( + (b) => b.sessionKey === agentPrefix || b.sessionKey.startsWith(`${agentPrefix}:`), + ); + } + + const totalToRemove = containersToRemove.length + browsersToRemove.length; + + if (totalToRemove === 0) { + runtime.log("No containers found matching the criteria."); + return; + } + + // Show what will be removed + runtime.log("\nContainers to be recreated:\n"); + + if (containersToRemove.length > 0) { + runtime.log("šŸ“¦ Sandbox Containers:"); + for (const container of containersToRemove) { + const status = container.running ? "running" : "stopped"; + runtime.log(` - ${container.containerName} (${status})`); + } + } + + if (browsersToRemove.length > 0) { + runtime.log("\n🌐 Browser Containers:"); + for (const browser of browsersToRemove) { + const status = browser.running ? "running" : "stopped"; + runtime.log(` - ${browser.containerName} (${status})`); + } + } + + runtime.log(`\nTotal: ${totalToRemove} container(s)`); + + // Confirmation + if (!opts.force) { + const shouldContinue = await clackConfirm({ + message: "This will stop and remove these containers. Continue?", + initialValue: false, + }); + + if (!shouldContinue || shouldContinue === Symbol.for("clack:cancel")) { + runtime.log("Cancelled."); + return; + } + } + + // Remove containers + runtime.log("\nRemoving containers...\n"); + + let successCount = 0; + let failCount = 0; + + for (const container of containersToRemove) { + try { + await removeSandboxContainer(container.containerName); + runtime.log(`āœ“ Removed ${container.containerName}`); + successCount++; + } catch (err) { + runtime.error( + `āœ— Failed to remove ${container.containerName}: ${String(err)}`, + ); + failCount++; + } + } + + for (const browser of browsersToRemove) { + try { + await removeSandboxBrowserContainer(browser.containerName); + runtime.log(`āœ“ Removed ${browser.containerName}`); + successCount++; + } catch (err) { + runtime.error( + `āœ— Failed to remove ${browser.containerName}: ${String(err)}`, + ); + failCount++; + } + } + + // Summary + runtime.log(`\nDone: ${successCount} removed, ${failCount} failed`); + + if (successCount > 0) { + runtime.log( + "\nContainers will be automatically recreated when the agent is next used.", + ); + } + + if (failCount > 0) { + runtime.exit(1); + } +} + +// --- Helpers --- + +function formatAge(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +}