refactor: extract shared sandbox and gateway plumbing

This commit is contained in:
Peter Steinberger
2026-03-02 23:16:02 +00:00
parent 350d041eaf
commit 7066d5e192
21 changed files with 870 additions and 675 deletions

View File

@@ -2,6 +2,7 @@ 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(() => ({
@@ -85,16 +86,6 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig {
};
}
function envEntriesFromDockerArgs(args: string[]): string[] {
const values: string[] = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "-e" && typeof args[i + 1] === "string") {
values.push(args[i + 1]);
}
}
return values;
}
describe("ensureSandboxBrowser create args", () => {
beforeEach(() => {
BROWSER_BRIDGES.clear();
@@ -151,13 +142,11 @@ describe("ensureSandboxBrowser create args", () => {
cfg: buildConfig(true),
});
const createArgs = dockerMocks.execDocker.mock.calls.find(
(call: unknown[]) => Array.isArray(call[0]) && call[0][0] === "create",
)?.[0] as string[] | undefined;
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
expect(createArgs).toBeDefined();
expect(createArgs).toContain("127.0.0.1::6080");
const envEntries = envEntriesFromDockerArgs(createArgs ?? []);
const envEntries = collectDockerFlagValues(createArgs ?? [], "-e");
expect(envEntries).toContain("OPENCLAW_BROWSER_NO_SANDBOX=1");
const passwordEntry = envEntries.find((entry) =>
entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="),
@@ -175,10 +164,8 @@ describe("ensureSandboxBrowser create args", () => {
cfg: buildConfig(false),
});
const createArgs = dockerMocks.execDocker.mock.calls.find(
(call: unknown[]) => Array.isArray(call[0]) && call[0][0] === "create",
)?.[0] as string[] | undefined;
const envEntries = envEntriesFromDockerArgs(createArgs ?? []);
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,
);
@@ -196,9 +183,7 @@ describe("ensureSandboxBrowser create args", () => {
cfg,
});
const createArgs = dockerMocks.execDocker.mock.calls.find(
(call: unknown[]) => Array.isArray(call[0]) && call[0][0] === "create",
)?.[0] as string[] | undefined;
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
expect(createArgs).toBeDefined();
expect(createArgs).toContain("/tmp/workspace:/workspace:ro");
@@ -215,9 +200,7 @@ describe("ensureSandboxBrowser create args", () => {
cfg,
});
const createArgs = dockerMocks.execDocker.mock.calls.find(
(call: unknown[]) => Array.isArray(call[0]) && call[0][0] === "create",
)?.[0] as string[] | undefined;
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
expect(createArgs).toBeDefined();
expect(createArgs).toContain("/tmp/workspace:/workspace");

View File

@@ -11,11 +11,7 @@ import { defaultRuntime } from "../../runtime.js";
import { BROWSER_BRIDGES } from "./browser-bridges.js";
import { computeSandboxBrowserConfigHash } from "./config-hash.js";
import { resolveSandboxBrowserDockerCreateConfig } from "./config.js";
import {
DEFAULT_SANDBOX_BROWSER_IMAGE,
SANDBOX_AGENT_WORKSPACE_MOUNT,
SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
} from "./constants.js";
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "./constants.js";
import {
buildSandboxCreateArgs,
dockerContainerState,
@@ -37,6 +33,7 @@ import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js";
import { isToolAllowed } from "./tool-policy.js";
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
import { validateNetworkMode } from "./validate-sandbox-security.js";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
@@ -237,15 +234,13 @@ export async function ensureSandboxBrowser(params: {
includeBinds: false,
bindSourceRoots: [params.workspaceDir, params.agentWorkspaceDir],
});
const mainMountSuffix = params.cfg.workspaceAccess === "rw" ? "" : ":ro";
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`);
if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : "";
args.push(
"-v",
`${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
);
}
appendWorkspaceMountArgs({
args,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
workdir: params.cfg.docker.workdir,
workspaceAccess: params.cfg.workspaceAccess,
});
if (browserDockerCfg.binds?.length) {
for (const bind of browserDockerCfg.binds) {
args.push("-v", bind);

View File

@@ -3,6 +3,7 @@ import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computeSandboxConfigHash } from "./config-hash.js";
import { ensureSandboxContainer } from "./docker.js";
import { collectDockerFlagValues } from "./test-args.js";
import type { SandboxConfig } from "./types.js";
type SpawnCall = {
@@ -237,13 +238,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
expect(createCall).toBeDefined();
expect(createCall?.args).toContain(`openclaw.configHash=${expectedHash}`);
const bindArgs: string[] = [];
const args = createCall?.args ?? [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "-v" && typeof args[i + 1] === "string") {
bindArgs.push(args[i + 1]);
}
}
const bindArgs = collectDockerFlagValues(createCall?.args ?? [], "-v");
const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace");
const customMountIdx = bindArgs.indexOf("/tmp/workspace-shared/USER.md:/workspace/USER.md:ro");
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
@@ -277,13 +272,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
);
expect(createCall).toBeDefined();
const bindArgs: string[] = [];
const args = createCall?.args ?? [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "-v" && typeof args[i + 1] === "string") {
bindArgs.push(args[i + 1]);
}
}
const bindArgs = collectDockerFlagValues(createCall?.args ?? [], "-v");
expect(bindArgs).toContain(expectedMainMount);
},
);

View File

@@ -164,11 +164,12 @@ export function execDockerRaw(
import { formatCliCommand } from "../../cli/command-format.js";
import { defaultRuntime } from "../../runtime.js";
import { computeSandboxConfigHash } from "./config-hash.js";
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
import { readRegistry, updateRegistry } from "./registry.js";
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
const log = createSubsystemLogger("docker");
@@ -452,15 +453,13 @@ async function createSandboxContainer(params: {
bindSourceRoots: [workspaceDir, params.agentWorkspaceDir],
});
args.push("--workdir", cfg.workdir);
const mainMountSuffix = params.workspaceAccess === "rw" ? "" : ":ro";
args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`);
if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "";
args.push(
"-v",
`${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
);
}
appendWorkspaceMountArgs({
args,
workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
workdir: cfg.workdir,
workspaceAccess: params.workspaceAccess,
});
appendCustomBinds(args, cfg);
args.push(cfg.image, "sleep", "infinity");

View File

@@ -0,0 +1,15 @@
export function findDockerArgsCall(calls: unknown[][], command: string): string[] | undefined {
return calls.find((call) => Array.isArray(call[0]) && call[0][0] === command)?.[0] as
| string[]
| undefined;
}
export function collectDockerFlagValues(args: string[], flag: string): string[] {
const values: string[] = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === flag && typeof args[i + 1] === "string") {
values.push(args[i + 1]);
}
}
return values;
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
describe("appendWorkspaceMountArgs", () => {
it.each([
{ access: "rw" as const, expected: "/tmp/workspace:/workspace" },
{ access: "ro" as const, expected: "/tmp/workspace:/workspace:ro" },
{ access: "none" as const, expected: "/tmp/workspace:/workspace:ro" },
])("sets main mount permissions for workspaceAccess=$access", ({ access, expected }) => {
const args: string[] = [];
appendWorkspaceMountArgs({
args,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/agent-workspace",
workdir: "/workspace",
workspaceAccess: access,
});
expect(args).toContain(expected);
});
it("omits agent workspace mount when workspaceAccess is none", () => {
const args: string[] = [];
appendWorkspaceMountArgs({
args,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/agent-workspace",
workdir: "/workspace",
workspaceAccess: "none",
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual(["/tmp/workspace:/workspace:ro"]);
});
it("omits agent workspace mount when paths are identical", () => {
const args: string[] = [];
appendWorkspaceMountArgs({
args,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
workdir: "/workspace",
workspaceAccess: "rw",
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual(["/tmp/workspace:/workspace"]);
});
});

View File

@@ -0,0 +1,28 @@
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import type { SandboxWorkspaceAccess } from "./types.js";
function mainWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" {
return access === "rw" ? "" : ":ro";
}
function agentWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" {
return access === "ro" ? ":ro" : "";
}
export function appendWorkspaceMountArgs(params: {
args: string[];
workspaceDir: string;
agentWorkspaceDir: string;
workdir: string;
workspaceAccess: SandboxWorkspaceAccess;
}) {
const { args, workspaceDir, agentWorkspaceDir, workdir, workspaceAccess } = params;
args.push("-v", `${workspaceDir}:${workdir}${mainWorkspaceMountSuffix(workspaceAccess)}`);
if (workspaceAccess !== "none" && workspaceDir !== agentWorkspaceDir) {
args.push(
"-v",
`${agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentWorkspaceMountSuffix(workspaceAccess)}`,
);
}
}