mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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!",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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."
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user