217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
|
import { ensureSandboxBrowser } from "./browser.js";
|
|
import { resetNoVncObserverTokensForTests } from "./novnc-auth.js";
|
|
import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js";
|
|
import type { SandboxConfig } from "./types.js";
|
|
|
|
const dockerMocks = vi.hoisted(() => ({
|
|
dockerContainerState: vi.fn(),
|
|
execDocker: vi.fn(),
|
|
readDockerContainerEnvVar: vi.fn(),
|
|
readDockerContainerLabel: vi.fn(),
|
|
readDockerPort: vi.fn(),
|
|
}));
|
|
|
|
const registryMocks = vi.hoisted(() => ({
|
|
readBrowserRegistry: vi.fn(),
|
|
updateBrowserRegistry: vi.fn(),
|
|
}));
|
|
|
|
const bridgeMocks = vi.hoisted(() => ({
|
|
startBrowserBridgeServer: vi.fn(),
|
|
stopBrowserBridgeServer: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./docker.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./docker.js")>();
|
|
return {
|
|
...actual,
|
|
dockerContainerState: dockerMocks.dockerContainerState,
|
|
execDocker: dockerMocks.execDocker,
|
|
readDockerContainerEnvVar: dockerMocks.readDockerContainerEnvVar,
|
|
readDockerContainerLabel: dockerMocks.readDockerContainerLabel,
|
|
readDockerPort: dockerMocks.readDockerPort,
|
|
};
|
|
});
|
|
|
|
vi.mock("./registry.js", () => ({
|
|
readBrowserRegistry: registryMocks.readBrowserRegistry,
|
|
updateBrowserRegistry: registryMocks.updateBrowserRegistry,
|
|
}));
|
|
|
|
vi.mock("../../browser/bridge-server.js", () => ({
|
|
startBrowserBridgeServer: bridgeMocks.startBrowserBridgeServer,
|
|
stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer,
|
|
}));
|
|
|
|
function buildConfig(enableNoVnc: boolean): SandboxConfig {
|
|
return {
|
|
mode: "all",
|
|
backend: "docker",
|
|
scope: "session",
|
|
workspaceAccess: "none",
|
|
workspaceRoot: "/tmp/openclaw-sandboxes",
|
|
docker: {
|
|
image: "openclaw-sandbox:bookworm-slim",
|
|
containerPrefix: "openclaw-sbx-",
|
|
workdir: "/workspace",
|
|
readOnlyRoot: true,
|
|
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
|
network: "none",
|
|
capDrop: ["ALL"],
|
|
env: { LANG: "C.UTF-8" },
|
|
},
|
|
ssh: {
|
|
command: "ssh",
|
|
workspaceRoot: "/tmp/openclaw-sandboxes",
|
|
strictHostKeyChecking: true,
|
|
updateHostKeys: true,
|
|
},
|
|
browser: {
|
|
enabled: true,
|
|
image: "openclaw-sandbox-browser:bookworm-slim",
|
|
containerPrefix: "openclaw-sbx-browser-",
|
|
network: "openclaw-sandbox-browser",
|
|
cdpPort: 9222,
|
|
vncPort: 5900,
|
|
noVncPort: 6080,
|
|
headless: false,
|
|
enableNoVnc,
|
|
allowHostControl: false,
|
|
autoStart: true,
|
|
autoStartTimeoutMs: 12_000,
|
|
},
|
|
tools: {
|
|
allow: ["browser"],
|
|
deny: [],
|
|
},
|
|
prune: {
|
|
idleHours: 24,
|
|
maxAgeDays: 7,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("ensureSandboxBrowser create args", () => {
|
|
beforeEach(() => {
|
|
BROWSER_BRIDGES.clear();
|
|
resetNoVncObserverTokensForTests();
|
|
dockerMocks.dockerContainerState.mockClear();
|
|
dockerMocks.execDocker.mockClear();
|
|
dockerMocks.readDockerContainerEnvVar.mockClear();
|
|
dockerMocks.readDockerContainerLabel.mockClear();
|
|
dockerMocks.readDockerPort.mockClear();
|
|
registryMocks.readBrowserRegistry.mockClear();
|
|
registryMocks.updateBrowserRegistry.mockClear();
|
|
bridgeMocks.startBrowserBridgeServer.mockClear();
|
|
bridgeMocks.stopBrowserBridgeServer.mockClear();
|
|
|
|
dockerMocks.dockerContainerState.mockResolvedValue({ exists: false, running: false });
|
|
dockerMocks.execDocker.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === "image" && args[1] === "inspect") {
|
|
return { stdout: "[]", stderr: "", code: 0 };
|
|
}
|
|
return { stdout: "", stderr: "", code: 0 };
|
|
});
|
|
dockerMocks.readDockerContainerLabel.mockResolvedValue(null);
|
|
dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null);
|
|
dockerMocks.readDockerPort.mockImplementation(async (_containerName: string, port: number) => {
|
|
if (port === 9222) {
|
|
return 49100;
|
|
}
|
|
if (port === 6080) {
|
|
return 49101;
|
|
}
|
|
return null;
|
|
});
|
|
registryMocks.readBrowserRegistry.mockResolvedValue({ entries: [] });
|
|
registryMocks.updateBrowserRegistry.mockResolvedValue(undefined);
|
|
bridgeMocks.startBrowserBridgeServer.mockResolvedValue({
|
|
server: {} as never,
|
|
port: 19000,
|
|
baseUrl: "http://127.0.0.1:19000",
|
|
state: {
|
|
server: null,
|
|
port: 19000,
|
|
resolved: { profiles: {} },
|
|
profiles: new Map(),
|
|
},
|
|
});
|
|
bridgeMocks.stopBrowserBridgeServer.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("publishes noVNC on loopback and injects noVNC password env", async () => {
|
|
const result = await ensureSandboxBrowser({
|
|
scopeKey: "session:test",
|
|
workspaceDir: "/tmp/workspace",
|
|
agentWorkspaceDir: "/tmp/workspace",
|
|
cfg: buildConfig(true),
|
|
});
|
|
|
|
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
|
|
|
expect(createArgs).toBeDefined();
|
|
expect(createArgs).toContain("127.0.0.1::6080");
|
|
const envEntries = collectDockerFlagValues(createArgs ?? [], "-e");
|
|
expect(envEntries).toContain("OPENCLAW_BROWSER_NO_SANDBOX=1");
|
|
const passwordEntry = envEntries.find((entry) =>
|
|
entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="),
|
|
);
|
|
expect(passwordEntry).toMatch(/^OPENCLAW_BROWSER_NOVNC_PASSWORD=[A-Za-z0-9]{8}$/);
|
|
expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:19000\/sandbox\/novnc\?token=/);
|
|
expect(result?.noVncUrl).not.toContain("password=");
|
|
});
|
|
|
|
it("does not inject noVNC password env when noVNC is disabled", async () => {
|
|
const result = await ensureSandboxBrowser({
|
|
scopeKey: "session:test",
|
|
workspaceDir: "/tmp/workspace",
|
|
agentWorkspaceDir: "/tmp/workspace",
|
|
cfg: buildConfig(false),
|
|
});
|
|
|
|
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
|
const envEntries = collectDockerFlagValues(createArgs ?? [], "-e");
|
|
expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="))).toBe(
|
|
false,
|
|
);
|
|
expect(result?.noVncUrl).toBeUndefined();
|
|
});
|
|
|
|
it("mounts the main workspace read-only when workspaceAccess is none", async () => {
|
|
const cfg = buildConfig(false);
|
|
cfg.workspaceAccess = "none";
|
|
|
|
await ensureSandboxBrowser({
|
|
scopeKey: "session:test",
|
|
workspaceDir: "/tmp/workspace",
|
|
agentWorkspaceDir: "/tmp/workspace",
|
|
cfg,
|
|
});
|
|
|
|
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
|
|
|
expect(createArgs).toBeDefined();
|
|
expect(createArgs).toContain("/tmp/workspace:/workspace:ro");
|
|
});
|
|
|
|
it("keeps the main workspace writable when workspaceAccess is rw", async () => {
|
|
const cfg = buildConfig(false);
|
|
cfg.workspaceAccess = "rw";
|
|
|
|
await ensureSandboxBrowser({
|
|
scopeKey: "session:test",
|
|
workspaceDir: "/tmp/workspace",
|
|
agentWorkspaceDir: "/tmp/workspace",
|
|
cfg,
|
|
});
|
|
|
|
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
|
|
|
expect(createArgs).toBeDefined();
|
|
expect(createArgs).toContain("/tmp/workspace:/workspace");
|
|
expect(createArgs).not.toContain("/tmp/workspace:/workspace:ro");
|
|
});
|
|
});
|