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:
sheeek
2026-01-09 09:40:14 +01:00
committed by Peter Steinberger
parent ae6f268987
commit 5e78d5a21f
4 changed files with 455 additions and 0 deletions

View File

@@ -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);
}
}
}