diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 64b318b3729..c75b8ac4c19 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -101,6 +101,58 @@ describe("resolveSandboxContext", () => { expect(result).toBeNull(); }, 15_000); + it("does not touch sandbox backends for cron or sub-agent sessions when sandbox mode is off", async () => { + const backendFactory = vi.fn(async () => ({ + id: "test-off-backend", + runtimeId: "unexpected-runtime", + runtimeLabel: "Unexpected Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["unexpected"], + env: process.env, + stdinMode: "pipe-closed" as const, + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + const restore = registerSandboxBackend("test-off-backend", backendFactory); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "off", + backend: "test-off-backend", + scope: "session", + }, + }, + }, + }; + + await expect( + resolveSandboxContext({ + config: cfg, + sessionKey: "agent:main:cron:job:run:uuid", + workspaceDir: "/tmp/openclaw-test", + }), + ).resolves.toBeNull(); + await expect( + resolveSandboxContext({ + config: cfg, + sessionKey: "agent:main:subagent:child", + workspaceDir: "/tmp/openclaw-test", + }), + ).resolves.toBeNull(); + + expect(backendFactory).not.toHaveBeenCalled(); + } finally { + restore(); + } + }, 15_000); + it("treats main session aliases as main in non-main mode", async () => { const cfg: OpenClawConfig = { session: { mainKey: "work" }, diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index ea7e515b037..117f5db9b6b 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -236,6 +236,34 @@ describe("ensureSandboxBrowser create args", () => { expect(result?.noVncUrl).toBeUndefined(); }); + it("fails before creating a browser container when Docker daemon is unavailable", async () => { + dockerMocks.execDocker.mockImplementation(async (args: string[]) => { + if (args[0] === "network" && args[1] === "inspect") { + return { stdout: "", stderr: "", code: 0 }; + } + if (args[0] === "image" && args[1] === "inspect") { + return { + stdout: "", + stderr: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + code: 1, + }; + } + return { stdout: "", stderr: "", code: 0 }; + }); + + await expect( + ensureTestSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: buildConfig(false), + }), + ).rejects.toThrow("Docker daemon is not available"); + + expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined(); + }); + it("passes the browser SSRF policy to the sandbox bridge", async () => { await ensureTestSandboxBrowser({ scopeKey: "session:test", diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index dac3f84d17c..fe24e25850b 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -26,6 +26,7 @@ import { buildSandboxCreateArgs, dockerContainerState, execDocker, + formatDockerDaemonUnavailableError, isDockerDaemonUnavailable, readDockerContainerEnvVar, readDockerContainerLabel, @@ -132,10 +133,8 @@ async function ensureSandboxBrowserImage(image: string) { 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(formatDockerDaemonUnavailableError(stderr)); } 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 2cae9429137..87a8a2b7d90 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -118,12 +118,14 @@ describe("ensureDockerImage", () => { ]); }); - it("returns when the Docker daemon is unavailable during image inspection", async () => { + it("throws 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); + await expect(ensureDockerImage(DEFAULT_SANDBOX_IMAGE)).rejects.toThrow( + "Docker daemon is not available", + ); expect(spawnState.calls).toEqual([ { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 6d9eca9865e..24ed82b520a 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -283,7 +283,18 @@ 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"> { +export function formatDockerDaemonUnavailableError(stderr: string): string { + const detail = stderr.trim(); + return [ + "Sandbox mode requires Docker, but the Docker daemon is not available.", + "Start Docker, or set `agents.defaults.sandbox.mode=off` to disable sandboxing.", + detail ? `Docker said: ${detail}` : undefined, + ] + .filter((line): line is string => Boolean(line)) + .join(" "); +} + +async function inspectDockerImage(image: string): Promise<"exists" | "missing"> { const result = await execDocker(["image", "inspect", image], { allowFailure: true, }); @@ -294,18 +305,15 @@ async function inspectDockerImage(image: string): Promise<"exists" | "missing" | 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(formatDockerDaemonUnavailableError(stderr)); } throw new Error(`Failed to inspect sandbox image: ${stderr}`); } export async function ensureDockerImage(image: string) { const imageState = await inspectDockerImage(image); - if (imageState === "exists" || imageState === "unavailable") { + if (imageState === "exists") { return; } if (image === DEFAULT_SANDBOX_IMAGE) {