diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 745910e090f..683721c3b28 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -18,7 +18,7 @@ export { requireSandboxBackendFactory, } from "./sandbox/backend.js"; -export { buildSandboxCreateArgs } from "./sandbox/docker.js"; +export { buildSandboxCreateArgs, isDockerDaemonUnavailable } from "./sandbox/docker.js"; export { listSandboxBrowsers, listSandboxContainers, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index a026779fd52..dac3f84d17c 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -26,6 +26,7 @@ import { buildSandboxCreateArgs, dockerContainerState, execDocker, + isDockerDaemonUnavailable, readDockerContainerEnvVar, readDockerContainerLabel, readDockerNetworkDriver, @@ -130,6 +131,12 @@ async function ensureSandboxBrowserImage(image: string) { if (result.code === 0) { return; } + const stderr = result.stderr.trim(); + // When Docker daemon is unavailable, silently return instead of throwing. + // This allows sandbox.mode="off" sessions to start without Docker errors. + if (isDockerDaemonUnavailable(stderr)) { + return; + } throw new Error( `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, ); diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 2dca47efcd8..2cae9429137 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -18,6 +18,7 @@ type MockDockerChild = EventEmitter & { const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], imageExists: true, + inspectError: "", })); function createMockDockerChild(): MockDockerChild { @@ -40,7 +41,9 @@ function spawnDockerProcess(command: string, args: string[]) { stderr = `unexpected command: ${command}`; } else if (args[0] === "image" && args[1] === "inspect") { code = spawnState.imageExists ? 0 : 1; - stderr = spawnState.imageExists ? "" : `Error response from daemon: No such image: ${args[2]}`; + stderr = spawnState.imageExists + ? "" + : spawnState.inspectError || `Error response from daemon: No such image: ${args[2]}`; } else if (args[0] === "pull" || args[0] === "tag") { code = 0; } else { @@ -79,6 +82,7 @@ describe("ensureDockerImage", () => { beforeEach(async () => { spawnState.calls.length = 0; spawnState.imageExists = true; + spawnState.inspectError = ""; await loadFreshDockerModuleForTest(); }); @@ -113,4 +117,19 @@ describe("ensureDockerImage", () => { }, ]); }); + + it("returns when the Docker daemon is unavailable during image inspection", async () => { + spawnState.imageExists = false; + spawnState.inspectError = + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; + + await ensureDockerImage(DEFAULT_SANDBOX_IMAGE); + + expect(spawnState.calls).toEqual([ + { + command: "docker", + args: ["image", "inspect", DEFAULT_SANDBOX_IMAGE], + }, + ]); + }); }); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index a84875f311b..6d9eca9865e 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -272,23 +272,40 @@ export async function readDockerPort(containerName: string, port: number) { return Number.isFinite(mapped) ? mapped : null; } -async function dockerImageExists(image: string) { +const DOCKER_DAEMON_UNAVAILABLE_MARKERS = [ + "cannot connect to the docker daemon", + "dial unix", + "docker daemon is not running", + "connection refused", +]; + +export function isDockerDaemonUnavailable(stderr: string): boolean { + return DOCKER_DAEMON_UNAVAILABLE_MARKERS.some((marker) => stderr.toLowerCase().includes(marker)); +} + +async function inspectDockerImage(image: string): Promise<"exists" | "missing" | "unavailable"> { const result = await execDocker(["image", "inspect", image], { allowFailure: true, }); if (result.code === 0) { - return true; + return "exists"; } const stderr = result.stderr.trim(); - if (stderr.includes("No such image")) { - return false; + if (stderr.toLowerCase().includes("no such image")) { + return "missing"; + } + // When Docker daemon is unavailable, treat the image as unavailable + // rather than throwing. This allows sandbox.mode="off" sessions to + // start without a running Docker daemon. + if (isDockerDaemonUnavailable(stderr)) { + return "unavailable"; } throw new Error(`Failed to inspect sandbox image: ${stderr}`); } export async function ensureDockerImage(image: string) { - const exists = await dockerImageExists(image); - if (exists) { + const imageState = await inspectDockerImage(image); + if (imageState === "exists" || imageState === "unavailable") { return; } if (image === DEFAULT_SANDBOX_IMAGE) { diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 3dd025b420d..fda5fd5d849 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -4,6 +4,7 @@ import { DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_COMMON_IMAGE, DEFAULT_SANDBOX_IMAGE, + isDockerDaemonUnavailable, resolveSandboxScope, } from "../agents/sandbox.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -85,6 +86,9 @@ async function dockerImageExists(image: string): Promise { if (stderr.includes("No such image")) { return false; } + if (isDockerDaemonUnavailable(stderr)) { + return false; + } throw error; } }