Skip to content

Commit 3c06a36

Browse files
fix: Found one reliability bug: the new Docker-daemon-unavailable bran
1 parent 2a7d83b commit 3c06a36

5 files changed

Lines changed: 100 additions & 11 deletions

File tree

src/agents/sandbox.resolveSandboxContext.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,58 @@ describe("resolveSandboxContext", () => {
101101
expect(result).toBeNull();
102102
}, 15_000);
103103

104+
it("does not touch sandbox backends for cron or sub-agent sessions when sandbox mode is off", async () => {
105+
const backendFactory = vi.fn(async () => ({
106+
id: "test-off-backend",
107+
runtimeId: "unexpected-runtime",
108+
runtimeLabel: "Unexpected Runtime",
109+
workdir: "/workspace",
110+
buildExecSpec: async () => ({
111+
argv: ["unexpected"],
112+
env: process.env,
113+
stdinMode: "pipe-closed" as const,
114+
}),
115+
runShellCommand: async () => ({
116+
stdout: Buffer.alloc(0),
117+
stderr: Buffer.alloc(0),
118+
code: 0,
119+
}),
120+
}));
121+
const restore = registerSandboxBackend("test-off-backend", backendFactory);
122+
try {
123+
const cfg: OpenClawConfig = {
124+
agents: {
125+
defaults: {
126+
sandbox: {
127+
mode: "off",
128+
backend: "test-off-backend",
129+
scope: "session",
130+
},
131+
},
132+
},
133+
};
134+
135+
await expect(
136+
resolveSandboxContext({
137+
config: cfg,
138+
sessionKey: "agent:main:cron:job:run:uuid",
139+
workspaceDir: "/tmp/openclaw-test",
140+
}),
141+
).resolves.toBeNull();
142+
await expect(
143+
resolveSandboxContext({
144+
config: cfg,
145+
sessionKey: "agent:main:subagent:child",
146+
workspaceDir: "/tmp/openclaw-test",
147+
}),
148+
).resolves.toBeNull();
149+
150+
expect(backendFactory).not.toHaveBeenCalled();
151+
} finally {
152+
restore();
153+
}
154+
}, 15_000);
155+
104156
it("treats main session aliases as main in non-main mode", async () => {
105157
const cfg: OpenClawConfig = {
106158
session: { mainKey: "work" },

src/agents/sandbox/browser.create.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,34 @@ describe("ensureSandboxBrowser create args", () => {
236236
expect(result?.noVncUrl).toBeUndefined();
237237
});
238238

239+
it("fails before creating a browser container when Docker daemon is unavailable", async () => {
240+
dockerMocks.execDocker.mockImplementation(async (args: string[]) => {
241+
if (args[0] === "network" && args[1] === "inspect") {
242+
return { stdout: "", stderr: "", code: 0 };
243+
}
244+
if (args[0] === "image" && args[1] === "inspect") {
245+
return {
246+
stdout: "",
247+
stderr:
248+
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?",
249+
code: 1,
250+
};
251+
}
252+
return { stdout: "", stderr: "", code: 0 };
253+
});
254+
255+
await expect(
256+
ensureTestSandboxBrowser({
257+
scopeKey: "session:test",
258+
workspaceDir: "/tmp/workspace",
259+
agentWorkspaceDir: "/tmp/workspace",
260+
cfg: buildConfig(false),
261+
}),
262+
).rejects.toThrow("Docker daemon is not available");
263+
264+
expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined();
265+
});
266+
239267
it("passes the browser SSRF policy to the sandbox bridge", async () => {
240268
await ensureTestSandboxBrowser({
241269
scopeKey: "session:test",

src/agents/sandbox/browser.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
buildSandboxCreateArgs,
2727
dockerContainerState,
2828
execDocker,
29+
formatDockerDaemonUnavailableError,
2930
isDockerDaemonUnavailable,
3031
readDockerContainerEnvVar,
3132
readDockerContainerLabel,
@@ -132,10 +133,8 @@ async function ensureSandboxBrowserImage(image: string) {
132133
return;
133134
}
134135
const stderr = result.stderr.trim();
135-
// When Docker daemon is unavailable, silently return instead of throwing.
136-
// This allows sandbox.mode="off" sessions to start without Docker errors.
137136
if (isDockerDaemonUnavailable(stderr)) {
138-
return;
137+
throw new Error(formatDockerDaemonUnavailableError(stderr));
139138
}
140139
throw new Error(
141140
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,

src/agents/sandbox/docker.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,14 @@ describe("ensureDockerImage", () => {
118118
]);
119119
});
120120

121-
it("returns when the Docker daemon is unavailable during image inspection", async () => {
121+
it("throws when the Docker daemon is unavailable during image inspection", async () => {
122122
spawnState.imageExists = false;
123123
spawnState.inspectError =
124124
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?";
125125

126-
await ensureDockerImage(DEFAULT_SANDBOX_IMAGE);
126+
await expect(ensureDockerImage(DEFAULT_SANDBOX_IMAGE)).rejects.toThrow(
127+
"Docker daemon is not available",
128+
);
127129

128130
expect(spawnState.calls).toEqual([
129131
{

src/agents/sandbox/docker.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,18 @@ export function isDockerDaemonUnavailable(stderr: string): boolean {
283283
return DOCKER_DAEMON_UNAVAILABLE_MARKERS.some((marker) => stderr.toLowerCase().includes(marker));
284284
}
285285

286-
async function inspectDockerImage(image: string): Promise<"exists" | "missing" | "unavailable"> {
286+
export function formatDockerDaemonUnavailableError(stderr: string): string {
287+
const detail = stderr.trim();
288+
return [
289+
"Sandbox mode requires Docker, but the Docker daemon is not available.",
290+
"Start Docker, or set `agents.defaults.sandbox.mode=off` to disable sandboxing.",
291+
detail ? `Docker said: ${detail}` : undefined,
292+
]
293+
.filter((line): line is string => Boolean(line))
294+
.join(" ");
295+
}
296+
297+
async function inspectDockerImage(image: string): Promise<"exists" | "missing"> {
287298
const result = await execDocker(["image", "inspect", image], {
288299
allowFailure: true,
289300
});
@@ -294,18 +305,15 @@ async function inspectDockerImage(image: string): Promise<"exists" | "missing" |
294305
if (stderr.toLowerCase().includes("no such image")) {
295306
return "missing";
296307
}
297-
// When Docker daemon is unavailable, treat the image as unavailable
298-
// rather than throwing. This allows sandbox.mode="off" sessions to
299-
// start without a running Docker daemon.
300308
if (isDockerDaemonUnavailable(stderr)) {
301-
return "unavailable";
309+
throw new Error(formatDockerDaemonUnavailableError(stderr));
302310
}
303311
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
304312
}
305313

306314
export async function ensureDockerImage(image: string) {
307315
const imageState = await inspectDockerImage(image);
308-
if (imageState === "exists" || imageState === "unavailable") {
316+
if (imageState === "exists") {
309317
return;
310318
}
311319
if (image === DEFAULT_SANDBOX_IMAGE) {

0 commit comments

Comments
 (0)