mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix(sandbox): restore workspace files in Docker Gateways (#147276)
* fix(sandbox): restore workspace files in Docker Gateways Resolve managed workspace, agent, skill and nested bind sources from a verified Gateway container before sandbox hashing and creation. Share the effective mount plan across shell and browser lifecycles; preserve read-only permissions and refuse stale hot mounts with scoped recreate guidance. Fixes #31331 Co-authored-by: AaronWander <siralonne@163.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com> * test(sandbox): isolate managed mount permission fixtures * fix(sandbox): narrow discovered Docker container ids * test(sandbox): split container creation and mount coverage * test(sandbox): restore filesystem fixture import * test(sandbox): sort mount proof records immutably * fix(sandbox): inspect typed Docker metadata and isolate E2E cleanup * test(sandbox): assert package import follows fixture environment --------- Co-authored-by: AaronWander <siralonne@163.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
parent
267184b3f4
commit
2c393ecf55
21 changed files with 2606 additions and 1106 deletions
|
|
@ -47,10 +47,20 @@ To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent
|
|||
<Warning>
|
||||
**Docker-out-of-Docker (DooD) constraints**
|
||||
|
||||
If you deploy the OpenClaw Gateway itself as a Docker container, it orchestrates sibling sandbox containers using the host's Docker socket (DooD). This introduces a path mapping constraint:
|
||||
If the Gateway runs in Docker, it creates sibling sandbox containers through the host's Docker socket.
|
||||
Keep workspace paths in `openclaw.json` relative to the Gateway filesystem, such as `/home/node/.openclaw/workspace`.
|
||||
OpenClaw translates managed workspace, agent-workspace, and skill mounts into the Docker host's paths automatically.
|
||||
Shell and browser containers use the same mapping rules.
|
||||
Nested Gateway binds are projected too, with their read-only permissions preserved.
|
||||
|
||||
- Bind-mount the workspace and OpenClaw state directories into the Gateway. Their host and Gateway paths can differ.
|
||||
- Use the Docker daemon that runs the Gateway. OpenClaw verifies its container identity before trusting the daemon's mount table.
|
||||
- Managed sources and their visible nested mounts must come from bind mounts. Named volumes, tmpfs, and files in the Gateway image are unsupported sources for sibling sandbox mounts.
|
||||
- A writable sandbox requires a writable Gateway bind. Use `workspaceAccess: "ro"` for read-only Gateway sources.
|
||||
- Explicit `sandbox.docker.binds` and `sandbox.browser.binds` retain their host-path contract. OpenClaw does not translate these operator-supplied sources.
|
||||
- Restart the Gateway after changing its mounts or Docker connection. If an existing sandbox has different mounts, OpenClaw reports a scoped `sandbox recreate` command.
|
||||
Recently used containers remain running until you recreate them, but OpenClaw refuses to reuse their stale mounts.
|
||||
|
||||
- **Config requires host paths**: `openclaw.json` `workspace` must contain the **host's absolute path** (e.g. `/home/user/.openclaw/workspaces`), not the internal Gateway container path. The Docker daemon evaluates paths relative to the host OS namespace, not the Gateway's own namespace.
|
||||
- **Matching volume map required**: The Gateway process also writes bridge files to that `workspace` path. Give the Gateway container an identical volume map (`-v /home/user/.openclaw:/home/user/.openclaw`) so the same host path resolves correctly from inside the Gateway container too. Mismatched mappings surface as `EACCES` when the Gateway writes workspace files.
|
||||
- **Codex code mode**: when an OpenClaw sandbox is active, OpenClaw disables Codex app-server native Code Mode, user MCP servers, and app-backed plugin execution for that turn (those run from the Gateway-host app-server process, not the OpenClaw sandbox backend), unless the sandbox tool policy exposes the required tools and you opt into the experimental sandbox exec-server path. Shell access then routes through OpenClaw sandbox-backed tools such as `sandbox_exec` and `sandbox_process`. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes. See [Codex Harness](/plugins/codex-harness) for the full behavior.
|
||||
|
||||
On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `workspace-write` shell execution needs unprivileged user namespaces inside the sandbox container, and this can fail before shell startup when the service user cannot create them. This needs an unprivileged network namespace too when Docker sandbox egress is disabled (`network: "none"`, the default). Common symptoms: `bwrap: setting up uid map: Permission denied` and `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. Run `openclaw doctor`; if it reports a Codex bwrap namespace probe failure, prefer an AppArmor profile that grants the required namespaces to the OpenClaw service process. `kernel.apparmor_restrict_unprivileged_userns=0` is a host-wide fallback with security tradeoffs; use it only when that host posture is acceptable.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import fs from "node:fs/promises";
|
|||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { resolveSandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -18,6 +17,10 @@ function requireEnv(name) {
|
|||
}
|
||||
|
||||
const root = requireEnv("OPENCLAW_E2E_ROOT");
|
||||
const hostRoot = requireEnv("OPENCLAW_E2E_HOST_ROOT");
|
||||
const workspaceAccess = requireEnv("OPENCLAW_E2E_WORKSPACE_ACCESS");
|
||||
assert.notEqual(root, hostRoot, "DooD proof requires different Gateway and daemon paths");
|
||||
assert(["none", "ro", "rw"].includes(workspaceAccess));
|
||||
const sandboxImage = requireEnv("OPENCLAW_E2E_SANDBOX_IMAGE");
|
||||
const browserImage = requireEnv("OPENCLAW_E2E_BROWSER_IMAGE");
|
||||
const sandboxPrefix = requireEnv("OPENCLAW_E2E_SANDBOX_PREFIX");
|
||||
|
|
@ -37,8 +40,12 @@ const ownedContainerNames = new Set();
|
|||
process.env.HOME = path.join(root, "home");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
// Sandbox state roots are process-stable; initialize them from the fixture's
|
||||
// environment before loading the packaged runtime.
|
||||
const { resolveSandboxContext } = await import("openclaw/plugin-sdk/agent-harness-runtime");
|
||||
|
||||
const config = {
|
||||
skills: { load: { extraDirs: [path.join(root, "skill-source")] } },
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
|
|
@ -65,7 +72,7 @@ const config = {
|
|||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "session",
|
||||
workspaceAccess: "rw",
|
||||
workspaceAccess,
|
||||
workspaceRoot: sandboxRoot,
|
||||
docker: {
|
||||
image: sandboxImage,
|
||||
|
|
@ -160,6 +167,13 @@ async function startFixtureServer() {
|
|||
await fs.mkdir(process.env.HOME, { recursive: true });
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "USER.md"), marker);
|
||||
await fs.writeFile(path.join(root, "nested data", "proof.txt"), marker);
|
||||
await fs.mkdir(path.join(root, "skill-source", "mount-proof"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "skill-source", "mount-proof", "SKILL.md"),
|
||||
`---\nname: mount-proof\ndescription: deterministic sandbox mount proof\n---\n${marker}\n`,
|
||||
);
|
||||
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
|
||||
const fixture = await startFixtureServer();
|
||||
|
|
@ -204,6 +218,103 @@ try {
|
|||
ownedContainerNames.add(first.containerName);
|
||||
ownedContainerNames.add(first.browser.containerName);
|
||||
|
||||
const skillPath =
|
||||
workspaceAccess === "rw"
|
||||
? "/workspace/.openclaw/sandbox-skills/skills/mount-proof/SKILL.md"
|
||||
: "/workspace/skills/mount-proof/SKILL.md";
|
||||
const nestedPath =
|
||||
workspaceAccess === "none"
|
||||
? undefined
|
||||
: `${workspaceAccess === "ro" ? "/agent" : "/workspace"}/data/proof.txt`;
|
||||
for (const filePath of ["/workspace/USER.md", skillPath, ...(nestedPath ? [nestedPath] : [])]) {
|
||||
assert.match((await first.fsBridge.readFile({ filePath })).toString(), new RegExp(marker));
|
||||
const read = await first.backend.runShellCommand({ script: 'cat "$1"', args: [filePath] });
|
||||
assert.match(
|
||||
read.stdout.toString(),
|
||||
new RegExp(marker),
|
||||
"sandbox exec disagrees with Gateway file tools",
|
||||
);
|
||||
const browserRead = await docker([
|
||||
"exec",
|
||||
first.browser.containerName,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'cat "$1"',
|
||||
"--",
|
||||
filePath,
|
||||
]);
|
||||
assert.match(browserRead.stdout, new RegExp(marker), "browser mount exposes different bytes");
|
||||
}
|
||||
const write = { filePath: "/workspace/roundtrip.txt", data: marker };
|
||||
if (workspaceAccess === "ro") {
|
||||
await assert.rejects(first.fsBridge.writeFile(write));
|
||||
const attempted = await first.backend.runShellCommand({
|
||||
script: 'printf %s "$2" > "$1"',
|
||||
args: [write.filePath, marker],
|
||||
allowFailure: true,
|
||||
});
|
||||
assert.notEqual(attempted.code, 0, "read-only workspace accepted an exec write");
|
||||
} else {
|
||||
await first.fsBridge.writeFile(write);
|
||||
const read = await first.backend.runShellCommand({
|
||||
script: 'cat "$1"',
|
||||
args: [write.filePath],
|
||||
});
|
||||
assert.equal(read.stdout.toString(), marker);
|
||||
await first.backend.runShellCommand({
|
||||
script: 'printf %s "$2" > "$1"',
|
||||
args: [write.filePath, `${marker}-exec`],
|
||||
});
|
||||
assert.equal(
|
||||
(await first.fsBridge.readFile({ filePath: write.filePath })).toString(),
|
||||
`${marker}-exec`,
|
||||
);
|
||||
}
|
||||
for (const filePath of [skillPath, ...(nestedPath ? [nestedPath] : [])]) {
|
||||
await assert.rejects(first.fsBridge.writeFile({ filePath, data: "must-not-write" }));
|
||||
const attempted = await first.backend.runShellCommand({
|
||||
script: 'printf x > "$1"',
|
||||
args: [filePath],
|
||||
allowFailure: true,
|
||||
});
|
||||
assert.notEqual(attempted.code, 0, "read-only skill or nested Gateway mount accepted a write");
|
||||
}
|
||||
const mountProof = [];
|
||||
for (const containerName of [first.containerName, first.browser.containerName]) {
|
||||
const { stdout } = await docker(["inspect", "--format", "{{json .Mounts}}", containerName]);
|
||||
const managed = JSON.parse(stdout).filter(
|
||||
(mount) =>
|
||||
mount.Destination === "/agent" ||
|
||||
mount.Destination.startsWith("/agent/") ||
|
||||
mount.Destination === "/workspace" ||
|
||||
mount.Destination.startsWith("/workspace/"),
|
||||
);
|
||||
assert(managed.length > 0);
|
||||
assert.equal(
|
||||
managed.some((mount) => mount.Destination === "/agent"),
|
||||
workspaceAccess === "ro",
|
||||
);
|
||||
for (const mount of managed) {
|
||||
assert.equal(mount.Type, "bind");
|
||||
assert(
|
||||
mount.Source.startsWith(`${hostRoot}/`),
|
||||
"sandbox source was not translated into the daemon namespace",
|
||||
);
|
||||
}
|
||||
mountProof.push(
|
||||
managed
|
||||
.map((mount) => ({
|
||||
source: path.relative(hostRoot, mount.Source),
|
||||
destination: mount.Destination,
|
||||
writable: mount.RW,
|
||||
}))
|
||||
.toSorted((a, b) =>
|
||||
a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(mountProof[0], mountProof[1], "shell/browser managed mount plans differ");
|
||||
|
||||
const unauthenticated = await fetch(`${first.browser.bridgeUrl}/`);
|
||||
assert.equal(unauthenticated.status, 401, "browser bridge accepted an unauthenticated request");
|
||||
await unauthenticated.body?.cancel();
|
||||
|
|
@ -248,6 +359,8 @@ try {
|
|||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
workspaceAccess,
|
||||
managedMounts: mountProof,
|
||||
sandboxContainer: first.containerName,
|
||||
browserContainer: first.browser.containerName,
|
||||
marker,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ SANDBOX_PREFIX="openclaw-e2e-sbx-${RUN_ID}-"
|
|||
BROWSER_PREFIX="openclaw-e2e-browser-${RUN_ID}-"
|
||||
NETWORK_NAME="openclaw-e2e-browser-${RUN_ID}"
|
||||
SCENARIO_ROOT="$(mktemp -d /tmp/openclaw-sandbox-browser-sidecar.XXXXXX)"
|
||||
SESSION_KEY="agent:main:sandbox-browser-sidecar"
|
||||
WORKSPACE_HASH="$(node -e 'process.stdout.write(require("node:crypto").createHash("sha256").update(process.argv[1]).digest("hex").slice(0, 32))' "$SCENARIO_ROOT/workspace")"
|
||||
SCOPE_KEY="${SESSION_KEY}:workspace:${WORKSPACE_HASH}"
|
||||
GATEWAY_ROOT="/home/appuser/.openclaw-e2e"
|
||||
BASE_SESSION_KEY="agent:main:sandbox-browser-sidecar:${RUN_ID}"
|
||||
WORKSPACE_HASH="$(node -e 'process.stdout.write(require("node:crypto").createHash("sha256").update(process.argv[1]).digest("hex").slice(0, 32))' "$GATEWAY_ROOT/workspace")"
|
||||
BUILD_DIR="$(mktemp -d /tmp/openclaw-sandbox-browser-sidecar-build.XXXXXX)"
|
||||
DOCKER_SOCKET="${OPENCLAW_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
SCENARIO_SOURCE="$ROOT_DIR/scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs"
|
||||
|
|
@ -37,10 +37,15 @@ docker_socket_gid() {
|
|||
}
|
||||
|
||||
remove_task_containers() {
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
docker_e2e_docker_cmd rm -f "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker_e2e_docker_cmd ps -a --filter "label=openclaw.sessionKey=$SCOPE_KEY" --format '{{.Names}}' 2>/dev/null || true)
|
||||
local access scope_key name
|
||||
# Retry leftovers from every mode, without matching another run that uses the
|
||||
# same Gateway workspace path and therefore the same workspace hash.
|
||||
for access in none ro rw; do
|
||||
scope_key="${BASE_SESSION_KEY}:$access:workspace:${WORKSPACE_HASH}"
|
||||
while IFS= read -r name; do
|
||||
docker_e2e_docker_cmd rm -f "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker_e2e_docker_cmd ps -a --filter "label=openclaw.sessionKey=$scope_key" --format '{{.Names}}' 2>/dev/null || true)
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
|
|
@ -59,9 +64,11 @@ if [ ! -S "$DOCKER_SOCKET" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# The inner sandbox containers bind host paths. Keep this path identical in the
|
||||
# package runner so the Docker daemon resolves the same task-owned directory.
|
||||
chmod 0777 "$SCENARIO_ROOT"
|
||||
# Deliberately distinct host/Gateway paths expose Docker-outside-Docker mistakes.
|
||||
# The nested workspace bind also proves longest-prefix mapping instead of relying
|
||||
# on the state directory's broader bind.
|
||||
mkdir -p "$SCENARIO_ROOT/agent workspace" "$SCENARIO_ROOT/nested data"
|
||||
chmod 0777 "$SCENARIO_ROOT" "$SCENARIO_ROOT/agent workspace" "$SCENARIO_ROOT/nested data"
|
||||
|
||||
docker_e2e_build_or_reuse \
|
||||
"$FUNCTIONAL_IMAGE" \
|
||||
|
|
@ -99,11 +106,16 @@ docker_build_run sandbox-browser-sidecar-runner-build \
|
|||
SOCKET_GID="$(docker_socket_gid)"
|
||||
|
||||
echo "Running package-backed sandbox browser sidecar Docker E2E..."
|
||||
for access in none ro rw; do
|
||||
SESSION_KEY="${BASE_SESSION_KEY}:$access"
|
||||
docker_e2e_run_logged_print_with_harness sandbox-browser-sidecar \
|
||||
--network host \
|
||||
--hostname sandbox-gateway-e2e \
|
||||
--group-add "$SOCKET_GID" \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_E2E_ROOT=$SCENARIO_ROOT" \
|
||||
-e "OPENCLAW_E2E_ROOT=$GATEWAY_ROOT" \
|
||||
-e "OPENCLAW_E2E_HOST_ROOT=$SCENARIO_ROOT" \
|
||||
-e "OPENCLAW_E2E_WORKSPACE_ACCESS=$access" \
|
||||
-e "OPENCLAW_E2E_SESSION_KEY=$SESSION_KEY" \
|
||||
-e "OPENCLAW_E2E_SANDBOX_IMAGE=$SANDBOX_IMAGE" \
|
||||
-e "OPENCLAW_E2E_BROWSER_IMAGE=$BROWSER_IMAGE" \
|
||||
|
|
@ -111,11 +123,14 @@ docker_e2e_run_logged_print_with_harness sandbox-browser-sidecar \
|
|||
-e "OPENCLAW_E2E_BROWSER_PREFIX=$BROWSER_PREFIX" \
|
||||
-e "OPENCLAW_E2E_BROWSER_NETWORK=$NETWORK_NAME" \
|
||||
-v "$DOCKER_SOCKET:/var/run/docker.sock" \
|
||||
-v "$SCENARIO_ROOT:$SCENARIO_ROOT" \
|
||||
-v "$SCENARIO_ROOT:$GATEWAY_ROOT" \
|
||||
-v "$SCENARIO_ROOT/agent workspace:$GATEWAY_ROOT/workspace" \
|
||||
-v "$SCENARIO_ROOT/nested data:$GATEWAY_ROOT/workspace/data:ro" \
|
||||
-v "$SCENARIO_SOURCE:/tmp/openclaw-sandbox-browser-sidecar-scenario.mjs:ro" \
|
||||
"$RUNNER_IMAGE" \
|
||||
bash -lc \
|
||||
'cp /tmp/openclaw-sandbox-browser-sidecar-scenario.mjs /app/sandbox-browser-sidecar-scenario.mjs
|
||||
exec node /app/sandbox-browser-sidecar-scenario.mjs'
|
||||
done
|
||||
|
||||
echo "Sandbox browser sidecar Docker E2E passed."
|
||||
|
|
|
|||
395
src/agents/sandbox/browser.create.test-helpers.ts
Normal file
395
src/agents/sandbox/browser.create.test-helpers.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { computeSandboxBrowserConfigHash } from "./config-hash.js";
|
||||
import { resolveSandboxBrowserDockerCreateConfig } from "./config.js";
|
||||
import {
|
||||
SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH,
|
||||
SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
|
||||
} from "./constants.js";
|
||||
import { DOCKER_SANDBOX_ENGINE } from "./container-engine.js";
|
||||
import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
const namespaceMocks = vi.hoisted(() => ({
|
||||
resolveDockerSourceNamespace: vi
|
||||
.fn<typeof import("./docker-mount-source.js").resolveDockerSourceNamespace>()
|
||||
.mockResolvedValue(undefined),
|
||||
execContainer: vi.fn<typeof import("./container-engine.js").execContainer>(),
|
||||
}));
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
}));
|
||||
|
||||
async function createDockerMock() {
|
||||
const actual = await vi.importActual<typeof import("./docker.js")>("./docker.js");
|
||||
return {
|
||||
...actual,
|
||||
dockerContainerState: dockerMocks.dockerContainerState,
|
||||
execDocker: dockerMocks.execDocker,
|
||||
readDockerContainerEnvVar: dockerMocks.readDockerContainerEnvVar,
|
||||
readDockerContainerLabel: dockerMocks.readDockerContainerLabel,
|
||||
readDockerPort: dockerMocks.readDockerPort,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./docker.js", createDockerMock);
|
||||
|
||||
async function createNamespaceMock() {
|
||||
const actual = await vi.importActual<typeof import("./docker-mount-source.js")>(
|
||||
"./docker-mount-source.js",
|
||||
);
|
||||
return {
|
||||
parseInspectedSandboxMounts: actual.parseInspectedSandboxMounts,
|
||||
translateSandboxMountSources: actual.translateSandboxMountSources,
|
||||
resolveDockerSourceNamespace: namespaceMocks.resolveDockerSourceNamespace,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./docker-mount-source.js", createNamespaceMock);
|
||||
async function createEngineMock() {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./container-engine.js")>("./container-engine.js");
|
||||
return { ...actual, execContainer: namespaceMocks.execContainer };
|
||||
}
|
||||
|
||||
vi.mock("./container-engine.js", createEngineMock);
|
||||
|
||||
function createRegistryMock() {
|
||||
return {
|
||||
readBrowserRegistry: registryMocks.readBrowserRegistry,
|
||||
updateBrowserRegistry: registryMocks.updateBrowserRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./registry.js", createRegistryMock);
|
||||
|
||||
function createBridgeMock() {
|
||||
return {
|
||||
startBrowserBridgeServer: bridgeMocks.startBrowserBridgeServer,
|
||||
stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../plugin-sdk/browser-bridge.js", createBridgeMock);
|
||||
|
||||
function createRuntimeMock() {
|
||||
return {
|
||||
defaultRuntime: runtimeMocks,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../runtime.js", createRuntimeMock);
|
||||
|
||||
function createProfilesMock() {
|
||||
return {
|
||||
DEFAULT_BROWSER_ACTION_TIMEOUT_MS: 60_000,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED: true,
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR: "#FF4500",
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: "openclaw",
|
||||
resolveProfile: (
|
||||
resolved: { cdpHost: string; cdpIsLoopback: boolean; profiles?: Record<string, unknown> },
|
||||
profileName: string,
|
||||
) => {
|
||||
const profile = resolved.profiles?.[profileName] as {
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
color?: string;
|
||||
};
|
||||
if (typeof profile?.cdpPort !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: profile.cdpPort,
|
||||
cdpUrl: profile.cdpUrl ?? `http://${resolved.cdpHost}:${profile.cdpPort}`,
|
||||
cdpHost: resolved.cdpHost,
|
||||
cdpIsLoopback: resolved.cdpIsLoopback,
|
||||
color: profile.color ?? "#FF4500",
|
||||
driver: "openclaw",
|
||||
attachOnly: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../plugin-sdk/browser-profiles.js", createProfilesMock);
|
||||
|
||||
export function createSandboxBrowserTestHarness() {
|
||||
// Keep loaded modules and temporary directories per suite. Shared mock modules
|
||||
// are reset by the hooks installed for that suite.
|
||||
let BROWSER_BRIDGES: Map<string, unknown>;
|
||||
let ensureSandboxBrowser: typeof import("./browser.js").ensureSandboxBrowser;
|
||||
let prepareSandboxMountPlan: typeof import("./mount-plan.js").prepareSandboxMountPlan;
|
||||
let capturedDockerCreateEnvEntries: string[] | undefined;
|
||||
let testWorkspaceDir: string;
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
async function loadFreshBrowserModulesForTest() {
|
||||
vi.resetModules();
|
||||
vi.doMock("./docker.js", createDockerMock);
|
||||
vi.doMock("./docker-mount-source.js", createNamespaceMock);
|
||||
vi.doMock("./container-engine.js", createEngineMock);
|
||||
vi.doMock("./registry.js", createRegistryMock);
|
||||
vi.doMock("../../plugin-sdk/browser-bridge.js", createBridgeMock);
|
||||
vi.doMock("../../runtime.js", createRuntimeMock);
|
||||
vi.doMock("../../plugin-sdk/browser-profiles.js", createProfilesMock);
|
||||
({ prepareSandboxMountPlan } = await import("./mount-plan.js"));
|
||||
({ BROWSER_BRIDGES } = await import("./browser-bridges.js"));
|
||||
({ ensureSandboxBrowser } = await import("./browser.js"));
|
||||
}
|
||||
|
||||
function buildConfig(noVncEnabled: boolean): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "session",
|
||||
workspaceAccess: "none",
|
||||
workspaceRoot: "/tmp/openclaw-sandboxes",
|
||||
dockerTmpfsSource: "default",
|
||||
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,
|
||||
noVncEnabled,
|
||||
allowHostControl: false,
|
||||
autoStart: true,
|
||||
autoStartTimeoutMs: 12_000,
|
||||
},
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
deny: [],
|
||||
},
|
||||
prune: {
|
||||
idleHours: 24,
|
||||
maxAgeDays: 7,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function computeTestBrowserHash(params: {
|
||||
cfg: SandboxConfig;
|
||||
createArgsEpoch: string;
|
||||
workspaceDir?: string;
|
||||
agentWorkspaceDir?: string;
|
||||
dockerEnvPolicyEpoch?: string;
|
||||
}): Promise<string> {
|
||||
const workspaceDir = params.workspaceDir ?? testWorkspaceDir;
|
||||
const agentWorkspaceDir = params.agentWorkspaceDir ?? workspaceDir;
|
||||
const browserDockerCfg = resolveSandboxBrowserDockerCreateConfig({
|
||||
docker: params.cfg.docker,
|
||||
browser: params.cfg.browser,
|
||||
});
|
||||
return computeSandboxBrowserConfigHash({
|
||||
docker: browserDockerCfg,
|
||||
dockerEnvPolicyEpoch: params.dockerEnvPolicyEpoch,
|
||||
browser: {
|
||||
cdpPort: params.cfg.browser.cdpPort,
|
||||
cdpSourceRange: params.cfg.browser.cdpSourceRange,
|
||||
vncPort: params.cfg.browser.vncPort,
|
||||
noVncPort: params.cfg.browser.noVncPort,
|
||||
headless: params.cfg.browser.headless,
|
||||
noVncEnabled: params.cfg.browser.noVncEnabled,
|
||||
autoStartTimeoutMs: params.cfg.browser.autoStartTimeoutMs,
|
||||
},
|
||||
securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: params.createArgsEpoch,
|
||||
managedMounts: (
|
||||
await prepareSandboxMountPlan({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
binds: browserDockerCfg.binds,
|
||||
})
|
||||
).binds,
|
||||
});
|
||||
}
|
||||
|
||||
type EnsureSandboxBrowserParams = Parameters<
|
||||
typeof import("./browser.js").ensureSandboxBrowser
|
||||
>[0];
|
||||
|
||||
async function ensureTestSandboxBrowser(params: Omit<EnsureSandboxBrowserParams, "bridgeAuth">) {
|
||||
return await ensureSandboxBrowser({
|
||||
...params,
|
||||
bridgeAuth: { token: "test-bridge-token" },
|
||||
});
|
||||
}
|
||||
|
||||
function requireDockerCreateArgs(): string[] {
|
||||
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
||||
if (!createArgs) {
|
||||
throw new Error("expected docker create args");
|
||||
}
|
||||
return createArgs;
|
||||
}
|
||||
|
||||
function snapshotDockerCreateEnvEntries(args: string[]): string[] | undefined {
|
||||
const envFile = collectDockerFlagValues(args, "--env-file")[0];
|
||||
return envFile ? readFileSync(envFile, "utf8").split("\n").filter(Boolean) : undefined;
|
||||
}
|
||||
|
||||
function requireDockerCreateEnvEntries(): string[] {
|
||||
if (!capturedDockerCreateEnvEntries) {
|
||||
throw new Error("expected the docker create environment file to exist during create");
|
||||
}
|
||||
return capturedDockerCreateEnvEntries;
|
||||
}
|
||||
|
||||
function requireValue<T>(value: T | null | undefined, label: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function latestBridgeResolved(): Record<string, unknown> {
|
||||
const params = bridgeMocks.startBrowserBridgeServer.mock.calls.at(-1)?.[0];
|
||||
if (!params || typeof params !== "object") {
|
||||
throw new Error("expected browser bridge start params");
|
||||
}
|
||||
const resolved = params.resolved;
|
||||
if (!resolved || typeof resolved !== "object") {
|
||||
throw new Error("expected resolved browser bridge config");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadFreshBrowserModulesForTest();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testWorkspaceDir = tempDirs.make("openclaw-browser-workspace-");
|
||||
vi.restoreAllMocks();
|
||||
BROWSER_BRIDGES.clear();
|
||||
namespaceMocks.resolveDockerSourceNamespace.mockResolvedValue(undefined);
|
||||
namespaceMocks.execContainer.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Mounts: [{ Type: "bind", Source: testWorkspaceDir, Destination: "/workspace", RW: true }],
|
||||
Tmpfs: null,
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
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();
|
||||
runtimeMocks.log.mockClear();
|
||||
capturedDockerCreateEnvEntries = undefined;
|
||||
|
||||
dockerMocks.dockerContainerState.mockResolvedValue({ exists: false, running: false });
|
||||
dockerMocks.execDocker.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === "image" && args[1] === "inspect") {
|
||||
return { stdout: `${SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (args[0] === "create") {
|
||||
capturedDockerCreateEnvEntries = snapshotDockerCreateEnvEntries(args);
|
||||
}
|
||||
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: { listening: true } 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);
|
||||
});
|
||||
|
||||
return {
|
||||
execContainer: namespaceMocks.execContainer,
|
||||
resolveDockerSourceNamespace: namespaceMocks.resolveDockerSourceNamespace,
|
||||
dockerMocks,
|
||||
registryMocks,
|
||||
bridgeMocks,
|
||||
runtimeMocks,
|
||||
tempDirs,
|
||||
buildConfig,
|
||||
computeTestBrowserHash,
|
||||
ensureTestSandboxBrowser,
|
||||
requireDockerCreateArgs,
|
||||
snapshotDockerCreateEnvEntries,
|
||||
requireDockerCreateEnvEntries,
|
||||
requireValue,
|
||||
latestBridgeResolved,
|
||||
get BROWSER_BRIDGES() {
|
||||
return BROWSER_BRIDGES;
|
||||
},
|
||||
get testWorkspaceDir() {
|
||||
return testWorkspaceDir;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,306 +1,32 @@
|
|||
// Sandbox browser creation tests cover Docker args, bridge auth, noVNC access,
|
||||
// config hashing, and cached bridge invalidation.
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo, Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
computeSandboxBrowserConfigHash,
|
||||
SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
|
||||
} from "./config-hash.js";
|
||||
import { resolveSandboxBrowserDockerCreateConfig } from "./config.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSandboxBrowserTestHarness } from "./browser.create.test-helpers.js";
|
||||
import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js";
|
||||
import {
|
||||
SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH,
|
||||
SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
|
||||
SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
} from "./constants.js";
|
||||
import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
let BROWSER_BRIDGES: Map<string, unknown>;
|
||||
let ensureSandboxBrowser: typeof import("./browser.js").ensureSandboxBrowser;
|
||||
let capturedDockerCreateEnvEntries: string[] | undefined;
|
||||
let testWorkspaceDir: string;
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
}));
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
vi.mock("./docker.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./docker.js")>("./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("../../plugin-sdk/browser-bridge.js", () => ({
|
||||
startBrowserBridgeServer: bridgeMocks.startBrowserBridgeServer,
|
||||
stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: runtimeMocks,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugin-sdk/browser-profiles.js", () => ({
|
||||
DEFAULT_BROWSER_ACTION_TIMEOUT_MS: 60_000,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED: true,
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR: "#FF4500",
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: "openclaw",
|
||||
resolveProfile: (
|
||||
resolved: { cdpHost: string; cdpIsLoopback: boolean; profiles?: Record<string, unknown> },
|
||||
profileName: string,
|
||||
) => {
|
||||
const profile = resolved.profiles?.[profileName] as {
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
color?: string;
|
||||
};
|
||||
if (typeof profile?.cdpPort !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: profile.cdpPort,
|
||||
cdpUrl: profile.cdpUrl ?? `http://${resolved.cdpHost}:${profile.cdpPort}`,
|
||||
cdpHost: resolved.cdpHost,
|
||||
cdpIsLoopback: resolved.cdpIsLoopback,
|
||||
color: profile.color ?? "#FF4500",
|
||||
driver: "openclaw",
|
||||
attachOnly: true,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
async function loadFreshBrowserModulesForTest() {
|
||||
vi.resetModules();
|
||||
({ BROWSER_BRIDGES } = await import("./browser-bridges.js"));
|
||||
({ ensureSandboxBrowser } = await import("./browser.js"));
|
||||
}
|
||||
|
||||
function buildConfig(noVncEnabled: boolean): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "session",
|
||||
workspaceAccess: "none",
|
||||
workspaceRoot: "/tmp/openclaw-sandboxes",
|
||||
dockerTmpfsSource: "default",
|
||||
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,
|
||||
noVncEnabled,
|
||||
allowHostControl: false,
|
||||
autoStart: true,
|
||||
autoStartTimeoutMs: 12_000,
|
||||
},
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
deny: [],
|
||||
},
|
||||
prune: {
|
||||
idleHours: 24,
|
||||
maxAgeDays: 7,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function computeTestBrowserHash(params: {
|
||||
cfg: SandboxConfig;
|
||||
createArgsEpoch: string;
|
||||
workspaceDir?: string;
|
||||
agentWorkspaceDir?: string;
|
||||
dockerEnvPolicyEpoch?: string;
|
||||
readOnlyWorkspaceSkillMounts?: string[];
|
||||
}): string {
|
||||
const workspaceDir = params.workspaceDir ?? testWorkspaceDir;
|
||||
const agentWorkspaceDir = params.agentWorkspaceDir ?? workspaceDir;
|
||||
const browserDockerCfg = resolveSandboxBrowserDockerCreateConfig({
|
||||
docker: params.cfg.docker,
|
||||
browser: params.cfg.browser,
|
||||
});
|
||||
return computeSandboxBrowserConfigHash({
|
||||
docker: browserDockerCfg,
|
||||
dockerEnvPolicyEpoch: params.dockerEnvPolicyEpoch,
|
||||
browser: {
|
||||
cdpPort: params.cfg.browser.cdpPort,
|
||||
cdpSourceRange: params.cfg.browser.cdpSourceRange,
|
||||
vncPort: params.cfg.browser.vncPort,
|
||||
noVncPort: params.cfg.browser.noVncPort,
|
||||
headless: params.cfg.browser.headless,
|
||||
noVncEnabled: params.cfg.browser.noVncEnabled,
|
||||
autoStartTimeoutMs: params.cfg.browser.autoStartTimeoutMs,
|
||||
},
|
||||
securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: params.createArgsEpoch,
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
type EnsureSandboxBrowserParams = Parameters<typeof import("./browser.js").ensureSandboxBrowser>[0];
|
||||
|
||||
async function ensureTestSandboxBrowser(params: Omit<EnsureSandboxBrowserParams, "bridgeAuth">) {
|
||||
return await ensureSandboxBrowser({
|
||||
...params,
|
||||
bridgeAuth: { token: "test-bridge-token" },
|
||||
});
|
||||
}
|
||||
|
||||
function requireDockerCreateArgs(): string[] {
|
||||
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
||||
if (!createArgs) {
|
||||
throw new Error("expected docker create args");
|
||||
}
|
||||
return createArgs;
|
||||
}
|
||||
|
||||
function snapshotDockerCreateEnvEntries(args: string[]): string[] | undefined {
|
||||
const envFile = collectDockerFlagValues(args, "--env-file")[0];
|
||||
return envFile ? readFileSync(envFile, "utf8").split("\n").filter(Boolean) : undefined;
|
||||
}
|
||||
|
||||
function requireDockerCreateEnvEntries(): string[] {
|
||||
if (!capturedDockerCreateEnvEntries) {
|
||||
throw new Error("expected the docker create environment file to exist during create");
|
||||
}
|
||||
return capturedDockerCreateEnvEntries;
|
||||
}
|
||||
|
||||
function requireValue<T>(value: T | null | undefined, label: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function latestBridgeResolved(): Record<string, unknown> {
|
||||
const params = bridgeMocks.startBrowserBridgeServer.mock.calls.at(-1)?.[0];
|
||||
if (!params || typeof params !== "object") {
|
||||
throw new Error("expected browser bridge start params");
|
||||
}
|
||||
const resolved = params.resolved;
|
||||
if (!resolved || typeof resolved !== "object") {
|
||||
throw new Error("expected resolved browser bridge config");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
describe("ensureSandboxBrowser create args", () => {
|
||||
beforeAll(async () => {
|
||||
await loadFreshBrowserModulesForTest();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testWorkspaceDir = tempDirs.make("openclaw-browser-workspace-");
|
||||
vi.restoreAllMocks();
|
||||
BROWSER_BRIDGES.clear();
|
||||
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();
|
||||
runtimeMocks.log.mockClear();
|
||||
capturedDockerCreateEnvEntries = undefined;
|
||||
|
||||
dockerMocks.dockerContainerState.mockResolvedValue({ exists: false, running: false });
|
||||
dockerMocks.execDocker.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === "image" && args[1] === "inspect") {
|
||||
return { stdout: `${SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (args[0] === "create") {
|
||||
capturedDockerCreateEnvEntries = snapshotDockerCreateEnvEntries(args);
|
||||
}
|
||||
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: { listening: true } 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);
|
||||
});
|
||||
const harness = createSandboxBrowserTestHarness();
|
||||
const {
|
||||
dockerMocks,
|
||||
registryMocks,
|
||||
bridgeMocks,
|
||||
runtimeMocks,
|
||||
buildConfig,
|
||||
computeTestBrowserHash,
|
||||
ensureTestSandboxBrowser,
|
||||
requireDockerCreateArgs,
|
||||
snapshotDockerCreateEnvEntries,
|
||||
requireDockerCreateEnvEntries,
|
||||
requireValue,
|
||||
latestBridgeResolved,
|
||||
} = harness;
|
||||
|
||||
it("rejects stale sandbox browser images without the relay auth contract", async () => {
|
||||
dockerMocks.execDocker.mockImplementation(async (args: string[]) => {
|
||||
|
|
@ -313,8 +39,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
|
|
@ -344,8 +70,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
cfg.docker.env = { ...cfg.docker.env, BROWSER_TRANSPORT_SENTINEL: configuredSentinel };
|
||||
const result = await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
|
|
@ -382,8 +108,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
it("creates browser containers with Docker init and the shared args epoch", async () => {
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
|
|
@ -429,8 +155,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
const params = {
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
};
|
||||
await expect(
|
||||
|
|
@ -447,7 +173,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
it("recreates a cold browser container when the shared args epoch changes", async () => {
|
||||
const cfg = buildConfig(false);
|
||||
const oldHash = computeTestBrowserHash({
|
||||
const oldHash = await computeTestBrowserHash({
|
||||
cfg,
|
||||
createArgsEpoch: "pre-init",
|
||||
});
|
||||
|
|
@ -467,15 +193,15 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
BROWSER_BRIDGES.set("session:test", {
|
||||
harness.BROWSER_BRIDGES.set("session:test", {
|
||||
containerName: "openclaw-sbx-browser-session-test-0661d10a",
|
||||
bridge: { server: { listening: true } },
|
||||
});
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
|
|
@ -492,7 +218,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
it("keeps a hot pre-init browser running and emits the recreate hint", async () => {
|
||||
const cfg = buildConfig(false);
|
||||
const oldHash = computeTestBrowserHash({
|
||||
const oldHash = await computeTestBrowserHash({
|
||||
cfg,
|
||||
createArgsEpoch: "pre-init",
|
||||
});
|
||||
|
|
@ -515,8 +241,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
|
|
@ -533,8 +259,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
it("does not inject noVNC password env when noVNC is disabled", async () => {
|
||||
const result = await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
|
|
@ -545,42 +271,6 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
expect(result?.noVncUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips browser user binds that conflict with protected skill overlay container paths", async () => {
|
||||
// Protected skill overlays are authoritative; a browser bind targeting the same
|
||||
// container path is skipped so the read-only skill overlay wins and Docker does
|
||||
// not reject the container with a "Duplicate mount point" error.
|
||||
const workspaceDir = tempDirs.make("openclaw-browser-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-browser-mounts-");
|
||||
mkdirSync(path.join(workspaceDir, "skills", "demo"), { recursive: true });
|
||||
const cfg = buildConfig(false);
|
||||
cfg.workspaceAccess = "rw";
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
cfg.docker.dangerouslyAllowReservedContainerTargets = true;
|
||||
cfg.browser.binds = [`${customRoot}:/workspace/skills:rw`];
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
const bindArgs = collectDockerFlagValues(requireDockerCreateArgs(), "-v");
|
||||
const workspaceMountIdx = bindArgs.indexOf(`${workspaceDir}:/workspace:z`);
|
||||
const customMount = `${customRoot}:/workspace/skills:rw`;
|
||||
const protectedMount = `${path.join(workspaceDir, "skills")}:/workspace/skills:ro,z`;
|
||||
const protectedMountIdx = bindArgs.indexOf(protectedMount);
|
||||
|
||||
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
|
||||
// User bind is skipped because it conflicts with the protected skill overlay
|
||||
expect(bindArgs).not.toContain(customMount);
|
||||
// Protected skill overlay is present and appended after user binds
|
||||
expect(protectedMountIdx).toBeGreaterThan(workspaceMountIdx);
|
||||
expect(runtimeMocks.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`skipping user bind "${customMount}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"includes the explicit env policy epoch in the browser config hash with skill mount=%s",
|
||||
async (withSkillMount) => {
|
||||
|
|
@ -590,7 +280,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
GEMINI_API_KEY: "dummy-gemini",
|
||||
};
|
||||
const scopeKey = "session-1";
|
||||
const workspaceDir = testWorkspaceDir;
|
||||
const workspaceDir = harness.testWorkspaceDir;
|
||||
const agentWorkspaceDir = workspaceDir;
|
||||
if (withSkillMount) {
|
||||
mkdirSync(path.join(workspaceDir, "skills"));
|
||||
|
|
@ -601,13 +291,10 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: withSkillMount
|
||||
? [`${path.join(workspaceDir, "skills")}:/workspace/skills:ro`]
|
||||
: [],
|
||||
};
|
||||
const expectedHash = computeTestBrowserHash(hashInputs);
|
||||
const expectedHash = await computeTestBrowserHash(hashInputs);
|
||||
expect(expectedHash).not.toBe(
|
||||
computeTestBrowserHash({ ...hashInputs, dockerEnvPolicyEpoch: undefined }),
|
||||
await computeTestBrowserHash({ ...hashInputs, dockerEnvPolicyEpoch: undefined }),
|
||||
);
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
|
|
@ -643,8 +330,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
}),
|
||||
).rejects.toThrow("Docker daemon is not available");
|
||||
|
|
@ -655,8 +342,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
it("passes the browser SSRF policy to the sandbox bridge", async () => {
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
|
||||
});
|
||||
|
|
@ -709,7 +396,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
BROWSER_BRIDGES.set("session:test", {
|
||||
harness.BROWSER_BRIDGES.set("session:test", {
|
||||
bridge: existingBridge,
|
||||
containerName: "openclaw-sbx-browser-session-test-0661d10a",
|
||||
authToken: "test-bridge-token",
|
||||
|
|
@ -718,8 +405,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
ssrfPolicy: { allowedHostnames: ["example.com"] },
|
||||
});
|
||||
|
|
@ -772,7 +459,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
BROWSER_BRIDGES.set("session:test", {
|
||||
harness.BROWSER_BRIDGES.set("session:test", {
|
||||
bridge: existingBridge,
|
||||
containerName: "openclaw-sbx-browser-session-test-0661d10a",
|
||||
authToken: "test-bridge-token",
|
||||
|
|
@ -781,8 +468,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
evaluateEnabled: false,
|
||||
});
|
||||
|
|
@ -791,42 +478,6 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
expect(latestBridgeResolved().evaluateEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ workspaceAccess: "none", flags: "z", rejectedFlags: "ro,z" },
|
||||
{ workspaceAccess: "ro", flags: "ro,z", rejectedFlags: "z" },
|
||||
{ workspaceAccess: "rw", flags: "z", rejectedFlags: "ro,z" },
|
||||
] as const)(
|
||||
"uses the main workspace mount permissions for workspaceAccess=$workspaceAccess",
|
||||
async ({ workspaceAccess, flags, rejectedFlags }) => {
|
||||
const cfg = buildConfig(false);
|
||||
cfg.workspaceAccess = workspaceAccess;
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
const createArgs = requireDockerCreateArgs();
|
||||
expect(createArgs).toContain(`${testWorkspaceDir}:/workspace:${flags}`);
|
||||
expect(createArgs).not.toContain(`${testWorkspaceDir}:/workspace:${rejectedFlags}`);
|
||||
},
|
||||
);
|
||||
|
||||
it("stamps the mount format version label on browser containers", async () => {
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
||||
const labels = collectDockerFlagValues(createArgs ?? [], "--label");
|
||||
expect(labels).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
|
||||
});
|
||||
|
||||
it("force-removes the browser container when CDP never becomes reachable", async () => {
|
||||
// A browser container that starts but never exposes CDP is unusable; remove
|
||||
// it immediately so the next attempt recreates from a clean state.
|
||||
|
|
@ -852,8 +503,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow("hung container has been forcefully removed");
|
||||
|
|
@ -887,8 +538,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
|
|
@ -946,8 +597,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
try {
|
||||
const startup = ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
const startupResult = startup.then(
|
||||
|
|
@ -984,8 +635,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
it("requires auth for the sandbox CDP relay without auto-derived source ranges", async () => {
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
|
|
@ -1013,8 +664,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
|
|
@ -1028,8 +679,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
|
|
@ -1046,7 +697,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
containerName,
|
||||
bridge: { server: { listening: true } },
|
||||
};
|
||||
BROWSER_BRIDGES.set("session:test", cached);
|
||||
harness.BROWSER_BRIDGES.set("session:test", cached);
|
||||
dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true });
|
||||
dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null);
|
||||
bridgeMocks.stopBrowserBridgeServer.mockRejectedValueOnce(new Error("bridge cleanup failed"));
|
||||
|
|
@ -1054,21 +705,21 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
}),
|
||||
).rejects.toThrow("bridge cleanup failed");
|
||||
|
||||
expect(BROWSER_BRIDGES.get("session:test")).toBe(cached);
|
||||
expect(harness.BROWSER_BRIDGES.get("session:test")).toBe(cached);
|
||||
expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "rm")).toBeUndefined();
|
||||
|
||||
bridgeMocks.stopBrowserBridgeServer.mockClear();
|
||||
dockerMocks.execDocker.mockClear();
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
|
|
@ -1076,7 +727,7 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
expect(bridgeMocks.stopBrowserBridgeServer.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
dockerMocks.execDocker.mock.invocationCallOrder[rmCallIndex] ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
expect(BROWSER_BRIDGES.get("session:test")).not.toBe(cached);
|
||||
expect(harness.BROWSER_BRIDGES.get("session:test")).not.toBe(cached);
|
||||
});
|
||||
|
||||
it("rejects network=none before Docker inspection or browser bridge startup", async () => {
|
||||
|
|
@ -1086,8 +737,8 @@ describe("ensureSandboxBrowser create args", () => {
|
|||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: testWorkspaceDir,
|
||||
agentWorkspaceDir: testWorkspaceDir,
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
|
|
|
|||
154
src/agents/sandbox/browser.mounts.test.ts
Normal file
154
src/agents/sandbox/browser.mounts.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { mkdirSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSandboxBrowserTestHarness } from "./browser.create.test-helpers.js";
|
||||
import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
describe("ensureSandboxBrowser managed mounts", () => {
|
||||
const harness = createSandboxBrowserTestHarness();
|
||||
const {
|
||||
execContainer,
|
||||
resolveDockerSourceNamespace,
|
||||
dockerMocks,
|
||||
bridgeMocks,
|
||||
runtimeMocks,
|
||||
tempDirs,
|
||||
buildConfig,
|
||||
ensureTestSandboxBrowser,
|
||||
requireDockerCreateArgs,
|
||||
} = harness;
|
||||
|
||||
it.each(["none", "ro", "rw"] as const)(
|
||||
"uses daemon sources for browser mounts with %s access",
|
||||
async (access) => {
|
||||
const root = realpathSync(harness.testWorkspaceDir);
|
||||
for (const dir of ["private/skills", "agent/skills", "materialized/skills"]) {
|
||||
mkdirSync(path.join(root, dir), { recursive: true });
|
||||
}
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/browser-state", destination: root, writable: true },
|
||||
]);
|
||||
const cfg = buildConfig(false);
|
||||
cfg.workspaceAccess = access;
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: path.join(root, access === "rw" ? "agent" : "private"),
|
||||
agentWorkspaceDir: path.join(root, "agent"),
|
||||
skillsWorkspaceDir: path.join(root, "materialized"),
|
||||
cfg,
|
||||
});
|
||||
const binds = collectDockerFlagValues(requireDockerCreateArgs(), "-v");
|
||||
expect(binds).toContain(
|
||||
`/host/browser-state/${access === "rw" ? "agent" : "private"}:/workspace:${access === "ro" ? "ro,z" : "z"}`,
|
||||
);
|
||||
expect(binds.includes("/host/browser-state/agent:/agent:ro,z")).toBe(access === "ro");
|
||||
if (access === "rw") {
|
||||
expect(binds).toContain(
|
||||
"/host/browser-state/materialized/skills:/workspace/.openclaw/sandbox-skills/skills:ro,z",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a hot browser with stale sources without removing it or its bridge", async () => {
|
||||
const containerName = "openclaw-sbx-browser-session-test-0661d10a";
|
||||
const bridge = { containerName, bridge: { server: { listening: true } } };
|
||||
harness.BROWSER_BRIDGES.set("session:test", bridge);
|
||||
dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true });
|
||||
dockerMocks.readDockerContainerEnvVar.mockResolvedValue("existing-cdp-token");
|
||||
dockerMocks.readDockerContainerLabel.mockResolvedValue("pre-fix-hash");
|
||||
vi.mocked(execContainer).mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Mounts: [{ Type: "bind", Source: "/old/source", Destination: "/workspace", RW: true }],
|
||||
Tmpfs: null,
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
await expect(
|
||||
ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
}),
|
||||
).rejects.toThrow("openclaw sandbox recreate --browser --session session:test");
|
||||
expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "rm")).toBeUndefined();
|
||||
expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined();
|
||||
expect(bridgeMocks.stopBrowserBridgeServer).not.toHaveBeenCalled();
|
||||
expect(harness.BROWSER_BRIDGES.get("session:test")).toBe(bridge);
|
||||
});
|
||||
|
||||
it("skips browser user binds that conflict with protected skill overlay container paths", async () => {
|
||||
// Protected skill overlays are authoritative; a browser bind targeting the same
|
||||
// container path is skipped so the read-only skill overlay wins and Docker does
|
||||
// not reject the container with a "Duplicate mount point" error.
|
||||
const workspaceDir = tempDirs.make("openclaw-browser-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-browser-mounts-");
|
||||
mkdirSync(path.join(workspaceDir, "skills", "demo"), { recursive: true });
|
||||
const cfg = buildConfig(false);
|
||||
cfg.workspaceAccess = "rw";
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
cfg.docker.dangerouslyAllowReservedContainerTargets = true;
|
||||
cfg.browser.binds = [`${customRoot}:/workspace/skills:rw`];
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
const bindArgs = collectDockerFlagValues(requireDockerCreateArgs(), "-v");
|
||||
const workspaceMountIdx = bindArgs.indexOf(`${workspaceDir}:/workspace:z`);
|
||||
const customMount = `${customRoot}:/workspace/skills:rw`;
|
||||
const protectedMount = `${path.join(workspaceDir, "skills")}:/workspace/skills:ro,z`;
|
||||
const protectedMountIdx = bindArgs.indexOf(protectedMount);
|
||||
|
||||
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
|
||||
// User bind is skipped because it conflicts with the protected skill overlay
|
||||
expect(bindArgs).not.toContain(customMount);
|
||||
// Protected skill overlay is present and appended after user binds
|
||||
expect(protectedMountIdx).toBeGreaterThan(workspaceMountIdx);
|
||||
expect(runtimeMocks.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`skipping user bind "${customMount}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ workspaceAccess: "none", flags: "z", rejectedFlags: "ro,z" },
|
||||
{ workspaceAccess: "ro", flags: "ro,z", rejectedFlags: "z" },
|
||||
{ workspaceAccess: "rw", flags: "z", rejectedFlags: "ro,z" },
|
||||
] as const)(
|
||||
"uses the main workspace mount permissions for workspaceAccess=$workspaceAccess",
|
||||
async ({ workspaceAccess, flags, rejectedFlags }) => {
|
||||
const cfg = buildConfig(false);
|
||||
cfg.workspaceAccess = workspaceAccess;
|
||||
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
const createArgs = requireDockerCreateArgs();
|
||||
expect(createArgs).toContain(`${harness.testWorkspaceDir}:/workspace:${flags}`);
|
||||
expect(createArgs).not.toContain(`${harness.testWorkspaceDir}:/workspace:${rejectedFlags}`);
|
||||
},
|
||||
);
|
||||
|
||||
it("stamps the mount format version label on browser containers", async () => {
|
||||
await ensureTestSandboxBrowser({
|
||||
scopeKey: "session:test",
|
||||
workspaceDir: harness.testWorkspaceDir,
|
||||
agentWorkspaceDir: harness.testWorkspaceDir,
|
||||
cfg: buildConfig(false),
|
||||
});
|
||||
|
||||
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
|
||||
const labels = collectDockerFlagValues(createArgs ?? [], "--label");
|
||||
expect(labels).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -35,6 +35,8 @@ import {
|
|||
SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
|
||||
SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
} from "./constants.js";
|
||||
import { DOCKER_SANDBOX_ENGINE } from "./container-engine.js";
|
||||
import { handleHotSandboxConfigMismatch } from "./current-config.js";
|
||||
import {
|
||||
buildSandboxCreateArgs,
|
||||
dockerContainerState,
|
||||
|
|
@ -46,6 +48,7 @@ import {
|
|||
readDockerPort,
|
||||
resolveDockerEnvPolicyEpoch,
|
||||
} from "./docker.js";
|
||||
import { prepareSandboxMountPlan, sandboxMountPlanMatchesContainer } from "./mount-plan.js";
|
||||
import {
|
||||
buildNoVncObserverTokenUrl,
|
||||
consumeNoVncObserverToken,
|
||||
|
|
@ -55,19 +58,11 @@ import {
|
|||
issueNoVncObserverToken,
|
||||
} from "./novnc-auth.js";
|
||||
import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js";
|
||||
import { buildSandboxContainerName, resolveSandboxAgentId, slugifySessionKey } from "./shared.js";
|
||||
import { buildSandboxContainerName, 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 {
|
||||
appendReadOnlyWorkspaceSkillMountArgs,
|
||||
appendWorkspaceMountArgs,
|
||||
filterBindsConflictingWithProtectedMounts,
|
||||
formatReadOnlyWorkspaceSkillMountHashState,
|
||||
resolveReadOnlyWorkspaceSkillMounts,
|
||||
resolveProtectedSkillMountContainerPaths,
|
||||
SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
} from "./workspace-mounts.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
|
||||
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
|
||||
|
|
@ -280,12 +275,14 @@ async function ensureSandboxBrowserContainer(
|
|||
docker: params.cfg.docker,
|
||||
browser: { ...params.cfg.browser, image: browserImage },
|
||||
});
|
||||
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
|
||||
const mountPlan = await prepareSandboxMountPlan({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
binds: browserDockerCfg.binds,
|
||||
});
|
||||
const expectedHash = computeSandboxBrowserConfigHash({
|
||||
docker: browserDockerCfg,
|
||||
|
|
@ -305,9 +302,7 @@ async function ensureSandboxBrowserContainer(
|
|||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState(
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
),
|
||||
managedMounts: mountPlan.binds,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
|
@ -351,19 +346,18 @@ async function ensureSandboxBrowserContainer(
|
|||
const isHot =
|
||||
running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_BROWSER_WINDOW_MS);
|
||||
if (isHot) {
|
||||
const hint = (() => {
|
||||
if (params.cfg.scope === "session") {
|
||||
return `openclaw sandbox recreate --browser --session ${params.scopeKey}`;
|
||||
}
|
||||
if (params.cfg.scope === "agent") {
|
||||
const agentId = resolveSandboxAgentId(params.scopeKey) ?? "main";
|
||||
return `openclaw sandbox recreate --browser --agent ${agentId}`;
|
||||
}
|
||||
return "openclaw sandbox recreate --browser --all";
|
||||
})();
|
||||
defaultRuntime.log(
|
||||
`Sandbox browser config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
|
||||
);
|
||||
const mountsMatch = await sandboxMountPlanMatchesContainer({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
containerName,
|
||||
plan: mountPlan,
|
||||
});
|
||||
handleHotSandboxConfigMismatch({
|
||||
containerName,
|
||||
scope: params.cfg.scope,
|
||||
sessionKey: params.scopeKey,
|
||||
browser: true,
|
||||
mountsChanged: !mountsMatch,
|
||||
});
|
||||
} else {
|
||||
await stopExistingForContainer();
|
||||
await execDocker(["rm", "-f", containerName], { allowFailure: true });
|
||||
|
|
@ -394,39 +388,14 @@ async function ensureSandboxBrowserContainer(
|
|||
includeBinds: false,
|
||||
bindSourceRoots: [params.workspaceDir, params.agentWorkspaceDir],
|
||||
});
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
includeReadOnlyWorkspaceSkillMounts: false,
|
||||
});
|
||||
if (browserDockerCfg.binds?.length) {
|
||||
// Skip user binds that conflict with protected skill mount container paths so
|
||||
// the read-only skill overlay remains authoritative.
|
||||
const protectedPaths = resolveProtectedSkillMountContainerPaths(readOnlyWorkspaceSkillMounts);
|
||||
const safeBinds =
|
||||
protectedPaths.size > 0
|
||||
? filterBindsConflictingWithProtectedMounts(browserDockerCfg.binds, protectedPaths)
|
||||
: browserDockerCfg.binds;
|
||||
for (const bind of browserDockerCfg.binds) {
|
||||
if (!safeBinds.includes(bind)) {
|
||||
defaultRuntime.log(
|
||||
`sandbox browser: skipping user bind "${bind}" — container path conflicts with a protected read-only skill mount`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const bind of safeBinds) {
|
||||
args.push("-v", bind);
|
||||
}
|
||||
for (const bind of mountPlan.skippedBinds) {
|
||||
defaultRuntime.log(
|
||||
`sandbox browser: skipping user bind "${bind}" — container path conflicts with a protected read-only skill mount`,
|
||||
);
|
||||
}
|
||||
for (const bind of mountPlan.binds) {
|
||||
args.push("-v", bind);
|
||||
}
|
||||
appendReadOnlyWorkspaceSkillMountArgs({
|
||||
args,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
});
|
||||
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
|
||||
if (noVncEnabled) {
|
||||
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
|
||||
|
|
|
|||
|
|
@ -142,12 +142,12 @@ describe("computeSandboxConfigHash", () => {
|
|||
|
||||
const withoutSkills = computeSandboxConfigHash({
|
||||
...shared,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
managedMounts: [],
|
||||
});
|
||||
|
||||
const withSkills = computeSandboxConfigHash({
|
||||
...shared,
|
||||
readOnlyWorkspaceSkillMounts: ["/tmp/workspace/skills:/workspace/skills:ro"],
|
||||
managedMounts: ["/tmp/workspace/skills:/workspace/skills:ro"],
|
||||
});
|
||||
|
||||
expect(withoutSkills).not.toBe(withSkills);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ type SandboxHashInput = {
|
|||
agentWorkspaceDir: string;
|
||||
mountFormatVersion: number;
|
||||
createArgsEpoch: string;
|
||||
readOnlyWorkspaceSkillMounts?: readonly string[];
|
||||
managedMounts?: readonly string[];
|
||||
};
|
||||
|
||||
type SandboxBrowserHashInput = {
|
||||
|
|
@ -44,7 +44,7 @@ type SandboxBrowserHashInput = {
|
|||
agentWorkspaceDir: string;
|
||||
mountFormatVersion: number;
|
||||
createArgsEpoch: string;
|
||||
readOnlyWorkspaceSkillMounts?: readonly string[];
|
||||
managedMounts?: readonly string[];
|
||||
};
|
||||
|
||||
function normalizeForHash(value: unknown): unknown {
|
||||
|
|
|
|||
|
|
@ -4,24 +4,36 @@ import { defaultRuntime } from "../../runtime.js";
|
|||
import { resolveSandboxAgentId } from "./shared.js";
|
||||
import type { SandboxScope } from "./types.js";
|
||||
|
||||
function formatSandboxRecreateHint(params: { scope: SandboxScope; sessionKey: string }) {
|
||||
function formatSandboxRecreateHint(params: {
|
||||
scope: SandboxScope;
|
||||
sessionKey: string;
|
||||
browser?: boolean;
|
||||
}) {
|
||||
const command = `openclaw sandbox recreate${params.browser ? " --browser" : ""}`;
|
||||
if (params.scope === "session") {
|
||||
return formatCliCommand(`openclaw sandbox recreate --session ${params.sessionKey}`);
|
||||
return formatCliCommand(`${command} --session ${params.sessionKey}`);
|
||||
}
|
||||
if (params.scope === "agent") {
|
||||
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
|
||||
return formatCliCommand(`openclaw sandbox recreate --agent ${agentId}`);
|
||||
return formatCliCommand(`${command} --agent ${agentId}`);
|
||||
}
|
||||
return formatCliCommand("openclaw sandbox recreate --all");
|
||||
return formatCliCommand(`${command} --all`);
|
||||
}
|
||||
|
||||
export function handleHotSandboxConfigMismatch(params: {
|
||||
containerName: string;
|
||||
requireCurrentConfig?: boolean;
|
||||
mountsChanged?: boolean;
|
||||
browser?: boolean;
|
||||
scope: SandboxScope;
|
||||
sessionKey: string;
|
||||
}) {
|
||||
const hint = formatSandboxRecreateHint(params);
|
||||
if (params.mountsChanged) {
|
||||
throw new Error(
|
||||
`Sandbox mounts changed for ${params.containerName}; the running container was preserved but cannot be reused with different workspace or skill sources. Recreate first: ${hint}`,
|
||||
);
|
||||
}
|
||||
if (params.requireCurrentConfig) {
|
||||
throw new Error(
|
||||
`Sandbox config changed for ${params.containerName}; restricted dispatch requires the current container config. Recreate first: ${hint}`,
|
||||
|
|
|
|||
280
src/agents/sandbox/docker-mount-source.test.ts
Normal file
280
src/agents/sandbox/docker-mount-source.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DOCKER_SANDBOX_ENGINE, execContainer, PODMAN_SANDBOX_ENGINE } from "./container-engine.js";
|
||||
import {
|
||||
parseInspectedSandboxMounts,
|
||||
resolveDockerSourceNamespace,
|
||||
translateSandboxMountSources,
|
||||
} from "./docker-mount-source.js";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
default: {
|
||||
readFileSync: vi.fn(),
|
||||
readlinkSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
realpathSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("node:os", () => ({ default: { hostname: vi.fn() } }));
|
||||
vi.mock("./container-engine.js", () => ({
|
||||
DOCKER_SANDBOX_ENGINE: { id: "docker", command: "docker", displayName: "Docker" },
|
||||
PODMAN_SANDBOX_ENGINE: { id: "podman", command: "podman", displayName: "Podman" },
|
||||
execContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
const id = "a".repeat(64);
|
||||
const identity = JSON.stringify(["test-boot", "mnt:[123]"]);
|
||||
const wireMount = {
|
||||
Type: "bind",
|
||||
Source: "/host/project",
|
||||
Destination: "/gateway/workspace",
|
||||
RW: true,
|
||||
};
|
||||
let target = 0;
|
||||
|
||||
function translateSandboxMountSource(
|
||||
params: Omit<
|
||||
Parameters<typeof translateSandboxMountSources>[0],
|
||||
"containerPath" | "shadowedTargets"
|
||||
>,
|
||||
): string {
|
||||
return translateSandboxMountSources({
|
||||
...params,
|
||||
containerPath: "/workspace",
|
||||
shadowedTargets: [],
|
||||
})[0]!.hostPath;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubGlobal("process", { ...process, platform: "linux" });
|
||||
vi.stubEnv("DOCKER_CONTEXT", `test-${++target}`);
|
||||
vi.mocked(os.hostname).mockReturnValue("gateway-hostname");
|
||||
vi.mocked(fs.existsSync).mockImplementation((file) => file === "/.dockerenv");
|
||||
vi.mocked(fs.readlinkSync).mockReturnValue("mnt:[123]");
|
||||
vi.mocked(fs.readFileSync).mockImplementation((file) =>
|
||||
file === "/proc/sys/kernel/random/boot_id" ? "test-boot\n" : "0::/\n",
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((file) => String(file));
|
||||
vi.mocked(execContainer).mockImplementation(async (_engine, args) => ({
|
||||
stdout: args[0] === "inspect" ? JSON.stringify({ Id: id, Mounts: [wireMount] }) : identity,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
}));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("Docker source namespace", () => {
|
||||
it("proves a custom hostname on private cgroup v2 before accepting mounts, and shares the snapshot", async () => {
|
||||
const [first, second] = await Promise.all([
|
||||
resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE),
|
||||
resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE),
|
||||
]);
|
||||
expect(first).toEqual(parseInspectedSandboxMounts([wireMount]));
|
||||
expect(second).toBe(first);
|
||||
expect(execContainer).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(execContainer).mock.calls[0]?.[1]).toEqual([
|
||||
"inspect",
|
||||
"--type",
|
||||
"container",
|
||||
"--format",
|
||||
'{"Id":{{json .ID}},"Mounts":{{json .Mounts}},"Tmpfs":{{json .HostConfig.Tmpfs}}}',
|
||||
"gateway-hostname",
|
||||
]);
|
||||
expect(vi.mocked(execContainer).mock.calls[1]?.[1].slice(0, 4)).toEqual([
|
||||
"exec",
|
||||
id,
|
||||
process.execPath,
|
||||
"-e",
|
||||
]);
|
||||
expect(vi.mocked(execContainer).mock.calls[0]?.[2]?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("uses a mountinfo container ID when the hostname is not a Docker name", async () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation((file) =>
|
||||
file === "/proc/self/mountinfo"
|
||||
? `1 2 0:1 /var/lib/docker/containers/${id}/hostname /etc/hostname rw - ext4 /dev/test rw`
|
||||
: file === "/proc/sys/kernel/random/boot_id"
|
||||
? "test-boot"
|
||||
: "0::/\n",
|
||||
);
|
||||
vi.mocked(execContainer).mockRejectedValueOnce(new Error("No such container"));
|
||||
await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toHaveLength(1);
|
||||
expect(vi.mocked(execContainer).mock.calls[1]?.[1].at(-1)).toBe(id);
|
||||
});
|
||||
|
||||
it.each(["/gateway/workspace", "/gateway/workspace/data"])(
|
||||
"rejects Docker --tmpfs at %s even when .Mounts only reports its bind ancestor",
|
||||
async (destination) => {
|
||||
vi.mocked(execContainer).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
Id: id,
|
||||
Mounts: [wireMount],
|
||||
Tmpfs: { [destination]: "rw,size=1m" },
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
const mounts = await resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE);
|
||||
expect(() =>
|
||||
translateSandboxMountSource({
|
||||
source: "/gateway/workspace",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts: mounts!,
|
||||
readOnly: false,
|
||||
}),
|
||||
).toThrow(/unsupported (?:nested )?tmpfs mount/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["wrong-boot", "wrong-namespace", "invalid-id", "missing-mounts", "unavailable"])(
|
||||
"rejects %s without a wrong-path fallback and retries after recovery",
|
||||
async (failure) => {
|
||||
if (failure === "unavailable") {
|
||||
vi.mocked(execContainer).mockRejectedValueOnce(new Error("daemon unavailable"));
|
||||
} else if (failure === "invalid-id" || failure === "missing-mounts") {
|
||||
vi.mocked(execContainer).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
Id: failure === "invalid-id" ? "short-id" : id,
|
||||
Mounts: failure === "missing-mounts" ? null : [wireMount],
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
} else {
|
||||
vi.mocked(execContainer)
|
||||
.mockImplementationOnce(async () => ({
|
||||
stdout: JSON.stringify({ Id: id, Mounts: [wireMount] }),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
}))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(
|
||||
failure === "wrong-boot" ? ["other-boot", "mnt:[123]"] : ["test-boot", "mnt:[456]"],
|
||||
),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).rejects.toThrow(
|
||||
"Connect Docker to the daemon that runs the Gateway",
|
||||
);
|
||||
await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves native Docker and Podman sources unchanged without self-inspection", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`1 2 0:1 / /var/lib/docker/overlay2/${id}/merged rw - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/${id}`,
|
||||
);
|
||||
await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toBeUndefined();
|
||||
await expect(resolveDockerSourceNamespace(PODMAN_SANDBOX_ENGINE)).resolves.toBeUndefined();
|
||||
expect(execContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed source translation", () => {
|
||||
it("uses the longest segment prefix and preserves spaces", () => {
|
||||
const mounts = parseInspectedSandboxMounts([
|
||||
wireMount,
|
||||
{ ...wireMount, Source: "/host/special skills", Destination: "/gateway/workspace/skills" },
|
||||
]);
|
||||
expect(
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/gateway/workspace/skills/demo",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts,
|
||||
}),
|
||||
).toBe("/host/special skills/demo");
|
||||
expect(() =>
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/gateway/workspace-other",
|
||||
allowedRoots: ["/gateway/workspace-other"],
|
||||
mounts,
|
||||
}),
|
||||
).toThrow("not backed by a Gateway bind mount");
|
||||
});
|
||||
|
||||
it.each(["volume", "tmpfs"])("does not reinterpret %s storage as a host bind", (type) => {
|
||||
const mounts = parseInspectedSandboxMounts([
|
||||
{ ...wireMount, Type: type, Source: "/var/lib/docker/private" },
|
||||
]);
|
||||
expect(() =>
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/gateway/workspace",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts,
|
||||
}),
|
||||
).toThrow(`unsupported ${type} mount`);
|
||||
});
|
||||
|
||||
it("rejects relative daemon sources", () => {
|
||||
expect(() => parseInspectedSandboxMounts([{ ...wireMount, Source: "relative" }])).toThrow(
|
||||
"invalid mount entry",
|
||||
);
|
||||
expect(() => parseInspectedSandboxMounts([wireMount], { relative: "rw" })).toThrow(
|
||||
"invalid tmpfs destination",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a root destination without losing the separator", () => {
|
||||
expect(
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/workspace",
|
||||
allowedRoots: ["/workspace"],
|
||||
mounts: parseInspectedSandboxMounts([{ ...wireMount, Destination: "/" }]),
|
||||
}),
|
||||
).toBe("/host/project/workspace");
|
||||
});
|
||||
|
||||
it("preserves Windows drive sources in container inspection", () => {
|
||||
expect(
|
||||
parseInspectedSandboxMounts([{ ...wireMount, Source: "c:\\Users\\Example\\project" }])[0]
|
||||
?.source,
|
||||
).toBe("C:/Users/Example/project");
|
||||
});
|
||||
|
||||
it("does not grant writes through a read-only Gateway bind", () => {
|
||||
const params = {
|
||||
source: "/gateway/workspace",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts: parseInspectedSandboxMounts([{ ...wireMount, RW: false }]),
|
||||
};
|
||||
expect(() => translateSandboxMountSource({ ...params, readOnly: false })).toThrow(
|
||||
"Use workspaceAccess=ro",
|
||||
);
|
||||
expect(translateSandboxMountSource({ ...params, readOnly: true })).toBe("/host/project");
|
||||
});
|
||||
|
||||
it("checks symlink containment locally and never resolves a daemon source locally", () => {
|
||||
vi.mocked(fs.realpathSync).mockImplementation((file) =>
|
||||
file === "/gateway/workspace/escape" ? "/etc" : String(file),
|
||||
);
|
||||
expect(() =>
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/gateway/workspace/escape",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts: parseInspectedSandboxMounts([wireMount]),
|
||||
}),
|
||||
).toThrow("escapes its Gateway workspace roots");
|
||||
translateSandboxMountSource({
|
||||
readOnly: false,
|
||||
source: "/gateway/workspace",
|
||||
allowedRoots: ["/gateway/workspace"],
|
||||
mounts: parseInspectedSandboxMounts([wireMount]),
|
||||
});
|
||||
expect(
|
||||
vi.mocked(fs.realpathSync).mock.calls.some(([file]) => String(file).startsWith("/host/")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
261
src/agents/sandbox/docker-mount-source.ts
Normal file
261
src/agents/sandbox/docker-mount-source.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { execContainer, type SandboxContainerEngine } from "./container-engine.js";
|
||||
import { isSandboxHostPathAbsolute, normalizeSandboxHostPath } from "./host-paths.js";
|
||||
import { normalizeMountContainerPath, type ManagedWorkspaceMount } from "./workspace-mounts.js";
|
||||
|
||||
export type InspectedSandboxMount = {
|
||||
type: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
writable: boolean;
|
||||
};
|
||||
|
||||
const SELF_INSPECT_TIMEOUT_MS = 5_000;
|
||||
const MAX_SELF_CANDIDATES = 8;
|
||||
const SELF_IDENTITY_PROBE =
|
||||
'const fs=require("node:fs");process.stdout.write(JSON.stringify([fs.readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim(),fs.readlinkSync("/proc/self/ns/mnt")]));';
|
||||
let sourceNamespace:
|
||||
| { key: string; mounts: Promise<readonly InspectedSandboxMount[] | undefined> }
|
||||
| undefined;
|
||||
|
||||
export function parseInspectedSandboxMounts(
|
||||
value: unknown,
|
||||
tmpfs?: unknown,
|
||||
): InspectedSandboxMount[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("Container inspect did not return a mount table.");
|
||||
}
|
||||
const mounts = value.map((mount: unknown) => {
|
||||
if (
|
||||
!isRecord(mount) ||
|
||||
typeof mount.Type !== "string" ||
|
||||
typeof mount.Destination !== "string" ||
|
||||
!path.posix.isAbsolute(mount.Destination) ||
|
||||
typeof mount.RW !== "boolean" ||
|
||||
(mount.Type === "bind" &&
|
||||
(typeof mount.Source !== "string" || !isSandboxHostPathAbsolute(mount.Source)))
|
||||
) {
|
||||
throw new Error("Container inspect returned an invalid mount entry.");
|
||||
}
|
||||
return {
|
||||
type: mount.Type,
|
||||
source: typeof mount.Source === "string" ? normalizeSandboxHostPath(mount.Source) : "",
|
||||
destination: normalizeMountContainerPath(mount.Destination),
|
||||
writable: mount.RW,
|
||||
};
|
||||
});
|
||||
if (tmpfs != null && !isRecord(tmpfs)) {
|
||||
throw new Error("Container inspect returned invalid tmpfs destinations.");
|
||||
}
|
||||
// Docker --tmpfs is stored separately and hides any bind at the same target.
|
||||
// Include it so neither source translation nor retained-container checks see
|
||||
// the obscured host files instead of the Gateway's effective filesystem.
|
||||
for (const destination of Object.keys(tmpfs ?? {})) {
|
||||
if (!path.posix.isAbsolute(destination)) {
|
||||
throw new Error("Container inspect returned an invalid tmpfs destination.");
|
||||
}
|
||||
mounts.push({
|
||||
type: "tmpfs",
|
||||
source: "",
|
||||
destination: normalizeMountContainerPath(destination),
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
return [...new Map(mounts.map((mount) => [mount.destination, mount])).values()];
|
||||
}
|
||||
|
||||
function readOptionalProcFile(file: string): string {
|
||||
try {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSourceNamespace(
|
||||
engine: SandboxContainerEngine,
|
||||
): Promise<readonly InspectedSandboxMount[] | undefined> {
|
||||
if (engine.id !== "docker" || process.platform !== "linux") {
|
||||
return undefined;
|
||||
}
|
||||
const cgroup = readOptionalProcFile("/proc/self/cgroup");
|
||||
const ids: string[] = [];
|
||||
for (const match of cgroup.matchAll(/(?:\/docker\/|docker-)([a-f0-9]{64})(?:\/|\.scope|$)/gm)) {
|
||||
const id = match[1];
|
||||
if (id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
// Host mount tables also contain other containers' IDs. Only Docker's mounts
|
||||
// of this process's own hostname/resolver files identify a self candidate.
|
||||
for (const line of readOptionalProcFile("/proc/self/mountinfo").split("\n")) {
|
||||
const fields = line.split(" ");
|
||||
if (!/^\/etc\/(?:hosts|hostname|resolv\.conf)$/.test(fields[4] ?? "")) {
|
||||
continue;
|
||||
}
|
||||
const id = fields[3]?.match(
|
||||
/\/containers\/([a-f0-9]{64})\/(?:hosts|hostname|resolv\.conf)$/,
|
||||
)?.[1];
|
||||
if (id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync("/.dockerenv") && !fs.existsSync("/run/.containerenv") && ids.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = [...new Set([os.hostname(), ...ids])].slice(0, MAX_SELF_CANDIDATES);
|
||||
const signal = AbortSignal.timeout(SELF_INSPECT_TIMEOUT_MS);
|
||||
let lastError: unknown;
|
||||
try {
|
||||
const identity = JSON.stringify([
|
||||
fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
|
||||
fs.readlinkSync("/proc/self/ns/mnt"),
|
||||
]);
|
||||
// A hostname or cgroup ID is only a candidate. Prove the selected daemon's
|
||||
// container shares this process's kernel and mount namespace before trusting
|
||||
// its host paths; private cgroup-v2 namespaces often contain no container ID.
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await execContainer(
|
||||
engine,
|
||||
[
|
||||
"inspect",
|
||||
"--type",
|
||||
"container",
|
||||
"--format",
|
||||
// Docker templates use the Go field ID. The JSON spelling Id forces
|
||||
// raw-map fallback, where an omitted Tmpfs field fails inspection.
|
||||
'{"Id":{{json .ID}},"Mounts":{{json .Mounts}},"Tmpfs":{{json .HostConfig.Tmpfs}}}',
|
||||
candidate,
|
||||
],
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const container: unknown = JSON.parse(result.stdout);
|
||||
if (
|
||||
!isRecord(container) ||
|
||||
typeof container.Id !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(container.Id)
|
||||
) {
|
||||
throw new Error("Container inspect did not return a full container ID.");
|
||||
}
|
||||
const probe = await execContainer(
|
||||
engine,
|
||||
["exec", container.Id, process.execPath, "-e", SELF_IDENTITY_PROBE],
|
||||
{ signal },
|
||||
);
|
||||
if (probe.stdout !== identity) {
|
||||
throw new Error("The selected Docker daemon did not identify this Gateway container.");
|
||||
}
|
||||
return parseInspectedSandboxMounts(container.Mounts, container.Tmpfs);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
throw new Error(
|
||||
"Cannot resolve sandbox bind sources from this Gateway container. Connect Docker to the daemon that runs the Gateway and make its container inspectable, then restart the Gateway. A hostname alone cannot establish container identity.",
|
||||
{ cause: lastError },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveDockerSourceNamespace(
|
||||
engine: SandboxContainerEngine,
|
||||
): Promise<readonly InspectedSandboxMount[] | undefined> {
|
||||
if (engine.id !== "docker" || process.platform !== "linux") {
|
||||
return undefined;
|
||||
}
|
||||
const key = JSON.stringify([engine, process.env.DOCKER_HOST, process.env.DOCKER_CONTEXT]);
|
||||
if (sourceNamespace?.key === key) {
|
||||
return await sourceNamespace.mounts;
|
||||
}
|
||||
const pending = { key, mounts: discoverSourceNamespace(engine) };
|
||||
sourceNamespace = pending;
|
||||
try {
|
||||
return await pending.mounts;
|
||||
} catch (error) {
|
||||
// A temporary daemon failure must not become a permanent identity mapping.
|
||||
if (sourceNamespace === pending) {
|
||||
sourceNamespace = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function translateSandboxMountSources(params: {
|
||||
source: string;
|
||||
containerPath: string;
|
||||
allowedRoots: readonly string[];
|
||||
mounts: readonly InspectedSandboxMount[];
|
||||
readOnly: boolean;
|
||||
shadowedTargets: readonly string[];
|
||||
}): ManagedWorkspaceMount[] {
|
||||
const source = fs.realpathSync(params.source);
|
||||
if (!params.allowedRoots.some((root) => isPathInside(fs.realpathSync(root), source))) {
|
||||
throw new Error(`Sandbox mount source ${params.source} escapes its Gateway workspace roots.`);
|
||||
}
|
||||
const mount = params.mounts
|
||||
.filter((entry) => isPathInside(entry.destination, source))
|
||||
.toSorted((a, b) => b.destination.length - a.destination.length)[0];
|
||||
if (!mount || mount.type !== "bind") {
|
||||
throw new Error(
|
||||
`Sandbox mount source ${params.source} ${mount ? `uses an unsupported ${mount.type} mount` : "is not backed by a Gateway bind mount"}. Bind-mount the workspace and OpenClaw state directories from the Docker host into the Gateway, then restart the Gateway.`,
|
||||
);
|
||||
}
|
||||
if (!mount.writable && !params.readOnly) {
|
||||
throw new Error(
|
||||
`Sandbox mount source ${params.source} is read-only in the Gateway. Use workspaceAccess=ro or make the Gateway bind writable before requesting a writable sandbox.`,
|
||||
);
|
||||
}
|
||||
const containerPath = normalizeMountContainerPath(params.containerPath);
|
||||
const translated = [
|
||||
{
|
||||
// Only the Gateway namespace is locally resolvable. Inspect's Source is
|
||||
// authoritative; local realpath/stat would check an unrelated host path.
|
||||
hostPath: path.posix.join(mount.source, path.posix.relative(mount.destination, source)),
|
||||
containerPath,
|
||||
readOnly: params.readOnly,
|
||||
},
|
||||
];
|
||||
// Gateway-only submounts do not propagate through a sibling's host bind.
|
||||
// Project visible children too, except where another final mount takes over.
|
||||
for (const child of params.mounts.toSorted((a, b) =>
|
||||
a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0,
|
||||
)) {
|
||||
if (child.destination === source || !isPathInside(source, child.destination)) {
|
||||
continue;
|
||||
}
|
||||
const target = normalizeMountContainerPath(
|
||||
path.posix.join(containerPath, path.posix.relative(source, child.destination)),
|
||||
);
|
||||
if (params.shadowedTargets.some((shadow) => isPathInside(shadow, target))) {
|
||||
continue;
|
||||
}
|
||||
if (child.type !== "bind") {
|
||||
throw new Error(
|
||||
`Sandbox mount source ${child.destination} uses an unsupported nested ${child.type} mount. Use Gateway bind mounts for this subtree or replace its sandbox destination with an explicit Docker bind, then restart the Gateway.`,
|
||||
);
|
||||
}
|
||||
if (!isPathInside(source, fs.realpathSync(child.destination))) {
|
||||
throw new Error(
|
||||
`Sandbox mount source ${child.destination} escapes its Gateway workspace root.`,
|
||||
);
|
||||
}
|
||||
translated.push({
|
||||
hostPath: child.source,
|
||||
containerPath: target,
|
||||
readOnly: params.readOnly || !child.writable,
|
||||
});
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
|
@ -1,284 +1,25 @@
|
|||
// Docker sandbox recreation tests cover config-hash labels, bind ordering, and
|
||||
// mount labels used to decide when shared containers must be rebuilt.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
computeSandboxConfigHash,
|
||||
SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
|
||||
} from "./config-hash.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js";
|
||||
import { SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js";
|
||||
import { createSandboxContainerTestHarness } from "./docker.create.test-helpers.js";
|
||||
import { collectDockerFlagValues } from "./test-args.js";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
globalArgs: string[];
|
||||
envFileContents?: string;
|
||||
};
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
calls: [] as SpawnCall[],
|
||||
containerExists: true,
|
||||
inspectRunning: true,
|
||||
inspectError: "",
|
||||
labelHash: "",
|
||||
podmanInfo: "true\tfalse\t\t5.0.0\n",
|
||||
podmanConnections: "[]\n",
|
||||
podmanMachines: "[]\n",
|
||||
}));
|
||||
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
readRegistryEntry: vi.fn(),
|
||||
removeRegistryEntry: vi.fn(),
|
||||
updateRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
}));
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function usePodmanMachine() {
|
||||
spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n";
|
||||
spawnState.podmanConnections = JSON.stringify([
|
||||
{
|
||||
Name: "podman-machine-default",
|
||||
URI: "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock",
|
||||
Identity: "/tmp/podman-machine-default",
|
||||
Default: true,
|
||||
},
|
||||
]);
|
||||
spawnState.podmanMachines = JSON.stringify([
|
||||
{
|
||||
Name: "podman-machine-default",
|
||||
Running: true,
|
||||
IdentityPath: "/tmp/podman-machine-default",
|
||||
Port: 60000,
|
||||
RemoteUsername: "core",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
vi.mock("./registry.js", () => ({
|
||||
readRegistryEntry: registryMocks.readRegistryEntry,
|
||||
removeRegistryEntry: registryMocks.removeRegistryEntry,
|
||||
updateRegistry: registryMocks.updateRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: runtimeMocks,
|
||||
}));
|
||||
|
||||
async function spawnDockerProcess(commandAndArgs: string[]) {
|
||||
const [command = "", ...rawArgs] = commandAndArgs;
|
||||
const globalArgs: string[] = [];
|
||||
let args = rawArgs;
|
||||
if (command === "podman") {
|
||||
while (args[0] === "--url" || args[0] === "--identity") {
|
||||
globalArgs.push(...args.slice(0, 2));
|
||||
args = args.slice(2);
|
||||
}
|
||||
}
|
||||
// The tests assert docker CLI arguments without requiring Docker; this mock
|
||||
// implements only the inspect/create/start/rm calls used by ensureSandboxContainer.
|
||||
const envFileIndex = args.indexOf("--env-file");
|
||||
const envFile = envFileIndex === -1 ? undefined : args[envFileIndex + 1];
|
||||
const call: SpawnCall = { command, args, globalArgs };
|
||||
if (args[0] === "create" && envFile) {
|
||||
call.envFileContents = fs.readFileSync(envFile, "utf8");
|
||||
}
|
||||
spawnState.calls.push(call);
|
||||
|
||||
let code = 0;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (command !== "docker" && command !== "podman") {
|
||||
code = 1;
|
||||
stderr = `unexpected command: ${command}`;
|
||||
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
|
||||
if (spawnState.inspectError) {
|
||||
code = 125;
|
||||
stderr = spawnState.inspectError;
|
||||
} else if (!spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "No such object";
|
||||
} else {
|
||||
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
|
||||
}
|
||||
} else if (
|
||||
args[0] === "inspect" &&
|
||||
args[1] === "-f" &&
|
||||
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
|
||||
) {
|
||||
if (!spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "No such object";
|
||||
} else {
|
||||
stdout = `${spawnState.labelHash}\n`;
|
||||
}
|
||||
} else if (command === "podman" && args[0] === "info") {
|
||||
stdout = spawnState.podmanInfo;
|
||||
} else if (command === "podman" && args[0] === "system") {
|
||||
stdout = spawnState.podmanConnections;
|
||||
} else if (command === "podman" && args[0] === "machine") {
|
||||
stdout = spawnState.podmanMachines;
|
||||
} else if (args[0] === "rm" && args[1] === "-f") {
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
} else if (args[0] === "image" && args[1] === "inspect") {
|
||||
code = 0;
|
||||
} else if (args[0] === "create") {
|
||||
if (spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "container name is already in use";
|
||||
} else {
|
||||
spawnState.containerExists = true;
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash =
|
||||
args
|
||||
.find((arg) => arg.startsWith("openclaw.configHash="))
|
||||
?.slice("openclaw.configHash=".length) ?? "";
|
||||
}
|
||||
} else if (args[0] === "start") {
|
||||
spawnState.inspectRunning = true;
|
||||
} else if (args[0] === "exec") {
|
||||
code = 0;
|
||||
} else {
|
||||
code = 1;
|
||||
stderr = `unexpected docker args: ${args.join(" ")}`;
|
||||
}
|
||||
return {
|
||||
failed: code !== 0,
|
||||
isCanceled: false,
|
||||
exitCode: code,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../process/exec.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../process/exec.js")>()),
|
||||
spawnCommand: spawnDockerProcess,
|
||||
}));
|
||||
|
||||
let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer;
|
||||
let resolveDockerEnvPolicyEpoch: typeof import("./docker.js").resolveDockerEnvPolicyEpoch;
|
||||
let PODMAN_SANDBOX_ENGINE: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./registry.js", () => ({
|
||||
readRegistryEntry: registryMocks.readRegistryEntry,
|
||||
removeRegistryEntry: registryMocks.removeRegistryEntry,
|
||||
updateRegistry: registryMocks.updateRegistry,
|
||||
}));
|
||||
vi.doMock("../../process/exec.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../process/exec.js")>()),
|
||||
spawnCommand: spawnDockerProcess,
|
||||
}));
|
||||
({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch, PODMAN_SANDBOX_ENGINE } =
|
||||
await import("./docker.js"));
|
||||
});
|
||||
|
||||
function createSandboxConfig(
|
||||
dns: string[],
|
||||
binds?: string[],
|
||||
workspaceAccess: "rw" | "ro" | "none" = "rw",
|
||||
env: Record<string, string> = { LANG: "C.UTF-8" },
|
||||
): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "shared",
|
||||
workspaceAccess,
|
||||
workspaceRoot: "~/.openclaw/sandboxes",
|
||||
dockerTmpfsSource: "default",
|
||||
docker: {
|
||||
image: "openclaw-sandbox:test",
|
||||
containerPrefix: "oc-test-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
||||
network: "none",
|
||||
capDrop: ["ALL"],
|
||||
env,
|
||||
dns,
|
||||
extraHosts: ["host.docker.internal:host-gateway"],
|
||||
binds: binds ?? ["/tmp/workspace:/workspace:rw"],
|
||||
dangerouslyAllowReservedContainerTargets: true,
|
||||
},
|
||||
ssh: {
|
||||
command: "ssh",
|
||||
workspaceRoot: "/tmp/openclaw-sandboxes",
|
||||
strictHostKeyChecking: true,
|
||||
updateHostKeys: true,
|
||||
},
|
||||
browser: {
|
||||
enabled: false,
|
||||
image: "openclaw-browser:test",
|
||||
containerPrefix: "oc-browser-",
|
||||
network: "openclaw-sandbox-browser",
|
||||
cdpPort: 9222,
|
||||
vncPort: 5900,
|
||||
noVncPort: 6080,
|
||||
headless: true,
|
||||
noVncEnabled: false,
|
||||
allowHostControl: false,
|
||||
autoStart: false,
|
||||
autoStartTimeoutMs: 5000,
|
||||
},
|
||||
tools: { allow: [], deny: [] },
|
||||
prune: { idleHours: 24, maxAgeDays: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSandboxCreateCallForTest(params: {
|
||||
cfg: SandboxConfig;
|
||||
workspaceDir?: string;
|
||||
scopeKey?: string;
|
||||
engine?: import("./docker.js").SandboxContainerEngine;
|
||||
}): Promise<SpawnCall> {
|
||||
const workspaceDir = params.workspaceDir ?? "/tmp/workspace";
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: params.scopeKey ?? "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: params.cfg,
|
||||
...(params.engine ? { engine: params.engine } : {}),
|
||||
});
|
||||
|
||||
const createCall = spawnState.calls.find(
|
||||
(call) => call.command === (params.engine?.command ?? "docker") && call.args[0] === "create",
|
||||
);
|
||||
if (!createCall) {
|
||||
throw new Error(`expected ${params.engine?.command ?? "docker"} create call`);
|
||||
}
|
||||
return createCall;
|
||||
}
|
||||
|
||||
describe("ensureSandboxContainer config-hash recreation", () => {
|
||||
beforeEach(() => {
|
||||
spawnState.calls.length = 0;
|
||||
spawnState.containerExists = true;
|
||||
spawnState.inspectRunning = true;
|
||||
spawnState.inspectError = "";
|
||||
spawnState.labelHash = "";
|
||||
spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n";
|
||||
spawnState.podmanConnections = "[]\n";
|
||||
spawnState.podmanMachines = "[]\n";
|
||||
registryMocks.readRegistryEntry.mockClear();
|
||||
registryMocks.removeRegistryEntry.mockClear();
|
||||
registryMocks.removeRegistryEntry.mockResolvedValue(undefined);
|
||||
registryMocks.updateRegistry.mockClear();
|
||||
registryMocks.updateRegistry.mockResolvedValue(undefined);
|
||||
runtimeMocks.log.mockClear();
|
||||
});
|
||||
const harness = createSandboxContainerTestHarness();
|
||||
const {
|
||||
spawnState,
|
||||
registryMocks,
|
||||
runtimeMocks,
|
||||
tempDirs,
|
||||
usePodmanMachine,
|
||||
createSandboxConfig,
|
||||
computeTestSandboxHash,
|
||||
ensureSandboxCreateCallForTest,
|
||||
} = harness;
|
||||
|
||||
it("serializes concurrent provisioning for one container", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
|
|
@ -294,8 +35,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
cfg,
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
ensureSandboxContainer(params),
|
||||
ensureSandboxContainer(params),
|
||||
harness.ensureSandboxContainer(params),
|
||||
harness.ensureSandboxContainer(params),
|
||||
]);
|
||||
|
||||
expect(first).toBe("oc-test-shared");
|
||||
|
|
@ -340,7 +81,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
|
||||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
...(backend === "podman" ? { engine: PODMAN_SANDBOX_ENGINE } : {}),
|
||||
...(backend === "podman" ? { engine: harness.PODMAN_SANDBOX_ENGINE } : {}),
|
||||
});
|
||||
|
||||
expect(createCall.args.join(" ")).not.toContain(sentinel);
|
||||
|
|
@ -359,23 +100,21 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"], [`${workspaceDir}:/workspace:rw`]);
|
||||
const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"], [`${workspaceDir}:/workspace:rw`]);
|
||||
|
||||
const oldHash = computeSandboxConfigHash({
|
||||
const oldHash = await computeTestSandboxHash({
|
||||
docker: oldCfg.docker,
|
||||
workspaceAccess: oldCfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
const newHash = computeSandboxConfigHash({
|
||||
const newHash = await computeTestSandboxHash({
|
||||
docker: newCfg.docker,
|
||||
workspaceAccess: newCfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
expect(newHash).not.toBe(oldHash);
|
||||
|
||||
|
|
@ -389,7 +128,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
configHash: oldHash,
|
||||
});
|
||||
|
||||
const containerName = await ensureSandboxContainer({
|
||||
const containerName = await harness.ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
|
|
@ -421,19 +160,18 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const cfg = createSandboxConfig([], [], "none", {});
|
||||
const hashInput = {
|
||||
docker: cfg.docker,
|
||||
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
dockerEnvPolicyEpoch: harness.resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
};
|
||||
const oldHash = computeSandboxConfigHash({
|
||||
const oldHash = await computeTestSandboxHash({
|
||||
...hashInput,
|
||||
createArgsEpoch: format === "create-args" ? "pre-init" : SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
mountFormatVersion: format === "private-workspace-mount" ? 3 : SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
});
|
||||
const newHash = computeSandboxConfigHash({
|
||||
const newHash = await computeTestSandboxHash({
|
||||
...hashInput,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
});
|
||||
|
|
@ -461,16 +199,18 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
|
||||
it("keeps a hot pre-init container running and emits the recreate hint", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
spawnState.mounts = JSON.stringify([
|
||||
{ Type: "bind", Source: workspaceDir, Destination: "/workspace", RW: true },
|
||||
]);
|
||||
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`], "rw", {});
|
||||
const oldHash = computeSandboxConfigHash({
|
||||
const oldHash = await computeTestSandboxHash({
|
||||
docker: cfg.docker,
|
||||
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
dockerEnvPolicyEpoch: harness.resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: "pre-init",
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
spawnState.labelHash = oldHash;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue({
|
||||
|
|
@ -482,7 +222,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
configHash: oldHash,
|
||||
});
|
||||
|
||||
await ensureSandboxContainer({
|
||||
await harness.ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
|
|
@ -511,7 +251,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
harness.ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
|
|
@ -532,16 +272,15 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
cfg.docker.binds = [`${workspaceDir}:/workspace:rw`];
|
||||
|
||||
const oldHash = computeSandboxConfigHash({
|
||||
const oldHash = await computeTestSandboxHash({
|
||||
docker: cfg.docker,
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
const newHash = computeSandboxConfigHash({
|
||||
const newHash = await computeTestSandboxHash({
|
||||
docker: cfg.docker,
|
||||
dockerEnvPolicyEpoch: SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
|
|
@ -549,7 +288,6 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
expect(newHash).not.toBe(oldHash);
|
||||
|
||||
|
|
@ -573,106 +311,9 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
expect(registryUpdate?.configHash).toBe(newHash);
|
||||
});
|
||||
|
||||
it("applies custom binds after workspace mounts so overlapping binds can override", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customUserFile = path.join(customRoot, "USER.md");
|
||||
const cfg = createSandboxConfig(["1.1.1.1"], [`${customUserFile}:/workspace/USER.md:ro`]);
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
const expectedHash = computeSandboxConfigHash({
|
||||
docker: cfg.docker,
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash = "stale-hash";
|
||||
registryMocks.readRegistryEntry.mockResolvedValue({
|
||||
containerName: "oc-test-shared",
|
||||
sessionKey: "shared",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 0,
|
||||
image: cfg.docker.image,
|
||||
configHash: "stale-hash",
|
||||
});
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
expect(createCall.args).toContain(`openclaw.configHash=${expectedHash}`);
|
||||
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
const workspaceMountIdx = bindArgs.indexOf(`${workspaceDir}:/workspace:z`);
|
||||
const customMountIdx = bindArgs.indexOf(`${customUserFile}:/workspace/USER.md:ro`);
|
||||
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(customMountIdx).toBeGreaterThan(workspaceMountIdx);
|
||||
});
|
||||
|
||||
it.each(["docker", "podman"] as const)(
|
||||
"skips user binds that conflict with protected skill overlays for %s",
|
||||
async (backend) => {
|
||||
// The protected overlay remains authoritative for both engines, avoiding
|
||||
// duplicate mount rejection without making checked-in skills writable.
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-docker-mounts-");
|
||||
fs.mkdirSync(path.join(workspaceDir, "skills", "demo"), { recursive: true });
|
||||
const customMount = `${customRoot}:/workspace/skills:rw`;
|
||||
const cfg = createSandboxConfig([], [customMount]);
|
||||
cfg.backend = backend;
|
||||
cfg.docker.workdir = "/workspace/.";
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const engine = backend === "podman" ? PODMAN_SANDBOX_ENGINE : undefined;
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir, engine });
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
|
||||
expect(createCall.command).toBe(backend);
|
||||
expect(bindArgs).not.toContain(customMount);
|
||||
expect(bindArgs).toContain(`${path.join(workspaceDir, "skills")}:/workspace/./skills:ro,z`);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace:z" },
|
||||
{ workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" },
|
||||
{ workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:z" },
|
||||
])(
|
||||
"uses expected main mount permissions when workspaceAccess=$workspaceAccess",
|
||||
async ({ workspaceAccess, expectedMainMount }) => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const cfg = createSandboxConfig([], undefined, workspaceAccess);
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
expect(bindArgs).toContain(expectedMainMount);
|
||||
},
|
||||
);
|
||||
|
||||
it("stamps the mount format version label on created containers", async () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const cfg = createSandboxConfig([]);
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash = "";
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
expect(createCall.args).toContain(
|
||||
`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shared lifecycle with rootless Podman workspace ownership", async () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const cfg = createSandboxConfig([]);
|
||||
const cfg = createSandboxConfig([], []);
|
||||
cfg.docker.user = "1001:1002";
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
|
@ -680,7 +321,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
workspaceDir,
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
|
||||
expect(createCall.command).toBe("podman");
|
||||
|
|
@ -711,7 +352,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
|
||||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
|
||||
expect(collectDockerFlagValues(createCall.args, "--user")).toEqual(["1001:1002"]);
|
||||
|
|
@ -732,8 +373,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -757,8 +398,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -775,8 +416,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -792,8 +433,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -823,8 +464,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -861,8 +502,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -897,8 +538,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "shared",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
@ -945,7 +586,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const firstCreate = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
scopeKey: "agent:first:session",
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
const firstName = collectDockerFlagValues(firstCreate.args, "--name")[0];
|
||||
|
||||
|
|
@ -954,7 +595,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const secondCreate = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
scopeKey: "agent:second:session",
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
const secondName = collectDockerFlagValues(secondCreate.args, "--name")[0];
|
||||
|
||||
|
|
@ -971,7 +612,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
|
||||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
|
||||
expect(createCall.args).toContain("--init");
|
||||
|
|
@ -984,7 +625,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: harness.PODMAN_SANDBOX_ENGINE }),
|
||||
).rejects.toThrow("would cover Podman's init path");
|
||||
});
|
||||
|
||||
|
|
@ -996,7 +637,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
|
||||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
|
||||
expect(createCall.args).toContain("--init");
|
||||
|
|
@ -1013,22 +654,21 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: harness.PODMAN_SANDBOX_ENGINE }),
|
||||
).rejects.toThrow("would cover Podman's init path");
|
||||
});
|
||||
|
||||
it("invalidates a Podman container when the same tmpfs list becomes explicit", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
|
||||
const genericHash = computeSandboxConfigHash({
|
||||
const genericHash = await computeTestSandboxHash({
|
||||
docker: cfg.docker,
|
||||
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
dockerEnvPolicyEpoch: harness.resolveDockerEnvPolicyEpoch(cfg.docker.env),
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: [],
|
||||
});
|
||||
const oldHash = `${genericHash}:podman-runtime-v8:keep-id:default`;
|
||||
cfg.dockerTmpfsSource = "configured";
|
||||
|
|
@ -1046,8 +686,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "agent:main:session-1",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
|
|
@ -1070,7 +710,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
|
||||
ensureSandboxCreateCallForTest({ cfg, engine: harness.PODMAN_SANDBOX_ENGINE }),
|
||||
).rejects.toThrow("would cover Podman's init path");
|
||||
});
|
||||
|
||||
|
|
@ -1085,7 +725,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
const createCall = await ensureSandboxCreateCallForTest({
|
||||
cfg,
|
||||
workspaceDir,
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
});
|
||||
|
||||
expect(createCall.command).toBe("podman");
|
||||
|
|
@ -1104,8 +744,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
|||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
ensureSandboxContainer({
|
||||
engine: PODMAN_SANDBOX_ENGINE,
|
||||
harness.ensureSandboxContainer({
|
||||
engine: harness.PODMAN_SANDBOX_ENGINE,
|
||||
scopeKey: "agent:test:session",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
|
|
|
|||
344
src/agents/sandbox/docker.create.test-helpers.ts
Normal file
344
src/agents/sandbox/docker.create.test-helpers.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import fs from "node:fs";
|
||||
import { afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { DOCKER_SANDBOX_ENGINE } from "./container-engine.js";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
globalArgs: string[];
|
||||
envFileContents?: string;
|
||||
};
|
||||
|
||||
const namespaceMocks = vi.hoisted(() => ({
|
||||
resolveDockerSourceNamespace: vi
|
||||
.fn<typeof import("./docker-mount-source.js").resolveDockerSourceNamespace>()
|
||||
.mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
calls: [] as SpawnCall[],
|
||||
containerExists: true,
|
||||
inspectRunning: true,
|
||||
inspectError: "",
|
||||
labelHash: "",
|
||||
mounts: "[]",
|
||||
podmanInfo: "true\tfalse\t\t5.0.0\n",
|
||||
podmanConnections: "[]\n",
|
||||
podmanMachines: "[]\n",
|
||||
}));
|
||||
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
readRegistryEntry: vi.fn(),
|
||||
removeRegistryEntry: vi.fn(),
|
||||
updateRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
}));
|
||||
|
||||
function usePodmanMachine() {
|
||||
spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n";
|
||||
spawnState.podmanConnections = JSON.stringify([
|
||||
{
|
||||
Name: "podman-machine-default",
|
||||
URI: "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock",
|
||||
Identity: "/tmp/podman-machine-default",
|
||||
Default: true,
|
||||
},
|
||||
]);
|
||||
spawnState.podmanMachines = JSON.stringify([
|
||||
{
|
||||
Name: "podman-machine-default",
|
||||
Running: true,
|
||||
IdentityPath: "/tmp/podman-machine-default",
|
||||
Port: 60000,
|
||||
RemoteUsername: "core",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function createRegistryMock() {
|
||||
return {
|
||||
readRegistryEntry: registryMocks.readRegistryEntry,
|
||||
removeRegistryEntry: registryMocks.removeRegistryEntry,
|
||||
updateRegistry: registryMocks.updateRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./registry.js", createRegistryMock);
|
||||
|
||||
function createRuntimeMock() {
|
||||
return {
|
||||
defaultRuntime: runtimeMocks,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../runtime.js", createRuntimeMock);
|
||||
|
||||
async function createNamespaceMock() {
|
||||
const actual = await vi.importActual<typeof import("./docker-mount-source.js")>(
|
||||
"./docker-mount-source.js",
|
||||
);
|
||||
return {
|
||||
parseInspectedSandboxMounts: actual.parseInspectedSandboxMounts,
|
||||
translateSandboxMountSources: actual.translateSandboxMountSources,
|
||||
resolveDockerSourceNamespace: namespaceMocks.resolveDockerSourceNamespace,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./docker-mount-source.js", createNamespaceMock);
|
||||
|
||||
async function spawnDockerProcess(commandAndArgs: string[]) {
|
||||
const [command = "", ...rawArgs] = commandAndArgs;
|
||||
const globalArgs: string[] = [];
|
||||
let args = rawArgs;
|
||||
if (command === "podman") {
|
||||
while (args[0] === "--url" || args[0] === "--identity") {
|
||||
globalArgs.push(...args.slice(0, 2));
|
||||
args = args.slice(2);
|
||||
}
|
||||
}
|
||||
// The tests assert docker CLI arguments without requiring Docker; this mock
|
||||
// implements only the inspect/create/start/rm calls used by ensureSandboxContainer.
|
||||
const envFileIndex = args.indexOf("--env-file");
|
||||
const envFile = envFileIndex === -1 ? undefined : args[envFileIndex + 1];
|
||||
const call: SpawnCall = { command, args, globalArgs };
|
||||
if (args[0] === "create" && envFile) {
|
||||
call.envFileContents = fs.readFileSync(envFile, "utf8");
|
||||
}
|
||||
spawnState.calls.push(call);
|
||||
|
||||
let code = 0;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (command !== "docker" && command !== "podman") {
|
||||
code = 1;
|
||||
stderr = `unexpected command: ${command}`;
|
||||
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
|
||||
if (spawnState.inspectError) {
|
||||
code = 125;
|
||||
stderr = spawnState.inspectError;
|
||||
} else if (!spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "No such object";
|
||||
} else {
|
||||
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
|
||||
}
|
||||
} else if (
|
||||
args[0] === "inspect" &&
|
||||
args[1] === "-f" &&
|
||||
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
|
||||
) {
|
||||
if (!spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "No such object";
|
||||
} else {
|
||||
stdout = `${spawnState.labelHash}\n`;
|
||||
}
|
||||
} else if (
|
||||
args[0] === "inspect" &&
|
||||
args[2] === '{"Mounts":{{json .Mounts}},"Tmpfs":{{json .HostConfig.Tmpfs}}}'
|
||||
) {
|
||||
stdout = JSON.stringify({ Mounts: JSON.parse(spawnState.mounts), Tmpfs: null });
|
||||
} else if (command === "podman" && args[0] === "info") {
|
||||
stdout = spawnState.podmanInfo;
|
||||
} else if (command === "podman" && args[0] === "system") {
|
||||
stdout = spawnState.podmanConnections;
|
||||
} else if (command === "podman" && args[0] === "machine") {
|
||||
stdout = spawnState.podmanMachines;
|
||||
} else if (args[0] === "rm" && args[1] === "-f") {
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
} else if (args[0] === "image" && args[1] === "inspect") {
|
||||
code = 0;
|
||||
} else if (args[0] === "create") {
|
||||
if (spawnState.containerExists) {
|
||||
code = 1;
|
||||
stderr = "container name is already in use";
|
||||
} else {
|
||||
spawnState.containerExists = true;
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash =
|
||||
args
|
||||
.find((arg) => arg.startsWith("openclaw.configHash="))
|
||||
?.slice("openclaw.configHash=".length) ?? "";
|
||||
}
|
||||
} else if (args[0] === "start") {
|
||||
spawnState.inspectRunning = true;
|
||||
} else if (args[0] === "exec") {
|
||||
code = 0;
|
||||
} else {
|
||||
code = 1;
|
||||
stderr = `unexpected docker args: ${args.join(" ")}`;
|
||||
}
|
||||
return {
|
||||
failed: code !== 0,
|
||||
isCanceled: false,
|
||||
exitCode: code,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
async function createProcessMock() {
|
||||
return {
|
||||
...(await vi.importActual<typeof import("../../process/exec.js")>("../../process/exec.js")),
|
||||
spawnCommand: spawnDockerProcess,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../process/exec.js", createProcessMock);
|
||||
|
||||
function createSandboxConfig(
|
||||
dns: string[],
|
||||
binds?: string[],
|
||||
workspaceAccess: "rw" | "ro" | "none" = "rw",
|
||||
env: Record<string, string> = { LANG: "C.UTF-8" },
|
||||
): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "shared",
|
||||
workspaceAccess,
|
||||
workspaceRoot: "~/.openclaw/sandboxes",
|
||||
dockerTmpfsSource: "default",
|
||||
docker: {
|
||||
image: "openclaw-sandbox:test",
|
||||
containerPrefix: "oc-test-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
||||
network: "none",
|
||||
capDrop: ["ALL"],
|
||||
env,
|
||||
dns,
|
||||
extraHosts: ["host.docker.internal:host-gateway"],
|
||||
binds: binds ?? ["/tmp/workspace:/workspace:rw"],
|
||||
dangerouslyAllowReservedContainerTargets: true,
|
||||
},
|
||||
ssh: {
|
||||
command: "ssh",
|
||||
workspaceRoot: "/tmp/openclaw-sandboxes",
|
||||
strictHostKeyChecking: true,
|
||||
updateHostKeys: true,
|
||||
},
|
||||
browser: {
|
||||
enabled: false,
|
||||
image: "openclaw-browser:test",
|
||||
containerPrefix: "oc-browser-",
|
||||
network: "openclaw-sandbox-browser",
|
||||
cdpPort: 9222,
|
||||
vncPort: 5900,
|
||||
noVncPort: 6080,
|
||||
headless: true,
|
||||
noVncEnabled: false,
|
||||
allowHostControl: false,
|
||||
autoStart: false,
|
||||
autoStartTimeoutMs: 5000,
|
||||
},
|
||||
tools: { allow: [], deny: [] },
|
||||
prune: { idleHours: 24, maxAgeDays: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
export function createSandboxContainerTestHarness() {
|
||||
// Keep loaded modules and temporary directories per suite. Shared mock modules
|
||||
// are reset by the hooks installed for that suite.
|
||||
let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer;
|
||||
let resolveDockerEnvPolicyEpoch: typeof import("./docker.js").resolveDockerEnvPolicyEpoch;
|
||||
let PODMAN_SANDBOX_ENGINE: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE;
|
||||
let prepareSandboxMountPlan: typeof import("./mount-plan.js").prepareSandboxMountPlan;
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./registry.js", createRegistryMock);
|
||||
vi.doMock("../../runtime.js", createRuntimeMock);
|
||||
vi.doMock("./docker-mount-source.js", createNamespaceMock);
|
||||
vi.doMock("../../process/exec.js", createProcessMock);
|
||||
({ prepareSandboxMountPlan } = await import("./mount-plan.js"));
|
||||
({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch, PODMAN_SANDBOX_ENGINE } =
|
||||
await import("./docker.js"));
|
||||
});
|
||||
|
||||
async function computeTestSandboxHash(input: Parameters<typeof computeSandboxConfigHash>[0]) {
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
workspaceDir: input.workspaceDir,
|
||||
agentWorkspaceDir: input.agentWorkspaceDir,
|
||||
workdir: input.docker.workdir,
|
||||
workspaceAccess: input.workspaceAccess,
|
||||
binds: input.docker.binds,
|
||||
});
|
||||
return computeSandboxConfigHash({ ...input, managedMounts: plan.binds });
|
||||
}
|
||||
|
||||
async function ensureSandboxCreateCallForTest(params: {
|
||||
cfg: SandboxConfig;
|
||||
workspaceDir?: string;
|
||||
scopeKey?: string;
|
||||
engine?: import("./docker.js").SandboxContainerEngine;
|
||||
}): Promise<SpawnCall> {
|
||||
const workspaceDir = params.workspaceDir ?? "/tmp/workspace";
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: params.scopeKey ?? "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: params.cfg,
|
||||
...(params.engine ? { engine: params.engine } : {}),
|
||||
});
|
||||
|
||||
const createCall = spawnState.calls.find(
|
||||
(call) => call.command === (params.engine?.command ?? "docker") && call.args[0] === "create",
|
||||
);
|
||||
if (!createCall) {
|
||||
throw new Error(`expected ${params.engine?.command ?? "docker"} create call`);
|
||||
}
|
||||
return createCall;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
spawnState.calls.length = 0;
|
||||
spawnState.containerExists = true;
|
||||
spawnState.inspectRunning = true;
|
||||
spawnState.inspectError = "";
|
||||
spawnState.labelHash = "";
|
||||
spawnState.mounts = "[]";
|
||||
namespaceMocks.resolveDockerSourceNamespace.mockResolvedValue(undefined);
|
||||
spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n";
|
||||
spawnState.podmanConnections = "[]\n";
|
||||
spawnState.podmanMachines = "[]\n";
|
||||
registryMocks.readRegistryEntry.mockClear();
|
||||
registryMocks.removeRegistryEntry.mockClear();
|
||||
registryMocks.removeRegistryEntry.mockResolvedValue(undefined);
|
||||
registryMocks.updateRegistry.mockClear();
|
||||
registryMocks.updateRegistry.mockResolvedValue(undefined);
|
||||
runtimeMocks.log.mockClear();
|
||||
});
|
||||
|
||||
return {
|
||||
resolveDockerSourceNamespace: namespaceMocks.resolveDockerSourceNamespace,
|
||||
spawnState,
|
||||
registryMocks,
|
||||
runtimeMocks,
|
||||
tempDirs,
|
||||
usePodmanMachine,
|
||||
createSandboxConfig,
|
||||
computeTestSandboxHash,
|
||||
ensureSandboxCreateCallForTest,
|
||||
get ensureSandboxContainer() {
|
||||
return ensureSandboxContainer;
|
||||
},
|
||||
get resolveDockerEnvPolicyEpoch() {
|
||||
return resolveDockerEnvPolicyEpoch;
|
||||
},
|
||||
get PODMAN_SANDBOX_ENGINE() {
|
||||
return PODMAN_SANDBOX_ENGINE;
|
||||
},
|
||||
};
|
||||
}
|
||||
219
src/agents/sandbox/docker.mounts.test.ts
Normal file
219
src/agents/sandbox/docker.mounts.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js";
|
||||
import { createSandboxContainerTestHarness } from "./docker.create.test-helpers.js";
|
||||
import { collectDockerFlagValues } from "./test-args.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
describe("ensureSandboxContainer managed mounts", () => {
|
||||
const harness = createSandboxContainerTestHarness();
|
||||
const {
|
||||
resolveDockerSourceNamespace,
|
||||
spawnState,
|
||||
registryMocks,
|
||||
tempDirs,
|
||||
createSandboxConfig,
|
||||
computeTestSandboxHash,
|
||||
ensureSandboxCreateCallForTest,
|
||||
} = harness;
|
||||
|
||||
it.each(["none", "ro", "rw"] as const)(
|
||||
"creates Docker mounts in the daemon namespace for %s access",
|
||||
async (access) => {
|
||||
const root = fs.realpathSync(tempDirs.make("openclaw-dood-"));
|
||||
for (const dir of ["private/skills", "agent/skills", "materialized/skills"]) {
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
}
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/state", destination: root, writable: true },
|
||||
]);
|
||||
spawnState.containerExists = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
await harness.ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir: path.join(root, access === "rw" ? "agent" : "private"),
|
||||
agentWorkspaceDir: path.join(root, "agent"),
|
||||
skillsWorkspaceDir: path.join(root, "materialized"),
|
||||
cfg: createSandboxConfig([], [], access),
|
||||
});
|
||||
const binds = collectDockerFlagValues(
|
||||
spawnState.calls.find((call) => call.args[0] === "create")?.args ?? [],
|
||||
"-v",
|
||||
);
|
||||
expect(binds).toContain(
|
||||
`/host/state/${access === "rw" ? "agent" : "private"}:/workspace:${access === "ro" ? "ro,z" : "z"}`,
|
||||
);
|
||||
expect(binds.includes("/host/state/agent:/agent:ro,z")).toBe(access === "ro");
|
||||
if (access === "rw") {
|
||||
expect(binds).toContain(
|
||||
"/host/state/materialized/skills:/workspace/.openclaw/sandbox-skills/skills:ro,z",
|
||||
);
|
||||
}
|
||||
expect(binds.some((bind) => bind.startsWith(root))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a hot container after a source change, then recreates it when stopped", async () => {
|
||||
const workspaceDir = fs.realpathSync(tempDirs.make("openclaw-dood-"));
|
||||
const cfg = createSandboxConfig([], []);
|
||||
const params = { scopeKey: "shared", workspaceDir, agentWorkspaceDir: workspaceDir, cfg };
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/first", destination: workspaceDir, writable: true },
|
||||
]);
|
||||
spawnState.containerExists = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
await harness.ensureSandboxContainer(params);
|
||||
const firstHash = spawnState.labelHash;
|
||||
spawnState.mounts = JSON.stringify([
|
||||
{ Type: "bind", Source: "/host/first", Destination: "/workspace", RW: true },
|
||||
]);
|
||||
registryMocks.readRegistryEntry.mockResolvedValue({
|
||||
containerName: "oc-test-shared",
|
||||
lastUsedAtMs: Date.now(),
|
||||
configHash: firstHash,
|
||||
});
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/second", destination: workspaceDir, writable: true },
|
||||
]);
|
||||
spawnState.calls.length = 0;
|
||||
await expect(harness.ensureSandboxContainer(params)).rejects.toThrow(
|
||||
"Recreate first: openclaw sandbox recreate --all",
|
||||
);
|
||||
expect(spawnState.calls.some((call) => ["rm", "create", "start"].includes(call.args[0]!))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(spawnState.labelHash).toBe(firstHash);
|
||||
spawnState.inspectRunning = false;
|
||||
await harness.ensureSandboxContainer(params);
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "rm")).toBe(true);
|
||||
expect(spawnState.labelHash).not.toBe(firstHash);
|
||||
expect(
|
||||
collectDockerFlagValues(
|
||||
spawnState.calls.find((call) => call.args[0] === "create")?.args ?? [],
|
||||
"-v",
|
||||
),
|
||||
).toContain("/host/second:/workspace:z");
|
||||
});
|
||||
|
||||
it("refuses a pre-fix hot container with a removed skill overlay", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-dood-");
|
||||
spawnState.mounts = JSON.stringify([
|
||||
{ Type: "bind", Source: workspaceDir, Destination: "/workspace", RW: true },
|
||||
{
|
||||
Type: "bind",
|
||||
Source: `${workspaceDir}/skills`,
|
||||
Destination: "/workspace/skills",
|
||||
RW: false,
|
||||
},
|
||||
]);
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
await expect(
|
||||
harness.ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createSandboxConfig([], [], "none"),
|
||||
}),
|
||||
).rejects.toThrow("Sandbox mounts changed");
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "rm")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies custom binds after workspace mounts so overlapping binds can override", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customUserFile = path.join(customRoot, "USER.md");
|
||||
const cfg = createSandboxConfig(["1.1.1.1"], [`${customUserFile}:/workspace/USER.md:ro`]);
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
const expectedHash = await computeTestSandboxHash({
|
||||
docker: cfg.docker,
|
||||
workspaceAccess: cfg.workspaceAccess,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
});
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash = "stale-hash";
|
||||
registryMocks.readRegistryEntry.mockResolvedValue({
|
||||
containerName: "oc-test-shared",
|
||||
sessionKey: "shared",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 0,
|
||||
image: cfg.docker.image,
|
||||
configHash: "stale-hash",
|
||||
});
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
expect(createCall.args).toContain(`openclaw.configHash=${expectedHash}`);
|
||||
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
const workspaceMountIdx = bindArgs.indexOf(`${workspaceDir}:/workspace:z`);
|
||||
const customMountIdx = bindArgs.indexOf(`${customUserFile}:/workspace/USER.md:ro`);
|
||||
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(customMountIdx).toBeGreaterThan(workspaceMountIdx);
|
||||
});
|
||||
|
||||
it.each(["docker", "podman"] as const)(
|
||||
"skips user binds that conflict with protected skill overlays for %s",
|
||||
async (backend) => {
|
||||
// The protected overlay remains authoritative for both engines, avoiding
|
||||
// duplicate mount rejection without making checked-in skills writable.
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
const customRoot = tempDirs.make("openclaw-docker-mounts-");
|
||||
fs.mkdirSync(path.join(workspaceDir, "skills", "demo"), { recursive: true });
|
||||
const customMount = `${customRoot}:/workspace/skills:rw`;
|
||||
const cfg = createSandboxConfig([], [customMount]);
|
||||
cfg.backend = backend;
|
||||
cfg.docker.workdir = "/workspace/.";
|
||||
cfg.docker.dangerouslyAllowExternalBindSources = true;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const engine = backend === "podman" ? harness.PODMAN_SANDBOX_ENGINE : undefined;
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir, engine });
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
|
||||
expect(createCall.command).toBe(backend);
|
||||
expect(bindArgs).not.toContain(customMount);
|
||||
expect(bindArgs).toContain(`${path.join(workspaceDir, "skills")}:/workspace/skills:ro,z`);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace:z" },
|
||||
{ workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" },
|
||||
{ workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:z" },
|
||||
])(
|
||||
"uses expected main mount permissions when workspaceAccess=$workspaceAccess",
|
||||
async ({ workspaceAccess, expectedMainMount }) => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const cfg = createSandboxConfig([], [], workspaceAccess);
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
|
||||
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
|
||||
expect(bindArgs).toContain(expectedMainMount);
|
||||
},
|
||||
);
|
||||
|
||||
it("stamps the mount format version label on created containers", async () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const cfg = createSandboxConfig([]);
|
||||
|
||||
spawnState.inspectRunning = false;
|
||||
spawnState.labelHash = "";
|
||||
spawnState.mounts = "[]";
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue(undefined);
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
const createCall = await ensureSandboxCreateCallForTest({ cfg, workspaceDir });
|
||||
expect(createCall.args).toContain(
|
||||
`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,11 @@ import {
|
|||
type SandboxContainerEngineTarget,
|
||||
} from "./container-engine.js";
|
||||
import { handleHotSandboxConfigMismatch } from "./current-config.js";
|
||||
import {
|
||||
prepareSandboxMountPlan,
|
||||
sandboxMountPlanMatchesContainer,
|
||||
type SandboxMountPlan,
|
||||
} from "./mount-plan.js";
|
||||
import {
|
||||
assertPodmanSandboxTarget,
|
||||
bindPodmanSandboxEngine,
|
||||
|
|
@ -36,16 +41,7 @@ import {
|
|||
import { buildSandboxContainerName, slugifySessionKey } from "./shared.js";
|
||||
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
||||
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
|
||||
import {
|
||||
appendReadOnlyWorkspaceSkillMountArgs,
|
||||
appendWorkspaceMountArgs,
|
||||
filterBindsConflictingWithProtectedMounts,
|
||||
formatReadOnlyWorkspaceSkillMountHashState,
|
||||
resolveReadOnlyWorkspaceSkillMounts,
|
||||
resolveProtectedSkillMountContainerPaths,
|
||||
SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
type ReadOnlyWorkspaceSkillMount,
|
||||
} from "./workspace-mounts.js";
|
||||
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
|
||||
|
||||
export {
|
||||
DOCKER_SANDBOX_ENGINE,
|
||||
|
|
@ -462,7 +458,7 @@ async function createSandboxContainer(params: {
|
|||
skillsWorkspaceDir?: string;
|
||||
scopeKey: string;
|
||||
configHash?: string;
|
||||
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
mountPlan: SandboxMountPlan;
|
||||
podmanRuntimeInfo?: PodmanSandboxRuntimeInfo;
|
||||
}) {
|
||||
const { engine, name, cfg, workspaceDir, scopeKey } = params;
|
||||
|
|
@ -474,7 +470,7 @@ async function createSandboxContainer(params: {
|
|||
workspaceDir,
|
||||
workspaceAccess: params.workspaceAccess,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
|
||||
readOnlyWorkspaceSkillMounts: params.mountPlan.readOnlyWorkspaceSkillMounts,
|
||||
runtimeInfo: params.podmanRuntimeInfo,
|
||||
})
|
||||
: undefined;
|
||||
|
|
@ -493,36 +489,12 @@ async function createSandboxContainer(params: {
|
|||
args.push(...podmanPolicy.extraCreateArgs);
|
||||
}
|
||||
args.push("--workdir", cfg.workdir);
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: cfg.workdir,
|
||||
workspaceAccess: params.workspaceAccess,
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
|
||||
includeReadOnlyWorkspaceSkillMounts: false,
|
||||
});
|
||||
// Protected skill overlays are authoritative. Remove exact destination
|
||||
// collisions before Docker or Podman sees duplicate mount arguments.
|
||||
const protectedPaths = resolveProtectedSkillMountContainerPaths(
|
||||
params.readOnlyWorkspaceSkillMounts,
|
||||
);
|
||||
let safeBinds = cfg.binds;
|
||||
if (protectedPaths.size > 0 && cfg.binds?.length) {
|
||||
safeBinds = filterBindsConflictingWithProtectedMounts(cfg.binds, protectedPaths);
|
||||
const skipped = cfg.binds.filter((b) => !safeBinds!.includes(b));
|
||||
for (const bind of skipped) {
|
||||
log.warn(
|
||||
`sandbox: skipping user bind "${bind}" — container path conflicts with a protected read-only skill mount`,
|
||||
);
|
||||
}
|
||||
for (const bind of params.mountPlan.skippedBinds) {
|
||||
log.warn(
|
||||
`sandbox: skipping user bind "${bind}" — container path conflicts with a protected read-only skill mount`,
|
||||
);
|
||||
}
|
||||
appendCustomBinds(args, safeBinds ? { ...cfg, binds: safeBinds } : cfg);
|
||||
appendReadOnlyWorkspaceSkillMountArgs({
|
||||
args,
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
|
||||
});
|
||||
appendCustomBinds(args, { ...cfg, binds: params.mountPlan.binds });
|
||||
await withContainerEnvFile(env, async (envFile) => {
|
||||
args.push("--env-file", envFile, cfg.image, "sleep", "infinity");
|
||||
await execContainer(engine, args);
|
||||
|
|
@ -608,12 +580,14 @@ async function ensureSandboxContainerLifecycle(
|
|||
existingRegistryEntry = null;
|
||||
}
|
||||
}
|
||||
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
|
||||
const mountPlan = await prepareSandboxMountPlan({
|
||||
engine,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
binds: params.cfg.docker.binds,
|
||||
});
|
||||
const genericConfigHash = computeSandboxConfigHash({
|
||||
docker: params.cfg.docker,
|
||||
|
|
@ -623,9 +597,7 @@ async function ensureSandboxContainerLifecycle(
|
|||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState(
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
),
|
||||
managedMounts: mountPlan.binds,
|
||||
});
|
||||
const expectedHash =
|
||||
engine.id === "podman"
|
||||
|
|
@ -654,10 +626,14 @@ async function ensureSandboxContainerLifecycle(
|
|||
running &&
|
||||
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
||||
if (isHot) {
|
||||
const mountsMatch =
|
||||
params.requireCurrentConfig ||
|
||||
(await sandboxMountPlanMatchesContainer({ engine, containerName, plan: mountPlan }));
|
||||
handleHotSandboxConfigMismatch({
|
||||
containerName,
|
||||
scope: params.cfg.scope,
|
||||
sessionKey: params.scopeKey,
|
||||
mountsChanged: !mountsMatch,
|
||||
...(params.requireCurrentConfig !== undefined
|
||||
? { requireCurrentConfig: params.requireCurrentConfig }
|
||||
: {}),
|
||||
|
|
@ -681,7 +657,7 @@ async function ensureSandboxContainerLifecycle(
|
|||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
scopeKey: params.scopeKey,
|
||||
configHash: expectedHash,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
mountPlan,
|
||||
podmanRuntimeInfo,
|
||||
});
|
||||
} else if (!running) {
|
||||
|
|
|
|||
362
src/agents/sandbox/mount-plan.test.ts
Normal file
362
src/agents/sandbox/mount-plan.test.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { DOCKER_SANDBOX_ENGINE, execContainer } from "./container-engine.js";
|
||||
import { resolveDockerSourceNamespace } from "./docker-mount-source.js";
|
||||
import { prepareSandboxMountPlan, sandboxMountPlanMatchesContainer } from "./mount-plan.js";
|
||||
|
||||
vi.mock("./container-engine.js", () => ({
|
||||
DOCKER_SANDBOX_ENGINE: { id: "docker", command: "docker", displayName: "Docker" },
|
||||
execContainer: vi.fn(),
|
||||
}));
|
||||
vi.mock("./docker-mount-source.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./docker-mount-source.js")>();
|
||||
return {
|
||||
parseInspectedSandboxMounts: actual.parseInspectedSandboxMounts,
|
||||
translateSandboxMountSources: actual.translateSandboxMountSources,
|
||||
resolveDockerSourceNamespace: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
root = fs.realpathSync(tempDirs.make("openclaw-mount-plan-"));
|
||||
for (const subdir of [
|
||||
"empty",
|
||||
"private/skills",
|
||||
"private/skills/nested",
|
||||
"private/data/nested",
|
||||
"agent/skills",
|
||||
"agent/skills/nested",
|
||||
"agent/data/nested",
|
||||
"agent/.agents/skills",
|
||||
"materialized/skills",
|
||||
"materialized/skills/nested",
|
||||
]) {
|
||||
fs.mkdirSync(path.join(root, subdir), { recursive: true });
|
||||
}
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/state", destination: root, writable: true },
|
||||
{ type: "bind", source: "/host/agent", destination: path.join(root, "agent"), writable: true },
|
||||
{
|
||||
type: "bind",
|
||||
source: "/host/materialized skills",
|
||||
destination: path.join(root, "materialized"),
|
||||
writable: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
function params(access: "none" | "ro" | "rw") {
|
||||
return {
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
workspaceDir: path.join(root, access === "rw" ? "agent" : "private"),
|
||||
agentWorkspaceDir: path.join(root, "agent"),
|
||||
skillsWorkspaceDir: path.join(root, "materialized"),
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: access,
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed mount plan", () => {
|
||||
it.each(["none", "ro", "rw"] as const)(
|
||||
"maps all managed roots with %s permissions",
|
||||
async (access) => {
|
||||
const plan = await prepareSandboxMountPlan(params(access));
|
||||
if (access === "none") {
|
||||
expect(plan.binds).toEqual([
|
||||
"/host/state/private:/workspace:z",
|
||||
"/host/state/private/skills:/workspace/skills:ro,z",
|
||||
]);
|
||||
} else if (access === "ro") {
|
||||
expect(plan.binds).toEqual([
|
||||
"/host/state/private:/workspace:ro,z",
|
||||
"/host/agent:/agent:ro,z",
|
||||
]);
|
||||
} else {
|
||||
expect(plan.binds).toEqual([
|
||||
"/host/agent:/workspace:z",
|
||||
"/host/agent/skills:/workspace/skills:ro,z",
|
||||
"/host/agent/.agents/skills:/workspace/.agents/skills:ro,z",
|
||||
"/host/materialized skills/skills:/workspace/.openclaw/sandbox-skills/skills:ro,z",
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves custom daemon sources while keeping protected skill mounts authoritative", async () => {
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("rw"),
|
||||
binds: ["/custom/host:/data:ro", "/custom/override:/workspace/skills:rw"],
|
||||
});
|
||||
expect(plan.binds).toContain("/custom/host:/data:ro");
|
||||
expect(plan.binds).toContain("/host/agent/skills:/workspace/skills:ro,z");
|
||||
expect(plan.skippedBinds).toEqual(["/custom/override:/workspace/skills:rw"]);
|
||||
});
|
||||
|
||||
it.each(["/workspace", "/workspace/"])(
|
||||
"honors a custom override at %s without translating the replaced source",
|
||||
async (target) => {
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([]);
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("ro"),
|
||||
workspaceDir: path.join(root, "agent"),
|
||||
binds: [`/custom/project:${target}:ro`],
|
||||
});
|
||||
expect(plan.binds).toEqual([`/custom/project:${target}:ro`]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["none", "ro", "rw"] as const)(
|
||||
"projects nested binds and protected skill descendants with %s permissions",
|
||||
async (access) => {
|
||||
const namespace = await resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE);
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
...namespace!,
|
||||
...["private", "agent"].flatMap((workspace) => [
|
||||
{
|
||||
type: "bind",
|
||||
source: `/host/${workspace}-data`,
|
||||
destination: path.join(root, workspace, "data"),
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
type: "bind",
|
||||
source: `/host/${workspace}-nested`,
|
||||
destination: path.join(root, workspace, "data/nested"),
|
||||
writable: true,
|
||||
},
|
||||
{
|
||||
type: "bind",
|
||||
source: `/host/${workspace}-skill`,
|
||||
destination: path.join(root, workspace, "skills/nested"),
|
||||
writable: true,
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "bind",
|
||||
source: "/host/materialized-skill",
|
||||
destination: path.join(root, "materialized/skills/nested"),
|
||||
writable: true,
|
||||
},
|
||||
]);
|
||||
const plan = await prepareSandboxMountPlan(params(access));
|
||||
const workspace = access === "rw" ? "agent" : "private";
|
||||
expect(plan.binds).toContain(`/host/${workspace}-data:/workspace/data:ro,z`);
|
||||
expect(plan.binds).toContain(
|
||||
`/host/${workspace}-nested:/workspace/data/nested:${access === "ro" ? "ro,z" : "z"}`,
|
||||
);
|
||||
expect(plan.binds).toContain(`/host/${workspace}-skill:/workspace/skills/nested:ro,z`);
|
||||
if (access === "rw") {
|
||||
expect(plan.binds).toContain(
|
||||
"/host/materialized-skill:/workspace/.openclaw/sandbox-skills/skills/nested:ro,z",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["volume", "tmpfs", "image"])(
|
||||
"rejects visible nested %s storage but respects a custom subtree override",
|
||||
async (type) => {
|
||||
const namespace = await resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE);
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
...namespace!,
|
||||
{
|
||||
type,
|
||||
source: "/private/storage",
|
||||
destination: path.join(root, "private/data"),
|
||||
writable: true,
|
||||
},
|
||||
{
|
||||
type: "bind",
|
||||
source: "/host/hidden",
|
||||
destination: path.join(root, "private/data/nested"),
|
||||
writable: true,
|
||||
},
|
||||
]);
|
||||
await expect(prepareSandboxMountPlan(params("none"))).rejects.toThrow(
|
||||
`unsupported nested ${type} mount`,
|
||||
);
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("none"),
|
||||
binds: ["/custom/data:/workspace/data/:ro"],
|
||||
});
|
||||
expect(plan.binds).toContain("/custom/data:/workspace/data/:ro");
|
||||
expect(plan.binds.some((bind) => bind.startsWith("/host/hidden:"))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("changes the hashed bind plan when the daemon source changes", async () => {
|
||||
const first = await prepareSandboxMountPlan(params("ro"));
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/replacement", destination: root, writable: true },
|
||||
]);
|
||||
const second = await prepareSandboxMountPlan(params("ro"));
|
||||
expect(second.binds).not.toEqual(first.binds);
|
||||
expect(second.binds).toContain("/replacement/private:/workspace:ro,z");
|
||||
});
|
||||
|
||||
it("keeps protected skills read-only on a read-only Gateway source", async () => {
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue([
|
||||
{ type: "bind", source: "/host/state", destination: root, writable: true },
|
||||
{
|
||||
type: "bind",
|
||||
source: "/host/skills",
|
||||
destination: path.join(root, "materialized"),
|
||||
writable: false,
|
||||
},
|
||||
]);
|
||||
expect((await prepareSandboxMountPlan(params("rw"))).binds).toContain(
|
||||
"/host/skills/skills:/workspace/.openclaw/sandbox-skills/skills:ro,z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retained mount identity", () => {
|
||||
it.each([
|
||||
"source",
|
||||
"writable",
|
||||
"removed-agent",
|
||||
"removed-skill",
|
||||
"removed-custom",
|
||||
"removed-nested",
|
||||
])("rejects a stale %s mount even on a pre-fix container", async (change) => {
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("none"),
|
||||
workspaceDir: path.join(root, "empty"),
|
||||
});
|
||||
const mounts = [
|
||||
{ Type: "bind", Source: "/host/state/empty", Destination: "/workspace", RW: true },
|
||||
];
|
||||
if (change === "source") {
|
||||
mounts[0]!.Source = "/old/source";
|
||||
}
|
||||
if (change === "writable") {
|
||||
mounts[0]!.RW = false;
|
||||
}
|
||||
if (change.startsWith("removed")) {
|
||||
mounts.push({
|
||||
Type: "bind",
|
||||
Source: "/old/source",
|
||||
Destination:
|
||||
change === "removed-agent"
|
||||
? "/agent"
|
||||
: change === "removed-skill"
|
||||
? "/workspace/skills"
|
||||
: change === "removed-custom"
|
||||
? "/data"
|
||||
: "/workspace/data",
|
||||
RW: false,
|
||||
});
|
||||
}
|
||||
vi.mocked(execContainer).mockResolvedValue({
|
||||
stdout: JSON.stringify({ Mounts: mounts, Tmpfs: null }),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
expect(
|
||||
await sandboxMountPlanMatchesContainer({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
containerName: "retained",
|
||||
plan,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an unchanged mount with a trailing-slash workdir and ignores unrelated image volumes", async () => {
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("none"),
|
||||
workspaceDir: path.join(root, "empty"),
|
||||
workdir: "/workspace/",
|
||||
});
|
||||
expect(plan.binds).toEqual(["/host/state/empty:/workspace:z"]);
|
||||
vi.mocked(execContainer).mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Mounts: [
|
||||
{ Type: "bind", Source: "/host/state/empty", Destination: "/workspace/", RW: true },
|
||||
{ Type: "volume", Source: "/engine/volume", Destination: "/image-data", RW: true },
|
||||
],
|
||||
Tmpfs: { "/tmp": "rw" },
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
expect(
|
||||
await sandboxMountPlanMatchesContainer({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
containerName: "retained",
|
||||
plan,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["source", "permission", "type", "tmpfs"])(
|
||||
"rejects a stale custom bind %s",
|
||||
async (change) => {
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("none"),
|
||||
workspaceDir: path.join(root, "empty"),
|
||||
binds: ["/host/data:/workspace/data:ro"],
|
||||
});
|
||||
vi.mocked(execContainer).mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Mounts: [
|
||||
{ Type: "bind", Source: "/host/state/empty", Destination: "/workspace", RW: true },
|
||||
{
|
||||
Type: change === "type" ? "volume" : "bind",
|
||||
Source: change === "source" ? "/old/data" : "/host/data",
|
||||
Destination: "/workspace/data",
|
||||
RW: change === "permission",
|
||||
},
|
||||
],
|
||||
Tmpfs: change === "tmpfs" ? { "/workspace/data": "rw" } : null,
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
expect(
|
||||
await sandboxMountPlanMatchesContainer({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
containerName: "retained",
|
||||
plan,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts intentional custom binds and case-insensitive Windows sources at managed destinations", async () => {
|
||||
vi.mocked(resolveDockerSourceNamespace).mockResolvedValue(undefined);
|
||||
const plan = await prepareSandboxMountPlan({
|
||||
...params("ro"),
|
||||
workspaceDir: "C:/Users/Example/project",
|
||||
agentWorkspaceDir: "C:/Users/Example/project",
|
||||
binds: ["D:\\Skills:/workspace/skills:ro"],
|
||||
});
|
||||
vi.mocked(execContainer).mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Mounts: [
|
||||
{
|
||||
Type: "bind",
|
||||
Source: "c:\\users\\example\\project",
|
||||
Destination: "/workspace",
|
||||
RW: false,
|
||||
},
|
||||
{ Type: "bind", Source: "d:\\skills", Destination: "/workspace/skills", RW: false },
|
||||
],
|
||||
Tmpfs: null,
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
expect(
|
||||
await sandboxMountPlanMatchesContainer({
|
||||
engine: DOCKER_SANDBOX_ENGINE,
|
||||
containerName: "retained",
|
||||
plan,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
141
src/agents/sandbox/mount-plan.ts
Normal file
141
src/agents/sandbox/mount-plan.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { splitSandboxBindSpec } from "./bind-spec.js";
|
||||
import { execContainer, type SandboxContainerEngine } from "./container-engine.js";
|
||||
import {
|
||||
parseInspectedSandboxMounts,
|
||||
resolveDockerSourceNamespace,
|
||||
translateSandboxMountSources,
|
||||
} from "./docker-mount-source.js";
|
||||
import { getSandboxHostPathPolicyKey } from "./host-paths.js";
|
||||
import type { SandboxWorkspaceAccess } from "./types.js";
|
||||
import {
|
||||
filterBindsConflictingWithProtectedMounts,
|
||||
normalizeMountContainerPath,
|
||||
resolveMaterializedSandboxSkillsWorkspaceDir,
|
||||
resolveProtectedSkillMountContainerPaths,
|
||||
resolveReadOnlyWorkspaceSkillMounts,
|
||||
resolveWorkspaceMounts,
|
||||
} from "./workspace-mounts.js";
|
||||
|
||||
export type SandboxMountPlan = {
|
||||
binds: string[];
|
||||
skippedBinds: string[];
|
||||
readOnlyWorkspaceSkillMounts: ReturnType<typeof resolveReadOnlyWorkspaceSkillMounts>;
|
||||
};
|
||||
|
||||
export async function prepareSandboxMountPlan(params: {
|
||||
engine: SandboxContainerEngine;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workdir: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
binds?: readonly string[];
|
||||
}): Promise<SandboxMountPlan> {
|
||||
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts(params);
|
||||
const managed = resolveWorkspaceMounts({ ...params, readOnlyWorkspaceSkillMounts });
|
||||
const namespace = await resolveDockerSourceNamespace(params.engine);
|
||||
const allowedRoots = [
|
||||
params.workspaceDir,
|
||||
params.agentWorkspaceDir,
|
||||
params.skillsWorkspaceDir ??
|
||||
resolveMaterializedSandboxSkillsWorkspaceDir(params.agentWorkspaceDir),
|
||||
];
|
||||
const protectedTargets = resolveProtectedSkillMountContainerPaths(readOnlyWorkspaceSkillMounts);
|
||||
const custom = filterBindsConflictingWithProtectedMounts(params.binds, protectedTargets);
|
||||
const overriddenTargets = new Set(
|
||||
custom.flatMap((bind) => {
|
||||
const parsed = splitSandboxBindSpec(bind);
|
||||
return parsed ? [normalizeMountContainerPath(parsed.container)] : [];
|
||||
}),
|
||||
);
|
||||
const targets = [
|
||||
...overriddenTargets,
|
||||
...managed.map((mount) => normalizeMountContainerPath(mount.containerPath)),
|
||||
];
|
||||
const binds = new Map<string, string>();
|
||||
for (const mount of managed) {
|
||||
const target = normalizeMountContainerPath(mount.containerPath);
|
||||
if (overriddenTargets.has(target)) {
|
||||
continue;
|
||||
}
|
||||
const translated = namespace
|
||||
? translateSandboxMountSources({
|
||||
source: mount.hostPath,
|
||||
containerPath: target,
|
||||
allowedRoots,
|
||||
mounts: namespace,
|
||||
readOnly: mount.readOnly,
|
||||
shadowedTargets: targets.filter(
|
||||
(other) => other !== target && isPathInside(target, other),
|
||||
),
|
||||
})
|
||||
: [{ ...mount, containerPath: target }];
|
||||
for (const projected of translated) {
|
||||
binds.set(
|
||||
projected.containerPath,
|
||||
`${projected.hostPath}:${projected.containerPath}:${projected.readOnly ? "ro,z" : "z"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Custom mounts retain their daemon-host contract. Protected instruction mounts
|
||||
// win exact collisions; other explicit overrides match filesystem bridge policy.
|
||||
for (const bind of custom) {
|
||||
const parsed = splitSandboxBindSpec(bind);
|
||||
binds.set(parsed ? normalizeMountContainerPath(parsed.container) : bind, bind);
|
||||
}
|
||||
return {
|
||||
binds: [...binds.values()],
|
||||
skippedBinds: (params.binds ?? []).filter((bind) => !custom.includes(bind)),
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sandboxMountPlanMatchesContainer(params: {
|
||||
engine: SandboxContainerEngine;
|
||||
containerName: string;
|
||||
plan: SandboxMountPlan;
|
||||
}): Promise<boolean> {
|
||||
const inspected = await execContainer(
|
||||
params.engine,
|
||||
[
|
||||
"inspect",
|
||||
"--format",
|
||||
'{"Mounts":{{json .Mounts}},"Tmpfs":{{json .HostConfig.Tmpfs}}}',
|
||||
params.containerName,
|
||||
],
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
);
|
||||
const data: unknown = JSON.parse(inspected.stdout);
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Container inspect did not return mount metadata.");
|
||||
}
|
||||
const actual = parseInspectedSandboxMounts(data.Mounts, data.Tmpfs);
|
||||
const expected = new Map(
|
||||
params.plan.binds.flatMap((bind) => {
|
||||
const parsed = splitSandboxBindSpec(bind);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
return [[normalizeMountContainerPath(parsed.container), parsed]] as const;
|
||||
}),
|
||||
);
|
||||
// Compare the whole effective bind set, including removed custom/subtree mounts.
|
||||
// Image-created volumes are unrelated unless they occupy an expected bind target.
|
||||
const managed = actual.filter(
|
||||
(mount) => mount.type === "bind" || expected.has(mount.destination),
|
||||
);
|
||||
return (
|
||||
managed.length === expected.size &&
|
||||
managed.every((mount) => {
|
||||
const bind = expected.get(mount.destination);
|
||||
return (
|
||||
bind !== undefined &&
|
||||
mount.type === "bind" &&
|
||||
getSandboxHostPathPolicyKey(mount.source) === getSandboxHostPathPolicyKey(bind.host) &&
|
||||
mount.writable === !bind.options.split(",").includes("ro")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -5,12 +5,16 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendWorkspaceMountArgs,
|
||||
resolveWorkspaceMounts,
|
||||
filterBindsConflictingWithProtectedMounts,
|
||||
resolveProtectedSkillMountContainerPaths,
|
||||
type ReadOnlyWorkspaceSkillMount,
|
||||
} from "./workspace-mounts.js";
|
||||
|
||||
function mountArgs(mount: ReturnType<typeof resolveWorkspaceMounts>[number]): string[] {
|
||||
return ["-v", `${mount.hostPath}:${mount.containerPath}:${mount.readOnly ? "ro,z" : "z"}`];
|
||||
}
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
function makeTempWorkspace(): string {
|
||||
|
|
@ -25,20 +29,18 @@ afterEach(() => {
|
|||
}
|
||||
});
|
||||
|
||||
describe("appendWorkspaceMountArgs", () => {
|
||||
describe("resolveWorkspaceMounts", () => {
|
||||
it.each([
|
||||
{ access: "rw" as const, expected: "/tmp/workspace:/workspace:z" },
|
||||
{ access: "ro" as const, expected: "/tmp/workspace:/workspace:ro,z" },
|
||||
{ access: "none" as const, expected: "/tmp/workspace:/workspace:z" },
|
||||
])("sets main mount permissions for workspaceAccess=$access", ({ access, expected }) => {
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/agent-workspace",
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: access,
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
expect(args).toContain(expected);
|
||||
});
|
||||
|
|
@ -46,42 +48,36 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
it("omits agent workspace mount when workspaceAccess is none", () => {
|
||||
const workspaceDir = makeTempWorkspace();
|
||||
const agentWorkspaceDir = makeTempWorkspace();
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "none",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
expect(args).toEqual(["-v", `${workspaceDir}:/workspace:z`]);
|
||||
});
|
||||
|
||||
it("omits agent workspace mount when paths are identical", () => {
|
||||
const workspaceDir = makeTempWorkspace();
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith(workspaceDir));
|
||||
expect(mounts).toEqual([`${workspaceDir}:/workspace:z`]);
|
||||
});
|
||||
|
||||
it("marks split agent workspace mounts shared for SELinux", () => {
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/agent-workspace",
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "ro",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
|
||||
expect(mounts).toEqual(["/tmp/workspace:/workspace:ro,z", "/tmp/agent-workspace:/agent:ro,z"]);
|
||||
|
|
@ -94,14 +90,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
fs.mkdirSync(path.join(agentWorkspaceDir, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(agentWorkspaceDir, "skills", "demo", "SKILL.md"), "# Demo\n");
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith(agentWorkspaceDir));
|
||||
expect(mounts).toEqual([
|
||||
|
|
@ -118,14 +112,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
fs.mkdirSync(path.join(outsideDir, "demo"), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(agentWorkspaceDir, "skills"), "dir");
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith(agentWorkspaceDir));
|
||||
expect(mounts).toEqual([`${agentWorkspaceDir}:/workspace:z`]);
|
||||
|
|
@ -139,14 +131,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
fs.mkdirSync(path.join(outsideDir, "skills", "demo"), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(agentWorkspaceDir, ".agents"), "dir");
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith(agentWorkspaceDir));
|
||||
expect(mounts).toEqual([`${agentWorkspaceDir}:/workspace:z`]);
|
||||
|
|
@ -163,14 +153,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
"# Demo\n",
|
||||
);
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter((arg) => arg.startsWith(agentWorkspaceDir));
|
||||
expect(mounts).toEqual([
|
||||
|
|
@ -186,15 +174,13 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
fs.mkdirSync(path.join(materializedSkillsDir, "demo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(materializedSkillsDir, "demo", "SKILL.md"), "# Demo\n");
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter(
|
||||
(arg) => arg.startsWith(agentWorkspaceDir) || arg.startsWith(skillsWorkspaceDir),
|
||||
|
|
@ -210,14 +196,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
const sandboxWorkspaceDir = makeTempWorkspace();
|
||||
fs.mkdirSync(path.join(sandboxWorkspaceDir, "skills", "demo"), { recursive: true });
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: sandboxWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "ro",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter(
|
||||
(arg) => arg.startsWith(agentWorkspaceDir) || arg.startsWith(sandboxWorkspaceDir),
|
||||
|
|
@ -239,14 +223,12 @@ describe("appendWorkspaceMountArgs", () => {
|
|||
fs.mkdirSync(path.join(sandboxWorkspaceDir, ".agents", "skills"), { recursive: true });
|
||||
fs.mkdirSync(path.join(agentWorkspaceDir, "skills", "host-only"), { recursive: true });
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
const args = resolveWorkspaceMounts({
|
||||
workspaceDir: sandboxWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "none",
|
||||
});
|
||||
}).flatMap(mountArgs);
|
||||
|
||||
const mounts = args.filter(
|
||||
(arg) => arg.startsWith(agentWorkspaceDir) || arg.startsWith(sandboxWorkspaceDir),
|
||||
|
|
|
|||
|
|
@ -21,14 +21,6 @@ export type ReadOnlyWorkspaceSkillMount = {
|
|||
containerPath: string;
|
||||
};
|
||||
|
||||
function formatManagedWorkspaceBind(params: {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readOnly: boolean;
|
||||
}): string {
|
||||
return `${params.hostPath}:${params.containerPath}:${params.readOnly ? "ro,z" : "z"}`;
|
||||
}
|
||||
|
||||
function containerJoin(root: string, ...parts: string[]): string {
|
||||
const normalizedRoot = root.endsWith("/") && root !== "/" ? root.slice(0, -1) : root;
|
||||
const suffix = parts
|
||||
|
|
@ -38,7 +30,7 @@ function containerJoin(root: string, ...parts: string[]): string {
|
|||
return suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot;
|
||||
}
|
||||
|
||||
function normalizeMountContainerPath(containerPath: string): string {
|
||||
export function normalizeMountContainerPath(containerPath: string): string {
|
||||
return normalizeContainerPathCore(containerPath).replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
|
|
@ -117,13 +109,6 @@ export function resolveReadOnlyWorkspaceSkillMounts(params: {
|
|||
.map(({ hostPath, containerPath }) => ({ hostPath, containerPath }));
|
||||
}
|
||||
|
||||
/** Returns stable mount state for sandbox config hashes. */
|
||||
export function formatReadOnlyWorkspaceSkillMountHashState(
|
||||
mounts: readonly ReadOnlyWorkspaceSkillMount[],
|
||||
): string[] {
|
||||
return mounts.map((mount) => `${mount.hostPath}:${mount.containerPath}:ro`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of container paths that are protected by read-only skill mounts.
|
||||
*
|
||||
|
|
@ -166,68 +151,37 @@ export function filterBindsConflictingWithProtectedMounts(
|
|||
return filtered;
|
||||
}
|
||||
|
||||
/** Appends Docker `-v` args for read-only skill mounts. */
|
||||
export function appendReadOnlyWorkspaceSkillMountArgs(params: {
|
||||
args: string[];
|
||||
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
}): void {
|
||||
for (const mount of params.readOnlyWorkspaceSkillMounts) {
|
||||
params.args.push(
|
||||
"-v",
|
||||
formatManagedWorkspaceBind({
|
||||
hostPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
readOnly: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
export type ManagedWorkspaceMount = ReadOnlyWorkspaceSkillMount & { readOnly: boolean };
|
||||
|
||||
/** Appends Docker workspace mount args for the project, agent workspace, and skill overlays. */
|
||||
export function appendWorkspaceMountArgs(params: {
|
||||
args: string[];
|
||||
/** Resolves Gateway-local sources before the container lifecycle selects daemon paths. */
|
||||
export function resolveWorkspaceMounts(params: {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workdir: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
readOnlyWorkspaceSkillMounts?: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
includeReadOnlyWorkspaceSkillMounts?: boolean;
|
||||
}) {
|
||||
const { args, workspaceDir, agentWorkspaceDir, workdir, workspaceAccess } = params;
|
||||
|
||||
args.push(
|
||||
"-v",
|
||||
formatManagedWorkspaceBind({
|
||||
}): ManagedWorkspaceMount[] {
|
||||
const { workspaceDir, agentWorkspaceDir, workdir, workspaceAccess } = params;
|
||||
const mounts: ManagedWorkspaceMount[] = [
|
||||
{
|
||||
hostPath: workspaceDir,
|
||||
containerPath: workdir,
|
||||
readOnly: workspaceAccess === "ro",
|
||||
}),
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
if (workspaceAccess !== "none" && workspaceDir !== agentWorkspaceDir) {
|
||||
args.push(
|
||||
"-v",
|
||||
formatManagedWorkspaceBind({
|
||||
hostPath: agentWorkspaceDir,
|
||||
containerPath: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||
readOnly: workspaceAccess === "ro",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (params.includeReadOnlyWorkspaceSkillMounts !== false) {
|
||||
appendReadOnlyWorkspaceSkillMountArgs({
|
||||
args,
|
||||
readOnlyWorkspaceSkillMounts:
|
||||
params.readOnlyWorkspaceSkillMounts ??
|
||||
resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir,
|
||||
workspaceAccess,
|
||||
}),
|
||||
mounts.push({
|
||||
hostPath: agentWorkspaceDir,
|
||||
containerPath: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||
readOnly: workspaceAccess === "ro",
|
||||
});
|
||||
}
|
||||
|
||||
const skills = params.readOnlyWorkspaceSkillMounts ?? resolveReadOnlyWorkspaceSkillMounts(params);
|
||||
for (const { hostPath, containerPath } of skills) {
|
||||
mounts.push({ hostPath, containerPath, readOnly: true });
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -911,14 +911,22 @@ fi
|
|||
expect(runner).toContain("--network host");
|
||||
expect(runner).toContain('--group-add "$SOCKET_GID"');
|
||||
expect(runner).toContain('-v "$DOCKER_SOCKET:/var/run/docker.sock"');
|
||||
expect(runner).toContain('-v "$SCENARIO_ROOT:$SCENARIO_ROOT"');
|
||||
expect(runner).toContain('-v "$SCENARIO_ROOT:$GATEWAY_ROOT"');
|
||||
expect(runner).toContain('-v "$SCENARIO_ROOT/agent workspace:$GATEWAY_ROOT/workspace"');
|
||||
expect(runner).toContain('-v "$SCENARIO_ROOT/nested data:$GATEWAY_ROOT/workspace/data:ro"');
|
||||
expect(runner).toContain("scripts/docker/sandbox/Dockerfile.browser");
|
||||
expect(scenario).toContain('from "openclaw/plugin-sdk/agent-harness-runtime"');
|
||||
expectTextToIncludeInOrder(scenario, [
|
||||
"process.env.HOME =",
|
||||
"process.env.OPENCLAW_STATE_DIR =",
|
||||
"process.env.OPENCLAW_CONFIG_PATH =",
|
||||
'await import("openclaw/plugin-sdk/agent-harness-runtime")',
|
||||
]);
|
||||
expect(scenario).not.toMatch(/from\s+["']openclaw\/plugin-sdk\/agent-harness-runtime["']/u);
|
||||
expect(scenario).toContain('"sandbox", "list", "--browser", "--json"');
|
||||
expect(scenario).not.toMatch(/from\s+["'][.]{1,2}\/.*src\//u);
|
||||
expect(scenario).not.toMatch(/(?:from\s+|import\s*\(\s*)["'][.]{1,2}\/.*src\//u);
|
||||
});
|
||||
|
||||
it("cleans only the sidecar task's containers when the runner exits early", () => {
|
||||
it("cleans all sidecar modes without touching another run on the same Gateway workspace", () => {
|
||||
const workDir = realpathSync(tempDirs.make("openclaw-sidecar-cleanup-"));
|
||||
const binDir = join(workDir, "bin");
|
||||
const scenarioRoot = join(workDir, "scenario");
|
||||
|
|
@ -926,20 +934,28 @@ fi
|
|||
const containersPath = join(workDir, "containers.json");
|
||||
const sessionKey = "agent:main:sandbox-browser-sidecar";
|
||||
const workspaceHash = createHash("sha256")
|
||||
.update(join(scenarioRoot, "workspace"))
|
||||
.update("/home/appuser/.openclaw-e2e/workspace")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
const scopeKey = `${sessionKey}:workspace:${workspaceHash}`;
|
||||
const unrelated = { name: "other-workspace", scopeKey: `${sessionKey}:workspace:other` };
|
||||
writeFileSync(
|
||||
containersPath,
|
||||
JSON.stringify([
|
||||
{ name: "short-normal-sandbox", scopeKey },
|
||||
{ name: "short-browser-sidecar", scopeKey },
|
||||
unrelated,
|
||||
]),
|
||||
);
|
||||
const unrelated = [
|
||||
{ name: "other-run", scopeKey: `${sessionKey}:other-run:rw:workspace:${workspaceHash}` },
|
||||
{ name: "other-workspace", scopeKey: `${sessionKey}:other-run:rw:workspace:other` },
|
||||
];
|
||||
const seedContainers = `
|
||||
const sessionKey = ${JSON.stringify(sessionKey)} + ":" + process.argv[1];
|
||||
const containers = ["none", "ro", "rw"].flatMap((access) =>
|
||||
["sandbox", "browser"].map((kind) => ({
|
||||
name: "short-" + access + "-" + kind,
|
||||
scopeKey: sessionKey + ":" + access + ":workspace:" + ${JSON.stringify(workspaceHash)},
|
||||
})),
|
||||
);
|
||||
require("node:fs").writeFileSync(${JSON.stringify(containersPath)}, JSON.stringify([
|
||||
...containers,
|
||||
...${JSON.stringify(unrelated)},
|
||||
]));
|
||||
`;
|
||||
writeExecutables(binDir, {
|
||||
date: "#!/bin/sh\nprintf '1700000000\\n'\n",
|
||||
mktemp: `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
|
|
@ -971,18 +987,24 @@ if (args[0] === "ps") {
|
|||
`,
|
||||
});
|
||||
|
||||
const result = spawnSync("bash", [SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
||||
OPENCLAW_DOCKER_SOCKET: join(workDir, "missing.sock"),
|
||||
// exec preserves the shell PID used by RUN_ID; the fixed date supplies its
|
||||
// other component without deriving expected labels from the cleanup filter.
|
||||
const result = spawnDockerSnippet(
|
||||
'node -e "$1" "$$-1700000000"\nexec /bin/bash "$2"',
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
||||
OPENCLAW_DOCKER_SOCKET: join(workDir, "missing.sock"),
|
||||
},
|
||||
},
|
||||
});
|
||||
["sidecar-cleanup", seedContainers, SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH],
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("Docker socket not found:");
|
||||
expect(JSON.parse(readFileSync(containersPath, "utf8"))).toEqual([unrelated]);
|
||||
expect(JSON.parse(readFileSync(containersPath, "utf8"))).toEqual(unrelated);
|
||||
expect(existsSync(scenarioRoot)).toBe(false);
|
||||
expect(existsSync(buildRoot)).toBe(false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue