Fix default sandbox image fallback for python3-dependent mutations (#73362)

This commit is contained in:
Vincent Koc
2026-04-28 01:57:44 -07:00
committed by GitHub
parent 6f3b5f8666
commit 47dc9f7fc0
3 changed files with 120 additions and 3 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
- Sandbox/Docker: stop satisfying a missing default sandbox image by tagging plain Debian as `openclaw-sandbox:bookworm-slim`, preserving the Python tooling required by sandbox write/edit helpers and directing users to build the default image. Fixes #51185; refs #45108, #51099, #51609, and #57713. Thanks @dpalis, @Tin55FoilDev, @jbcohen2-coder, @macminihal-cyber, and @PraxoOnline.
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.

View File

@@ -0,0 +1,116 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
type SpawnCall = {
command: string;
args: string[];
};
type MockDockerChild = EventEmitter & {
stdout: Readable;
stderr: Readable;
stdin: { end: (input?: string | Buffer) => void };
kill: (signal?: NodeJS.Signals) => void;
};
const spawnState = vi.hoisted(() => ({
calls: [] as SpawnCall[],
imageExists: true,
}));
function createMockDockerChild(): MockDockerChild {
const child = new EventEmitter() as MockDockerChild;
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });
child.stdin = { end: () => undefined };
child.kill = () => undefined;
return child;
}
function spawnDockerProcess(command: string, args: string[]) {
spawnState.calls.push({ command, args });
const child = createMockDockerChild();
let code = 0;
let stderr = "";
if (command !== "docker") {
code = 1;
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]}`;
} else if (args[0] === "pull" || args[0] === "tag") {
code = 0;
} else {
code = 1;
stderr = `unexpected docker args: ${args.join(" ")}`;
}
queueMicrotask(() => {
if (stderr) {
child.stderr.emit("data", Buffer.from(stderr));
}
child.emit("close", code);
});
return child;
}
async function createChildProcessMock() {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnDockerProcess,
};
}
vi.mock("node:child_process", async () => createChildProcessMock());
let ensureDockerImage: typeof import("./docker.js").ensureDockerImage;
async function loadFreshDockerModuleForTest() {
vi.resetModules();
vi.doMock("node:child_process", async () => createChildProcessMock());
({ ensureDockerImage } = await import("./docker.js"));
}
describe("ensureDockerImage", () => {
beforeEach(async () => {
spawnState.calls.length = 0;
spawnState.imageExists = true;
await loadFreshDockerModuleForTest();
});
it("returns when the configured image already exists", async () => {
await ensureDockerImage(DEFAULT_SANDBOX_IMAGE);
expect(spawnState.calls).toEqual([
{
command: "docker",
args: ["image", "inspect", DEFAULT_SANDBOX_IMAGE],
},
]);
});
it("does not satisfy the missing default sandbox image by tagging plain Debian", async () => {
spawnState.imageExists = false;
let err: unknown;
try {
await ensureDockerImage(DEFAULT_SANDBOX_IMAGE);
} catch (caught) {
err = caught;
}
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain("scripts/sandbox-setup.sh");
expect((err as Error).message).toContain("python3");
expect(spawnState.calls).toEqual([
{
command: "docker",
args: ["image", "inspect", DEFAULT_SANDBOX_IMAGE],
},
]);
});
});

View File

@@ -292,9 +292,9 @@ export async function ensureDockerImage(image: string) {
return;
}
if (image === DEFAULT_SANDBOX_IMAGE) {
await execDocker(["pull", "debian:bookworm-slim"]);
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]);
return;
throw new Error(
`Sandbox image not found: ${image}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
);
}
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
}