diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index bb411dc10..0eb6a8471 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -104,7 +104,7 @@ const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join( ".clawdbot", "sandboxes", ); -const DEFAULT_SANDBOX_IMAGE = "clawdbot-sandbox:bookworm-slim"; +export const DEFAULT_SANDBOX_IMAGE = "clawdbot-sandbox:bookworm-slim"; const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-"; const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_IDLE_HOURS = 24; @@ -118,7 +118,10 @@ const DEFAULT_TOOL_DENY = [ "discord", "gateway", ]; -const DEFAULT_SANDBOX_BROWSER_IMAGE = "clawdbot-sandbox-browser:bookworm-slim"; +export const DEFAULT_SANDBOX_BROWSER_IMAGE = + "clawdbot-sandbox-browser:bookworm-slim"; +export const DEFAULT_SANDBOX_COMMON_IMAGE = + "clawdbot-sandbox-common:bookworm-slim"; const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-"; const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 40e648053..be4efaf6e 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -1,12 +1,22 @@ import { describe, expect, it, vi } from "vitest"; const readConfigFileSnapshot = vi.fn(); +const confirm = vi.fn().mockResolvedValue(true); const writeConfigFile = vi.fn().mockResolvedValue(undefined); const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], })); +const runExec = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); +const runCommandWithTimeout = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, +}); + const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({ path: "/tmp/clawdis.json", exists: false, @@ -32,7 +42,7 @@ const serviceRestart = vi.fn().mockResolvedValue(undefined); const serviceUninstall = vi.fn().mockResolvedValue(undefined); vi.mock("@clack/prompts", () => ({ - confirm: vi.fn().mockResolvedValue(true), + confirm, intro: vi.fn(), note: vi.fn(), outro: vi.fn(), @@ -42,13 +52,17 @@ vi.mock("../agents/skills-status.js", () => ({ buildWorkspaceSkillStatus: () => ({ skills: [] }), })); -vi.mock("../config/config.js", () => ({ - CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", - createConfigIO, - readConfigFileSnapshot, - writeConfigFile, - migrateLegacyConfig, -})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + createConfigIO, + readConfigFileSnapshot, + writeConfigFile, + migrateLegacyConfig, + }; +}); vi.mock("../daemon/legacy.js", () => ({ findLegacyGatewayServices, @@ -59,6 +73,11 @@ vi.mock("../daemon/program-args.js", () => ({ resolveGatewayProgramArguments, })); +vi.mock("../process/exec.js", () => ({ + runExec, + runCommandWithTimeout, +})); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => ({ label: "LaunchAgent", @@ -82,10 +101,14 @@ vi.mock("../runtime.js", () => ({ }, })); -vi.mock("../utils.js", () => ({ - resolveUserPath: (value: string) => value, - sleep: vi.fn(), -})); +vi.mock("../utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserPath: (value: string) => value, + sleep: vi.fn(), + }; +}); vi.mock("./health.js", () => ({ healthCommand: vi.fn().mockResolvedValue(undefined), @@ -302,4 +325,75 @@ describe("doctor", () => { expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.containerPrefix).toBe("clawdbot-sbx"); }); + it("falls back to legacy sandbox image when missing", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: { + agent: { + sandbox: { + mode: "non-main", + docker: { + image: "clawdbot-sandbox-common:bookworm-slim", + }, + }, + }, + }, + valid: true, + config: { + agent: { + sandbox: { + mode: "non-main", + docker: { + image: "clawdbot-sandbox-common:bookworm-slim", + }, + }, + }, + }, + issues: [], + legacyIssues: [], + }); + + runExec.mockImplementation((command: string, args: string[]) => { + if (command !== "docker") { + return Promise.resolve({ stdout: "", stderr: "" }); + } + if (args[0] === "version") { + return Promise.resolve({ stdout: "1", stderr: "" }); + } + if (args[0] === "image" && args[1] === "inspect") { + const image = args[2]; + if (image === "clawdbot-sandbox-common:bookworm-slim") { + return Promise.reject(new Error("missing")); + } + if (image === "clawdis-sandbox-common:bookworm-slim") { + return Promise.resolve({ stdout: "ok", stderr: "" }); + } + } + return Promise.resolve({ stdout: "", stderr: "" }); + }); + + confirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime); + + const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + const agent = written.agent as Record; + const sandbox = agent.sandbox as Record; + const docker = sandbox.docker as Record; + + expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim"); + expect(runCommandWithTimeout).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d49daad8d..9cacd44ce 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,8 +1,14 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { confirm, intro, note, outro } from "@clack/prompts"; +import { + DEFAULT_SANDBOX_BROWSER_IMAGE, + DEFAULT_SANDBOX_COMMON_IMAGE, + DEFAULT_SANDBOX_IMAGE, +} from "../agents/sandbox.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -20,6 +26,7 @@ import { } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -58,6 +65,249 @@ function replaceLegacyName(value: string | undefined): string | undefined { return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot"); } +function replaceModernName(value: string | undefined): string | undefined { + if (!value) return value; + if (!value.includes("clawdbot")) return value; + return value.replace(/clawdbot/g, "clawdis"); +} + +type SandboxScriptInfo = { + scriptPath: string; + cwd: string; +}; + +function resolveSandboxScript(scriptRel: string): SandboxScriptInfo | null { + const candidates = new Set(); + candidates.add(process.cwd()); + const argv1 = process.argv[1]; + if (argv1) { + const normalized = path.resolve(argv1); + candidates.add(path.resolve(path.dirname(normalized), "..")); + candidates.add(path.resolve(path.dirname(normalized))); + } + + for (const root of candidates) { + const scriptPath = path.join(root, scriptRel); + if (fs.existsSync(scriptPath)) { + return { scriptPath, cwd: root }; + } + } + + return null; +} + +async function runSandboxScript( + scriptRel: string, + runtime: RuntimeEnv, +): Promise { + const script = resolveSandboxScript(scriptRel); + if (!script) { + note( + `Unable to locate ${scriptRel}. Run it from the repo root.`, + "Sandbox", + ); + return false; + } + + runtime.log(`Running ${scriptRel}...`); + const result = await runCommandWithTimeout(["bash", script.scriptPath], { + timeoutMs: 20 * 60 * 1000, + cwd: script.cwd, + }); + if (result.code !== 0) { + runtime.error( + `Failed running ${scriptRel}: ${ + result.stderr.trim() || result.stdout.trim() || "unknown error" + }`, + ); + return false; + } + + runtime.log(`Completed ${scriptRel}.`); + return true; +} + +async function isDockerAvailable(): Promise { + try { + await runExec("docker", ["version", "--format", "{{.Server.Version}}"], { + timeoutMs: 5_000, + }); + return true; + } catch { + return false; + } +} + +async function dockerImageExists(image: string): Promise { + try { + await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 }); + return true; + } catch { + return false; + } +} + +function resolveSandboxDockerImage(cfg: ClawdbotConfig): string { + const image = cfg.agent?.sandbox?.docker?.image?.trim(); + return image ? image : DEFAULT_SANDBOX_IMAGE; +} + +function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string { + const image = cfg.agent?.sandbox?.browser?.image?.trim(); + return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; +} + +function updateSandboxDockerImage( + cfg: ClawdbotConfig, + image: string, +): ClawdbotConfig { + return { + ...cfg, + agent: { + ...cfg.agent, + sandbox: { + ...cfg.agent?.sandbox, + docker: { + ...cfg.agent?.sandbox?.docker, + image, + }, + }, + }, + }; +} + +function updateSandboxBrowserImage( + cfg: ClawdbotConfig, + image: string, +): ClawdbotConfig { + return { + ...cfg, + agent: { + ...cfg.agent, + sandbox: { + ...cfg.agent?.sandbox, + browser: { + ...cfg.agent?.sandbox?.browser, + image, + }, + }, + }, + }; +} + +type SandboxImageCheck = { + label: string; + image: string; + buildScript?: string; + updateConfig: (image: string) => void; +}; + +async function handleMissingSandboxImage( + params: SandboxImageCheck, + runtime: RuntimeEnv, +) { + const exists = await dockerImageExists(params.image); + if (exists) return; + + const buildHint = params.buildScript + ? `Build it with ${params.buildScript}.` + : "Build or pull it first."; + note( + `Sandbox ${params.label} image missing: ${params.image}. ${buildHint}`, + "Sandbox", + ); + + let built = false; + if (params.buildScript) { + const build = guardCancel( + await confirm({ + message: `Build ${params.label} sandbox image now?`, + initialValue: true, + }), + runtime, + ); + if (build) { + built = await runSandboxScript(params.buildScript, runtime); + } + } + + if (built) return; + + const legacyImage = replaceModernName(params.image); + if (!legacyImage || legacyImage === params.image) return; + const legacyExists = await dockerImageExists(legacyImage); + if (!legacyExists) return; + + const fallback = guardCancel( + await confirm({ + message: `Switch config to legacy image ${legacyImage}?`, + initialValue: false, + }), + runtime, + ); + if (!fallback) return; + + params.updateConfig(legacyImage); +} + +async function maybeRepairSandboxImages( + cfg: ClawdbotConfig, + runtime: RuntimeEnv, +): Promise { + const sandbox = cfg.agent?.sandbox; + const mode = sandbox?.mode ?? "off"; + if (!sandbox || mode === "off") return cfg; + + const dockerAvailable = await isDockerAvailable(); + if (!dockerAvailable) { + note("Docker not available; skipping sandbox image checks.", "Sandbox"); + return cfg; + } + + let next = cfg; + const changes: string[] = []; + + const dockerImage = resolveSandboxDockerImage(cfg); + await handleMissingSandboxImage( + { + label: "base", + image: dockerImage, + buildScript: + dockerImage === DEFAULT_SANDBOX_COMMON_IMAGE + ? "scripts/sandbox-common-setup.sh" + : dockerImage === DEFAULT_SANDBOX_IMAGE + ? "scripts/sandbox-setup.sh" + : undefined, + updateConfig: (image) => { + next = updateSandboxDockerImage(next, image); + changes.push(`Updated agent.sandbox.docker.image → ${image}`); + }, + }, + runtime, + ); + + if (sandbox.browser?.enabled) { + await handleMissingSandboxImage( + { + label: "browser", + image: resolveSandboxBrowserImage(cfg), + buildScript: "scripts/sandbox-browser-setup.sh", + updateConfig: (image) => { + next = updateSandboxBrowserImage(next, image); + changes.push(`Updated agent.sandbox.browser.image → ${image}`); + }, + }, + runtime, + ); + } + + if (changes.length > 0) { + note(changes.join("\n"), "Doctor changes"); + } + + return next; +} + function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { config: ClawdbotConfig; changes: string[]; @@ -345,6 +595,8 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { cfg = normalized.config; } + cfg = await maybeRepairSandboxImages(cfg, runtime); + await maybeMigrateLegacyGatewayService(cfg, runtime); const workspaceDir = resolveUserPath(