diff --git a/config/knip.config.ts b/config/knip.config.ts index 2a6c73ecc41d..953cb181c532 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -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!", diff --git a/package.json b/package.json index b0437aab8bfb..cae3e523951d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index 4f52dbd51462..b0e03e3af784 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -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`); diff --git a/scripts/code-mode-model-matrix.ts b/scripts/code-mode-model-matrix.ts index df7fe1d62751..98f39a8d659d 100644 --- a/scripts/code-mode-model-matrix.ts +++ b/scripts/code-mode-model-matrix.ts @@ -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 { async function runtimeArtifactDirectories(repoRoot: string, outputDir: string): Promise { 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 diff --git a/scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs b/scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs new file mode 100644 index 000000000000..581430c30f19 --- /dev/null +++ b/scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs @@ -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(`

${marker}

`); + }); + 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); diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 18afbf9ba00f..f0137125d000 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -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(value: T): Record { } 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 { diff --git a/scripts/e2e/sandbox-browser-sidecar-docker.sh b/scripts/e2e/sandbox-browser-sidecar-docker.sh new file mode 100755 index 000000000000..50ba769b4624 --- /dev/null +++ b/scripts/e2e/sandbox-browser-sidecar-docker.sh @@ -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." diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 3daba16f856f..1ae0f4c782c1 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -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", diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 865d5187381b..578e8c81a6a2 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -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({ diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index ac1d2c51b009..f5b9c4412f30 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -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 { +}; + +export async function ensureSandboxBrowser( + params: EnsureSandboxBrowserParams, +): Promise { 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 { let existing = BROWSER_BRIDGES.get(params.scopeKey); const stopExistingForContainer = async () => { await stopCachedBrowserBridgesForContainer(containerName); diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index c7e93e10d5ab..4e3877a482d4 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -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. diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index a7a9f9d58c22..fb3dce94d036 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -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 { + return await ensureSandboxContainerLifecycle(params, scopeKey, containerName); + }); +} + +async function ensureSandboxContainerLifecycle( + params: EnsureSandboxContainerParams, + scopeKey: string, + containerName: string, +) { const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, diff --git a/test/scripts/code-mode-model-matrix.test.ts b/test/scripts/code-mode-model-matrix.test.ts index f4825d8b9119..90c76d50270d 100644 --- a/test/scripts/code-mode-model-matrix.test.ts +++ b/test/scripts/code-mode-model-matrix.test.ts @@ -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, diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 254e014e8d39..c3e9b78ff104 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -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"); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index a7f3566ff295..89e58cbe13ec 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -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",