mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
test(sandbox): add array-order hash and recreate regression tests
This commit is contained in:
@@ -19,6 +19,40 @@ function createDockerConfig(overrides?: Partial<SandboxDockerConfig>): SandboxDo
|
||||
};
|
||||
}
|
||||
|
||||
type DockerArrayField = "tmpfs" | "capDrop" | "dns" | "extraHosts" | "binds";
|
||||
|
||||
const ORDER_SENSITIVE_ARRAY_CASES: ReadonlyArray<{
|
||||
field: DockerArrayField;
|
||||
before: string[];
|
||||
after: string[];
|
||||
}> = [
|
||||
{
|
||||
field: "tmpfs",
|
||||
before: ["/tmp", "/var/tmp", "/run"],
|
||||
after: ["/run", "/var/tmp", "/tmp"],
|
||||
},
|
||||
{
|
||||
field: "capDrop",
|
||||
before: ["ALL", "CHOWN"],
|
||||
after: ["CHOWN", "ALL"],
|
||||
},
|
||||
{
|
||||
field: "dns",
|
||||
before: ["1.1.1.1", "8.8.8.8"],
|
||||
after: ["8.8.8.8", "1.1.1.1"],
|
||||
},
|
||||
{
|
||||
field: "extraHosts",
|
||||
before: ["host.docker.internal:host-gateway", "db.local:10.0.0.5"],
|
||||
after: ["db.local:10.0.0.5", "host.docker.internal:host-gateway"],
|
||||
},
|
||||
{
|
||||
field: "binds",
|
||||
before: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"],
|
||||
after: ["/tmp/cache:/cache:ro", "/tmp/workspace:/workspace:rw"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("computeSandboxConfigHash", () => {
|
||||
it("ignores object key order", () => {
|
||||
const shared = {
|
||||
@@ -49,7 +83,7 @@ describe("computeSandboxConfigHash", () => {
|
||||
expect(left).toBe(right);
|
||||
});
|
||||
|
||||
it("treats primitive array order as significant", () => {
|
||||
it.each(ORDER_SENSITIVE_ARRAY_CASES)("treats $field order as significant", (testCase) => {
|
||||
const shared = {
|
||||
workspaceAccess: "rw" as const,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -58,14 +92,14 @@ describe("computeSandboxConfigHash", () => {
|
||||
const left = computeSandboxConfigHash({
|
||||
...shared,
|
||||
docker: createDockerConfig({
|
||||
dns: ["1.1.1.1", "8.8.8.8"],
|
||||
}),
|
||||
[testCase.field]: testCase.before,
|
||||
} as Partial<SandboxDockerConfig>),
|
||||
});
|
||||
const right = computeSandboxConfigHash({
|
||||
...shared,
|
||||
docker: createDockerConfig({
|
||||
dns: ["8.8.8.8", "1.1.1.1"],
|
||||
}),
|
||||
[testCase.field]: testCase.after,
|
||||
} as Partial<SandboxDockerConfig>),
|
||||
});
|
||||
expect(left).not.toBe(right);
|
||||
});
|
||||
|
||||
191
src/agents/sandbox/docker.config-hash-recreate.test.ts
Normal file
191
src/agents/sandbox/docker.config-hash-recreate.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { ensureSandboxContainer } from "./docker.js";
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
calls: [] as SpawnCall[],
|
||||
inspectRunning: true,
|
||||
labelHash: "",
|
||||
}));
|
||||
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
readRegistry: vi.fn(),
|
||||
updateRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./registry.js", () => ({
|
||||
readRegistry: registryMocks.readRegistry,
|
||||
updateRegistry: registryMocks.updateRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: (command: string, args: string[]) => {
|
||||
spawnState.calls.push({ command, args });
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
stdin: { end: (input?: string | Buffer) => void };
|
||||
kill: (signal?: NodeJS.Signals) => void;
|
||||
};
|
||||
child.stdout = new Readable({ read() {} });
|
||||
child.stderr = new Readable({ read() {} });
|
||||
child.stdin = { end: () => undefined };
|
||||
child.kill = () => undefined;
|
||||
|
||||
let code = 0;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (command !== "docker") {
|
||||
code = 1;
|
||||
stderr = `unexpected command: ${command}`;
|
||||
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
|
||||
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
|
||||
} else if (
|
||||
args[0] === "inspect" &&
|
||||
args[1] === "-f" &&
|
||||
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
|
||||
) {
|
||||
stdout = `${spawnState.labelHash}\n`;
|
||||
} else if (
|
||||
(args[0] === "rm" && args[1] === "-f") ||
|
||||
(args[0] === "image" && args[1] === "inspect") ||
|
||||
args[0] === "create" ||
|
||||
args[0] === "start"
|
||||
) {
|
||||
code = 0;
|
||||
} else {
|
||||
code = 1;
|
||||
stderr = `unexpected docker args: ${args.join(" ")}`;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (stdout) {
|
||||
child.stdout.emit("data", Buffer.from(stdout));
|
||||
}
|
||||
if (stderr) {
|
||||
child.stderr.emit("data", Buffer.from(stderr));
|
||||
}
|
||||
child.emit("close", code);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createSandboxConfig(dns: string[]): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
scope: "shared",
|
||||
workspaceAccess: "rw",
|
||||
workspaceRoot: "~/.openclaw/sandboxes",
|
||||
docker: {
|
||||
image: "openclaw-sandbox:test",
|
||||
containerPrefix: "oc-test-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
||||
network: "none",
|
||||
capDrop: ["ALL"],
|
||||
env: { LANG: "C.UTF-8" },
|
||||
dns,
|
||||
extraHosts: ["host.docker.internal:host-gateway"],
|
||||
binds: ["/tmp/workspace:/workspace:rw"],
|
||||
},
|
||||
browser: {
|
||||
enabled: false,
|
||||
image: "openclaw-browser:test",
|
||||
containerPrefix: "oc-browser-",
|
||||
cdpPort: 9222,
|
||||
vncPort: 5900,
|
||||
noVncPort: 6080,
|
||||
headless: true,
|
||||
enableNoVnc: false,
|
||||
allowHostControl: false,
|
||||
autoStart: false,
|
||||
autoStartTimeoutMs: 5000,
|
||||
},
|
||||
tools: { allow: [], deny: [] },
|
||||
prune: { idleHours: 24, maxAgeDays: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("ensureSandboxContainer config-hash recreation", () => {
|
||||
beforeEach(() => {
|
||||
spawnState.calls.length = 0;
|
||||
spawnState.inspectRunning = true;
|
||||
spawnState.labelHash = "";
|
||||
registryMocks.readRegistry.mockReset();
|
||||
registryMocks.updateRegistry.mockReset();
|
||||
registryMocks.updateRegistry.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("recreates shared container when array-order change alters hash", async () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"]);
|
||||
const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"]);
|
||||
|
||||
const oldHash = computeSandboxConfigHash({
|
||||
docker: oldCfg.docker,
|
||||
workspaceAccess: oldCfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
});
|
||||
const newHash = computeSandboxConfigHash({
|
||||
docker: newCfg.docker,
|
||||
workspaceAccess: newCfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
});
|
||||
expect(newHash).not.toBe(oldHash);
|
||||
|
||||
spawnState.labelHash = oldHash;
|
||||
registryMocks.readRegistry.mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
containerName: "oc-test-shared",
|
||||
sessionKey: "shared",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 0,
|
||||
image: newCfg.docker.image,
|
||||
configHash: oldHash,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const containerName = await ensureSandboxContainer({
|
||||
sessionKey: "agent:main:session-1",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: newCfg,
|
||||
});
|
||||
|
||||
expect(containerName).toBe("oc-test-shared");
|
||||
const dockerCalls = spawnState.calls.filter((call) => call.command === "docker");
|
||||
expect(
|
||||
dockerCalls.some(
|
||||
(call) =>
|
||||
call.args[0] === "rm" && call.args[1] === "-f" && call.args[2] === "oc-test-shared",
|
||||
),
|
||||
).toBe(true);
|
||||
const createCall = dockerCalls.find((call) => call.args[0] === "create");
|
||||
expect(createCall).toBeDefined();
|
||||
expect(createCall?.args).toContain(`openclaw.configHash=${newHash}`);
|
||||
expect(registryMocks.updateRegistry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
containerName: "oc-test-shared",
|
||||
configHash: newHash,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user