fix(agents): serialize sandbox provisioning (#115645)

* fix(agents): serialize sandbox provisioning

* test(docker): add sandbox browser sidecar e2e

* fix(ci): register sandbox browser e2e entrypoint

* fix(ci): restore code mode matrix checks
This commit is contained in:
Vincent Koc
2026-07-29 14:50:15 +08:00
committed by GitHub
parent c193ffd554
commit 9daa60961c
15 changed files with 627 additions and 29 deletions
+1
View File
@@ -52,6 +52,7 @@ const repositoryScriptEntries = [
"scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs!",
"scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs!",
"scripts/e2e/lib/run-with-pty.mjs!",
"scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs!",
"scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs!",
"scripts/embedded-run-abort-leak.ts!",
"scripts/fixtures/packed-plugin-sdk-type-smoke.ts!",
+1
View File
@@ -1709,6 +1709,7 @@
"test:docker:agents-delete-shared-workspace": "bash scripts/e2e/agents-delete-shared-workspace-docker.sh",
"test:docker:all": "node scripts/test-docker-all.mjs",
"test:docker:browser-cdp-snapshot": "bash scripts/e2e/browser-cdp-snapshot-docker.sh",
"test:docker:sandbox-browser-sidecar": "bash scripts/e2e/sandbox-browser-sidecar-docker.sh",
"test:docker:bundled-plugin-install-uninstall": "bash scripts/e2e/bundled-plugin-install-uninstall-docker.sh",
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
"test:docker:commitments-safety": "bash scripts/e2e/commitments-safety-docker.sh",
+32 -2
View File
@@ -47,13 +47,43 @@ function walk(dir, out = []) {
return out;
}
function findRelativeModuleSpecifiers(text) {
const specifiers = new Set();
for (const pattern of [
/(?:\bfrom\s*|\bimport\s*\()\s*["']([^"']+)["']/gu,
/\bimport\s*["']([^"']+)["']/gu,
]) {
for (const match of text.matchAll(pattern)) {
const specifier = match[1];
if (specifier?.startsWith(".")) {
specifiers.add(specifier);
}
}
}
return [...specifiers];
}
function isPathWithin(parent, candidate) {
const relative = path.relative(parent, candidate);
return (
relative === "" ||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
);
}
for (const relativePath of walk("scripts/e2e")) {
if (!/\.(?:sh|ts|mjs|js)$/u.test(relativePath)) {
continue;
}
const text = readText(relativePath);
if (/from\s+["']\.\.\/\.\.\/src\//u.test(text) || /import\(["']\.\.\/\.\.\/src\//u.test(text)) {
errors.push(`${relativePath}: Docker E2E harness must import built dist, not ../../src`);
const sourceImport = findRelativeModuleSpecifiers(text).find((specifier) => {
const resolved = path.resolve(ROOT_DIR, path.dirname(relativePath), specifier);
return isPathWithin(path.join(ROOT_DIR, "src"), resolved);
});
if (sourceImport) {
errors.push(
`${relativePath}: Docker E2E harness must import package exports, not ${sourceImport}`,
);
}
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
+9 -7
View File
@@ -14,7 +14,7 @@ import {
validateQaEvidenceSummaryJson,
type QaEvidenceStatus,
type QaEvidenceSummaryJson,
} from "../extensions/qa-lab/api.ts";
} from "../extensions/qa-lab/api.js";
import type { AgentExecEnvelope } from "../src/commands/agent-exec.ts";
import { previewForDevToolLog, redactJsonValueForDevToolLog } from "./lib/dev-tooling-safety.ts";
@@ -401,12 +401,14 @@ async function canonicalizeExistingPathPrefix(value: string): Promise<string> {
async function runtimeArtifactDirectories(repoRoot: string, outputDir: string): Promise<string[]> {
const packagesRoot = path.join(repoRoot, "packages");
const packageEntries = await fs.readdir(packagesRoot, { withFileTypes: true }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
});
const packageEntries = await fs
.readdir(packagesRoot, { withFileTypes: true })
.catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
});
const artifacts = [
path.join(repoRoot, "dist"),
...packageEntries
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
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);
function requireEnv(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const root = requireEnv("OPENCLAW_E2E_ROOT");
const sandboxImage = requireEnv("OPENCLAW_E2E_SANDBOX_IMAGE");
const browserImage = requireEnv("OPENCLAW_E2E_BROWSER_IMAGE");
const sandboxPrefix = requireEnv("OPENCLAW_E2E_SANDBOX_PREFIX");
const browserPrefix = requireEnv("OPENCLAW_E2E_BROWSER_PREFIX");
const browserNetwork = requireEnv("OPENCLAW_E2E_BROWSER_NETWORK");
const stateDir = path.join(root, "state");
const workspaceDir = path.join(root, "workspace");
const sandboxRoot = path.join(root, "sandboxes");
const configPath = path.join(stateDir, "openclaw.json");
const sessionKey = "agent:main:sandbox-browser-sidecar";
const browserToken = `sandbox-browser-sidecar-${process.pid}`;
const marker = `OPENCLAW_SANDBOX_BROWSER_SIDECAR_${process.pid}`;
const ownedContainerNames = new Set();
process.env.HOME = path.join(root, "home");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
const config = {
gateway: {
auth: {
mode: "token",
token: browserToken,
},
},
browser: {
enabled: true,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
},
},
},
agents: {
defaults: {
workspace: workspaceDir,
sandbox: {
mode: "all",
backend: "docker",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: sandboxRoot,
docker: {
image: sandboxImage,
containerPrefix: sandboxPrefix,
network: "none",
extraHosts: ["host.docker.internal:host-gateway"],
},
browser: {
enabled: true,
image: browserImage,
containerPrefix: browserPrefix,
network: browserNetwork,
headless: true,
noVncEnabled: false,
autoStart: true,
autoStartTimeoutMs: 120_000,
},
},
},
},
};
async function run(command, args, options = {}) {
return await execFileAsync(command, args, {
cwd: "/app",
env: process.env,
maxBuffer: 8 * 1024 * 1024,
...options,
});
}
async function docker(args, options) {
return await run("docker", args, options);
}
async function listTaskContainers() {
const { stdout } = await docker(["ps", "-a", "--format", "{{.Names}}"]);
return stdout
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((name) => name.startsWith(sandboxPrefix) || name.startsWith(browserPrefix));
}
async function cleanupTaskResources() {
const names = new Set([...ownedContainerNames, ...(await listTaskContainers().catch(() => []))]);
for (const name of names) {
await docker(["rm", "-f", name]).catch(() => undefined);
}
await docker(["network", "rm", browserNetwork]).catch(() => undefined);
}
function requestJson(baseUrl, requestPath, init = {}) {
return fetch(`${baseUrl}${requestPath}`, {
...init,
headers: {
Authorization: `Bearer ${browserToken}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
}).then(async (response) => {
const text = await response.text();
assert.equal(response.ok, true, `${requestPath} failed (${response.status}): ${text}`);
return text ? JSON.parse(text) : {};
});
}
async function startFixtureServer() {
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html><html><body><main><h1>${marker}</h1></main></body></html>`);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "0.0.0.0", resolve);
});
const address = server.address();
assert(address && typeof address === "object", "fixture server did not bind a TCP port");
return {
server,
port: address.port,
};
}
await fs.mkdir(process.env.HOME, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
const fixture = await startFixtureServer();
try {
const params = {
config,
agentId: "main",
sessionKey,
workspaceDir,
};
const [first, second] = await Promise.all([
resolveSandboxContext(params),
resolveSandboxContext(params),
]);
assert(first, "first sandbox context was not provisioned");
assert(second, "second sandbox context was not provisioned");
assert(first.browser, "first sandbox browser context was not provisioned");
assert(second.browser, "second sandbox browser context was not provisioned");
assert.equal(first.containerName, second.containerName, "concurrent calls split sandbox runtime");
assert.equal(
first.browser.containerName,
second.browser.containerName,
"concurrent calls split browser runtime",
);
assert.equal(
first.browser.bridgeUrl,
second.browser.bridgeUrl,
"concurrent calls split browser bridge",
);
const { stdout: gatewayStdout } = await docker([
"network",
"inspect",
"-f",
"{{(index .IPAM.Config 0).Gateway}}",
browserNetwork,
]);
const gateway = gatewayStdout.trim();
assert(gateway, "browser Docker network did not report a gateway");
const fixtureUrl = `http://${gateway}:${fixture.port}/`;
ownedContainerNames.add(first.containerName);
ownedContainerNames.add(first.browser.containerName);
const unauthenticated = await fetch(`${first.browser.bridgeUrl}/`);
assert.equal(unauthenticated.status, 401, "browser bridge accepted an unauthenticated request");
await unauthenticated.body?.cancel();
const opened = await requestJson(first.browser.bridgeUrl, "/tabs/open", {
method: "POST",
body: JSON.stringify({ url: fixtureUrl }),
});
assert.equal(typeof opened.targetId, "string", "bridge did not return an opened tab");
const snapshot = await requestJson(
first.browser.bridgeUrl,
`/snapshot?format=ai&targetId=${encodeURIComponent(opened.targetId)}`,
);
assert.equal(snapshot.format, "ai", "bridge returned the wrong snapshot format");
assert.match(snapshot.snapshot, new RegExp(marker), "snapshot did not contain the HTML marker");
const { stdout: listStdout } = await run("openclaw", ["sandbox", "list", "--browser", "--json"]);
const listed = JSON.parse(listStdout);
const browserEntry = listed.browsers?.find(
(entry) => entry.containerName === first.browser.containerName,
);
assert(browserEntry, "packaged sandbox list did not report the browser container");
assert.equal(browserEntry.sessionKey, sessionKey);
assert.equal(browserEntry.running, true);
await run("openclaw", ["sandbox", "recreate", "--browser", "--session", sessionKey, "--force"]);
const remaining = await listTaskContainers();
assert(
!remaining.includes(first.browser.containerName),
"packaged recreate kept browser container",
);
assert(remaining.includes(first.containerName), "packaged recreate removed normal sandbox");
process.stdout.write(
`${JSON.stringify({
ok: true,
sandboxContainer: first.containerName,
browserContainer: first.browser.containerName,
marker,
})}\n`,
);
} finally {
await new Promise((resolve) => {
fixture.server.close(resolve);
});
await cleanupTaskResources();
}
// resolveSandboxContext owns an in-process bridge server that intentionally
// stays live for agent reuse. This standalone scenario has finished its cleanup.
process.exit(0);
+12 -2
View File
@@ -11,7 +11,7 @@ import {
clampTimerTimeoutMs,
finiteSecondsToTimerSafeMilliseconds,
} from "@openclaw/normalization-core/number-coercion";
import { formatDurationCompact } from "../../../src/infra/format-time/format-duration.ts";
import prettyMilliseconds from "pretty-ms";
import {
die,
ensureValue,
@@ -532,7 +532,17 @@ function platformRecord<T>(value: T): Record<Platform, T> {
}
function formatDuration(durationMs: number): string {
return formatDurationCompact(durationMs, { spaced: true }) ?? "0ms";
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return "0ms";
}
const roundedMs = Math.round(durationMs);
if (roundedMs < 1000) {
return prettyMilliseconds(roundedMs);
}
return prettyMilliseconds(Math.round(durationMs / 1000) * 1000, {
hideYear: true,
unitCount: 2,
});
}
function readHarnessCheckoutVersion(): string {
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
FUNCTIONAL_IMAGE="$(docker_e2e_resolve_image \
"openclaw-sandbox-browser-sidecar-functional:local" \
OPENCLAW_SANDBOX_BROWSER_SIDECAR_FUNCTIONAL_IMAGE)"
RUNNER_IMAGE="${OPENCLAW_SANDBOX_BROWSER_SIDECAR_RUNNER_IMAGE:-openclaw-sandbox-browser-sidecar-e2e:${OPENCLAW_DOCKER_ALL_LANE_NAME:-local}}"
SANDBOX_IMAGE="${OPENCLAW_SANDBOX_IMAGE:-openclaw-sandbox:bookworm-slim}"
BROWSER_IMAGE="${OPENCLAW_SANDBOX_BROWSER_IMAGE:-openclaw-sandbox-browser:bookworm-slim}"
RUN_ID="$$-$(date +%s)"
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)"
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"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_SANDBOX_BROWSER_SIDECAR_DOCKER_TIMEOUT:-1200s}"
docker_socket_gid() {
if stat -c "%g" "$DOCKER_SOCKET" >/dev/null 2>&1; then
stat -c "%g" "$DOCKER_SOCKET"
return
fi
stat -f "%g" "$DOCKER_SOCKET"
}
remove_prefixed_containers() {
local prefix="$1"
local name
while IFS= read -r name; do
case "$name" in
"$prefix"*) docker_e2e_docker_cmd rm -f "$name" >/dev/null 2>&1 || true ;;
esac
done < <(docker_e2e_docker_cmd ps -a --format '{{.Names}}' 2>/dev/null || true)
}
cleanup() {
remove_prefixed_containers "$SANDBOX_PREFIX"
remove_prefixed_containers "$BROWSER_PREFIX"
docker_e2e_docker_cmd network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
docker_e2e_docker_cmd run --rm --user 0:0 \
-v "$SCENARIO_ROOT:/target" \
"$SANDBOX_IMAGE" \
sh -c 'rm -rf /target/* /target/.[!.]* /target/..?*' >/dev/null 2>&1 || true
rm -rf "$SCENARIO_ROOT" "$BUILD_DIR"
}
trap cleanup EXIT
if [ ! -S "$DOCKER_SOCKET" ]; then
echo "Docker socket not found: $DOCKER_SOCKET" >&2
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"
docker_e2e_build_or_reuse \
"$FUNCTIONAL_IMAGE" \
sandbox-browser-sidecar-functional \
"$ROOT_DIR/scripts/e2e/Dockerfile" \
"$ROOT_DIR" \
functional
docker_build_run sandbox-browser-sidecar-sandbox-build \
-t "$SANDBOX_IMAGE" \
-f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile" \
"$ROOT_DIR"
docker_build_run sandbox-browser-sidecar-browser-build \
-t "$BROWSER_IMAGE" \
-f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.browser" \
"$ROOT_DIR"
cat >"$BUILD_DIR/Dockerfile" <<'EOF'
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends docker.io \
&& rm -rf /var/lib/apt/lists/*
USER appuser
EOF
docker_build_run sandbox-browser-sidecar-runner-build \
--build-arg "BASE_IMAGE=$FUNCTIONAL_IMAGE" \
-t "$RUNNER_IMAGE" \
-f "$BUILD_DIR/Dockerfile" \
"$BUILD_DIR"
SOCKET_GID="$(docker_socket_gid)"
echo "Running package-backed sandbox browser sidecar Docker E2E..."
docker_e2e_run_logged_print_with_harness sandbox-browser-sidecar \
--network host \
--group-add "$SOCKET_GID" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_E2E_ROOT=$SCENARIO_ROOT" \
-e "OPENCLAW_E2E_SANDBOX_IMAGE=$SANDBOX_IMAGE" \
-e "OPENCLAW_E2E_BROWSER_IMAGE=$BROWSER_IMAGE" \
-e "OPENCLAW_E2E_SANDBOX_PREFIX=$SANDBOX_PREFIX" \
-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_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'
echo "Sandbox browser sidecar Docker E2E passed."
+9
View File
@@ -472,6 +472,15 @@ export const mainLanes = [
timeoutMs: 20 * 60 * 1000,
weight: 3,
}),
serviceLane(
"sandbox-browser-sidecar",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:sandbox-browser-sidecar",
{
stateScenario: "empty",
timeoutMs: 20 * 60 * 1000,
weight: 4,
},
),
serviceLane(
"agents-delete-shared-workspace",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace",
+49
View File
@@ -363,6 +363,55 @@ describe("ensureSandboxBrowser create args", () => {
expect(createArgs).toContain(`openclaw.createArgsEpoch=${SANDBOX_DOCKER_CREATE_ARGS_EPOCH}`);
});
it("serializes concurrent provisioning for the same browser container", async () => {
let created = false;
let cdpAuthToken: string | undefined;
let configHash: string | undefined;
dockerMocks.dockerContainerState.mockImplementation(async () => ({
exists: created,
running: created,
}));
dockerMocks.readDockerContainerEnvVar.mockImplementation(async (_containerName, key) =>
key === "OPENCLAW_BROWSER_CDP_AUTH_TOKEN" ? (cdpAuthToken ?? null) : null,
);
dockerMocks.readDockerContainerLabel.mockImplementation(async () => configHash ?? null);
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") {
if (created) {
throw new Error("docker name conflict");
}
created = true;
cdpAuthToken = collectDockerFlagValues(args, "-e")
.find((entry) => entry.startsWith("OPENCLAW_BROWSER_CDP_AUTH_TOKEN="))
?.slice("OPENCLAW_BROWSER_CDP_AUTH_TOKEN=".length);
configHash = collectDockerFlagValues(args, "--label")
.find((entry) => entry.startsWith("openclaw.configHash="))
?.slice("openclaw.configHash=".length);
}
return { stdout: "", stderr: "", code: 0 };
});
const params = {
scopeKey: "session:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: buildConfig(false),
};
await expect(
Promise.all([ensureTestSandboxBrowser(params), ensureTestSandboxBrowser(params)]),
).resolves.toHaveLength(2);
expect(dockerMocks.execDocker.mock.calls.filter(([args]) => args[0] === "create")).toHaveLength(
1,
);
expect(dockerMocks.execDocker.mock.calls.filter(([args]) => args[0] === "start")).toHaveLength(
1,
);
});
it("recreates a cold browser container when the shared args epoch changes", async () => {
const cfg = buildConfig(false);
const oldHash = computeTestBrowserHash({
+29 -7
View File
@@ -19,6 +19,7 @@ import {
resolveProfile,
type ResolvedBrowserConfig,
} from "../../plugin-sdk/browser-profiles.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { defaultRuntime } from "../../runtime.js";
import {
BROWSER_BRIDGES,
@@ -69,6 +70,8 @@ const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
const CDP_AUTH_TOKEN_ENV_KEY = "OPENCLAW_BROWSER_CDP_AUTH_TOKEN";
const SANDBOX_BROWSER_IMAGE_CONTRACT_LABEL = "org.openclaw.sandbox-browser.contract";
const browserContainerLifecycleQueue = new KeyedAsyncQueue();
const browserNetworkLifecycleQueue = new KeyedAsyncQueue();
function buildSandboxCdpAuthHeader(token: string): string {
return `Basic ${Buffer.from(`openclaw:${token}`).toString("base64")}`;
@@ -212,14 +215,16 @@ async function ensureDockerNetwork(
if (!normalized || normalized === "bridge" || normalized === "none") {
return;
}
const inspect = await execDocker(["network", "inspect", network], { allowFailure: true });
if (inspect.code === 0) {
return;
}
await execDocker(["network", "create", "--driver", "bridge", network]);
await browserNetworkLifecycleQueue.enqueue(normalized, async () => {
const inspect = await execDocker(["network", "inspect", network], { allowFailure: true });
if (inspect.code === 0) {
return;
}
await execDocker(["network", "create", "--driver", "bridge", network]);
});
}
export async function ensureSandboxBrowser(params: {
type EnsureSandboxBrowserParams = {
scopeKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
@@ -228,7 +233,11 @@ export async function ensureSandboxBrowser(params: {
evaluateEnabled?: boolean;
bridgeAuth?: { token?: string; password?: string };
ssrfPolicy?: SsrFPolicy;
}): Promise<SandboxBrowserContext | null> {
};
export async function ensureSandboxBrowser(
params: EnsureSandboxBrowserParams,
): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) {
return null;
}
@@ -243,6 +252,19 @@ export async function ensureSandboxBrowser(params: {
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
const containerName = buildSandboxContainerName(params.cfg.browser.containerPrefix, slug);
// Independent agent runs can converge on one Docker resource. Serialize the
// full lifecycle so followers re-read container and bridge state after the
// preceding create, start, or replacement has settled.
return await browserContainerLifecycleQueue.enqueue(containerName, async () => {
return await ensureSandboxBrowserContainer(params, containerName);
});
}
async function ensureSandboxBrowserContainer(
params: EnsureSandboxBrowserParams,
containerName: string,
): Promise<SandboxBrowserContext> {
let existing = BROWSER_BRIDGES.get(params.scopeKey);
const stopExistingForContainer = async () => {
await stopCachedBrowserBridgesForContainer(containerName);
@@ -20,6 +20,7 @@ type SpawnCall = {
const spawnState = vi.hoisted(() => ({
calls: [] as SpawnCall[],
containerExists: true,
inspectRunning: true,
labelHash: "",
}));
@@ -63,19 +64,43 @@ async function spawnDockerProcess(commandAndArgs: string[]) {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
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"')
) {
stdout = `${spawnState.labelHash}\n`;
} else if (
(args[0] === "rm" && args[1] === "-f") ||
(args[0] === "image" && args[1] === "inspect") ||
args[0] === "create" ||
args[0] === "start"
) {
if (!spawnState.containerExists) {
code = 1;
stderr = "No such object";
} else {
stdout = `${spawnState.labelHash}\n`;
}
} 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;
@@ -193,6 +218,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
beforeEach(async () => {
spawnState.calls.length = 0;
spawnState.containerExists = true;
spawnState.inspectRunning = true;
spawnState.labelHash = "";
registryMocks.readRegistryEntry.mockClear();
@@ -202,6 +228,31 @@ describe("ensureSandboxContainer config-hash recreation", () => {
await loadFreshDockerModuleForTest();
});
it("serializes concurrent provisioning for one container", async () => {
const workspaceDir = makeTempDir();
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
spawnState.containerExists = false;
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const params = {
sessionKey: "agent:main:session-1",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
};
const [first, second] = await Promise.all([
ensureSandboxContainer(params),
ensureSandboxContainer(params),
]);
expect(first).toBe("oc-test-shared");
expect(second).toBe(first);
expect(spawnState.calls.filter((call) => call.args[0] === "create")).toHaveLength(1);
expect(spawnState.calls.filter((call) => call.args[0] === "start")).toHaveLength(1);
expect(registryMocks.updateRegistry).toHaveBeenCalledTimes(2);
});
it("recreates shared container when array-order change alters hash", async () => {
// Docker flag order is part of the runtime contract, so order-sensitive
// config changes must invalidate a shared container.
+19 -2
View File
@@ -84,6 +84,7 @@ export async function execDockerRaw(
}
import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import {
computeSandboxConfigHash,
SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
@@ -110,6 +111,7 @@ import {
const log = createSubsystemLogger("docker");
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
const sandboxContainerLifecycleQueue = new KeyedAsyncQueue();
type ExecDockerOptions = ExecDockerRawOptions;
@@ -493,17 +495,32 @@ async function readContainerConfigHash(containerName: string): Promise<string |
return await readDockerContainerLabel(containerName, "openclaw.configHash");
}
export async function ensureSandboxContainer(params: {
type EnsureSandboxContainerParams = {
sessionKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
skillsWorkspaceDir?: string;
cfg: SandboxConfig;
requireCurrentConfig?: boolean;
}) {
};
export async function ensureSandboxContainer(params: EnsureSandboxContainerParams) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const containerName = buildSandboxContainerName(params.cfg.docker.containerPrefix, slug);
// Independent agent runs can converge on one Docker resource. Serialize the
// full lifecycle so followers re-read state after create, start, or replace.
return await sandboxContainerLifecycleQueue.enqueue(containerName, async () => {
return await ensureSandboxContainerLifecycle(params, scopeKey, containerName);
});
}
async function ensureSandboxContainerLifecycle(
params: EnsureSandboxContainerParams,
scopeKey: string,
containerName: string,
) {
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
+1 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { validateQaEvidenceSummaryJson } from "../../extensions/qa-lab/api.ts";
import { validateQaEvidenceSummaryJson } from "../../extensions/qa-lab/api.js";
import {
buildCodeModeMatrixAgentEnv,
classifyCodeModeMatrixCell,
+23
View File
@@ -107,9 +107,13 @@ const UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH =
"scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh";
const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh";
const BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH = "scripts/e2e/browser-cdp-snapshot-docker.sh";
const SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH = "scripts/e2e/sandbox-browser-sidecar-docker.sh";
const SANDBOX_BROWSER_SIDECAR_SCENARIO_PATH =
"scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs";
const CENTRALIZED_BUILD_SCRIPTS = [
"scripts/docker/setup.sh",
BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH,
SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH,
"scripts/e2e/qr-import-docker.sh",
"scripts/lib/docker-e2e-image.sh",
"scripts/sandbox-browser-setup.sh",
@@ -325,6 +329,25 @@ fi
expect(installE2eSmoke).not.toContain("docker run --rm \\");
});
it("runs the sandbox browser sidecar proof from the package-installed image", () => {
const runner = readFileSync(SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH, "utf8");
const scenario = readFileSync(SANDBOX_BROWSER_SIDECAR_SCENARIO_PATH, "utf8");
expect(runner).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"');
expect(runner).toContain("docker_e2e_build_or_reuse");
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("scripts/docker/sandbox/Dockerfile.browser");
expect(runner).toContain("remove_prefixed_containers");
expect(scenario).toContain('from "openclaw/plugin-sdk/agent-harness-runtime"');
expect(scenario).toContain("Promise.all([");
expect(scenario).toContain('"sandbox", "list", "--browser", "--json"');
expect(scenario).toContain('"sandbox", "recreate", "--browser", "--session"');
expect(scenario).not.toMatch(/from\s+["'][.]{1,2}\/.*src\//u);
});
it("gives cleanup-smoke builds enough Node heap while preserving explicit callers", () => {
const cleanupRun = readFileSync(CLEANUP_SMOKE_RUN_PATH, "utf8");
+20
View File
@@ -126,6 +126,26 @@ describe("scripts/lib/docker-e2e-plan", () => {
);
});
it("plans the package-backed sandbox browser sidecar lane", () => {
const plan = planFor({
selectedLaneNames: ["sandbox-browser-sidecar"],
});
expect(plan.lanes.map(summarizeLane)).toEqual([
{
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:sandbox-browser-sidecar",
imageKind: "functional",
live: false,
name: "sandbox-browser-sidecar",
resources: ["docker", "service"],
stateScenario: "empty",
timeoutMs: 1_200_000,
weight: 4,
},
]);
expect(plan.needs.functionalImage).toBe(true);
});
it("routes live Docker scripts through the nested trusted release harness", () => {
const sourceLane = allReleasePathLanes({ releaseProfile: "beta" }).find(
(candidate) => candidate.name === "live-codex-npm-plugin",