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.
This commit is contained in:
committed by
Peter Steinberger
parent
ae6f268987
commit
5e78d5a21f
@@ -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<SandboxContainerInfo[]> {
|
||||
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<SandboxBrowserInfo[]> {
|
||||
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<void> {
|
||||
try {
|
||||
await execDocker(["rm", "-f", containerName], { allowFailure: true });
|
||||
} catch {
|
||||
// ignore removal failures
|
||||
}
|
||||
await removeRegistryEntry(containerName);
|
||||
}
|
||||
|
||||
export async function removeSandboxBrowserContainer(
|
||||
containerName: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
82
src/cli/sandbox-cli.ts
Normal file
82
src/cli/sandbox-cli.ts
Normal file
@@ -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 <key>", "Recreate container for specific session")
|
||||
.option("--agent <id>", "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);
|
||||
}
|
||||
});
|
||||
}
|
||||
266
src/commands/sandbox.ts
Normal file
266
src/commands/sandbox.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
// Validation
|
||||
if (!opts.all && !opts.session && !opts.agent) {
|
||||
runtime.error(
|
||||
"Please specify --all, --session <key>, or --agent <id>",
|
||||
);
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user