diff --git a/docs/install/docker.md b/docs/install/docker.md index bd490c0cd112..3ec82f919f1e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -46,6 +46,29 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va + + On offline hosts, transfer and load the image first: + + ```bash + docker load -i openclaw-image.tar + export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest" + ./scripts/docker/setup.sh --offline + ``` + + `--offline` verifies that `OPENCLAW_IMAGE` already exists locally, disables + implicit Compose pulls and builds, then runs the normal setup flow such as + `.env` synchronization, permission fixes, onboarding, gateway config sync, + and Compose startup. + + If `OPENCLAW_SANDBOX=1`, offline setup also checks the configured default + and active per-agent sandbox images on the daemon behind + `OPENCLAW_DOCKER_SOCKET`. Docker-backed browser images must also carry the + current OpenClaw browser contract label. When a required image is missing or + incompatible, setup exits without changing sandbox configuration instead of + reporting success with an unusable sandbox. + + + The setup script runs onboarding automatically. It will: diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 2134e5d30def..5a145c7918d7 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -15,12 +15,28 @@ TIMEZONE="${OPENCLAW_TZ:-}" RAW_SKIP_ONBOARDING="${OPENCLAW_SKIP_ONBOARDING:-}" SKIP_ONBOARDING="" DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}" +OFFLINE_MODE="" +DEFAULT_SANDBOX_IMAGE="openclaw-sandbox:bookworm-slim" +DEFAULT_SANDBOX_BROWSER_IMAGE="openclaw-sandbox-browser:bookworm-slim" +SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH="2026-05-12-cdp-relay-auth" fail() { echo "ERROR: $*" >&2 exit 1 } +while [[ $# -gt 0 ]]; do + case "$1" in + --offline) + OFFLINE_MODE="1" + ;; + *) + fail "Unknown option: $1" + ;; + esac + shift +done + require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing dependency: $1" >&2 @@ -47,6 +63,14 @@ run_docker_pull() { docker pull "$image" } +require_local_docker_image() { + local image="$1" + if docker image inspect "$image" >/dev/null 2>&1; then + return 0 + fi + fail "Offline Docker setup requires preloaded image $image. Load it with 'docker load -i ' before running scripts/docker/setup.sh --offline." +} + is_truthy_value() { local raw="${1:-}" raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" @@ -154,8 +178,16 @@ sync_gateway_config() { fi } +run_compose_one_off() { + local -a run_args=(run) + if [[ -n "$OFFLINE_MODE" ]]; then + run_args+=(--pull never) + fi + docker compose "${COMPOSE_ARGS[@]}" "${run_args[@]}" "$@" +} + run_prestart_gateway() { - docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@" + run_compose_one_off --rm --no-deps "$@" } run_prestart_cli() { @@ -182,7 +214,11 @@ run_runtime_cli() { shift 2 local -a compose_args - local -a run_args=(run --rm) + local -a run_args=(run) + if [[ -n "$OFFLINE_MODE" ]]; then + run_args+=(--pull never) + fi + run_args+=(--rm) case "$compose_scope" in current) compose_args=("${COMPOSE_ARGS[@]}") ;; @@ -199,6 +235,181 @@ run_runtime_cli() { docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@" } +run_gateway_up() { + local compose_scope="${1:-current}" + shift + + local -a compose_args + local -a up_args=(up -d) + + case "$compose_scope" in + current) compose_args=("${COMPOSE_ARGS[@]}") ;; + base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;; + *) fail "Unknown gateway compose scope: $compose_scope" ;; + esac + + if [[ -n "$OFFLINE_MODE" ]]; then + up_args+=(--pull never --no-build) + fi + up_args+=("$@") + + docker compose "${compose_args[@]}" "${up_args[@]}" openclaw-gateway +} + +resolve_offline_sandbox_images() { + local agents_json sandbox_tools_json + agents_json="$(run_prestart_cli config get agents --json 2>/dev/null || true)" + if [[ -z "$agents_json" ]]; then + agents_json="{}" + fi + sandbox_tools_json="$( + run_prestart_cli config get tools.sandbox.tools --json 2>/dev/null || true + )" + if [[ -z "$sandbox_tools_json" ]]; then + sandbox_tools_json="{}" + fi + + printf '%s' "$agents_json" | run_prestart_gateway \ + -T --entrypoint node openclaw-gateway -e ' +const fs = require("node:fs"); +const agents = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); +const globalToolPolicy = JSON.parse(process.argv[3] || "{}"); +const defaultSandbox = agents?.defaults?.sandbox ?? {}; +const defaultDockerImage = defaultSandbox?.docker?.image ?? process.argv[1]; +const defaultBrowserImage = defaultSandbox?.browser?.image ?? process.argv[2]; +const images = new Set(); +const configuredEntries = Array.isArray(agents?.list) + ? agents.list.filter((entry) => entry !== null && typeof entry === "object") + : []; +const entries = configuredEntries.length > 0 ? configuredEntries : [{ sandbox: {} }]; + +const matchesBrowser = (rawPattern) => { + const pattern = String(rawPattern ?? "").trim().toLowerCase(); + if (pattern === "group:openclaw" || pattern === "group:ui") { + return true; + } + if (!pattern) { + return false; + } + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped.replaceAll("*", ".*")}$`).test("browser"); +}; +const permitsBrowser = (entry) => { + const agentPolicy = entry?.tools?.sandbox?.tools ?? {}; + const allow = Array.isArray(agentPolicy.allow) + ? agentPolicy.allow + : Array.isArray(globalToolPolicy?.allow) + ? globalToolPolicy.allow + : undefined; + const alsoAllow = Array.isArray(agentPolicy.alsoAllow) + ? agentPolicy.alsoAllow + : Array.isArray(globalToolPolicy?.alsoAllow) + ? globalToolPolicy.alsoAllow + : undefined; + const deny = Array.isArray(agentPolicy.deny) + ? agentPolicy.deny + : Array.isArray(globalToolPolicy?.deny) + ? globalToolPolicy.deny + : undefined; + + // Browser is absent from the default allowlist and present in the default + // denylist. Explicit allow patterns re-enable it unless an explicit deny wins. + const explicitAllows = [...(allow ?? []), ...(alsoAllow ?? [])]; + const allowedByAllowlist = Array.isArray(allow) + ? allow.length === 0 || explicitAllows.some(matchesBrowser) + : (alsoAllow ?? []).some(matchesBrowser); + const denied = Array.isArray(deny) + ? deny.some(matchesBrowser) + : !explicitAllows.some(matchesBrowser); + return allowedByAllowlist && !denied; +}; + +for (const entry of entries) { + const sandbox = entry?.sandbox ?? {}; + const mode = sandbox.mode ?? "non-main"; + const backend = ( + sandbox.backend?.trim() || + defaultSandbox.backend?.trim() || + "docker" + ).toLowerCase(); + if (mode === "off" || backend !== "docker") { + continue; + } + + // Setup writes defaults scope=agent. Explicit per-agent scope still wins, + // and shared scope intentionally ignores per-agent Docker/browser overrides. + const scope = sandbox.scope ?? "agent"; + const agentDocker = scope === "shared" ? undefined : sandbox.docker; + images.add(`sandbox\t${agentDocker?.image ?? defaultDockerImage}`); + + const agentBrowser = scope === "shared" ? undefined : sandbox.browser; + const browserEnabled = agentBrowser?.enabled ?? defaultSandbox?.browser?.enabled ?? false; + if (browserEnabled && permitsBrowser(entry)) { + images.add(`browser\t${agentBrowser?.image ?? defaultBrowserImage}`); + } +} +process.stdout.write([...images].join("\n")); +' "$DEFAULT_SANDBOX_IMAGE" "$DEFAULT_SANDBOX_BROWSER_IMAGE" "$sandbox_tools_json" +} + +validate_offline_sandbox_prerequisites() { + if [[ ! -S "$DOCKER_SOCKET_PATH" ]]; then + fail "Offline sandbox setup requires a Docker socket at $DOCKER_SOCKET_PATH." + fi + + local sandbox_images + sandbox_images="$(resolve_offline_sandbox_images)" + local -a sandbox_image_errors=() + local image_kind sandbox_image browser_contract + while IFS=$'\t' read -r image_kind sandbox_image; do + [[ -n "$image_kind" ]] || continue + case "$image_kind" in + sandbox) + if ! docker --host "unix://$DOCKER_SOCKET_PATH" image inspect "$sandbox_image" >/dev/null 2>&1; then + sandbox_image_errors+=("$sandbox_image (missing)") + fi + ;; + browser) + if ! browser_contract="$( + docker --host "unix://$DOCKER_SOCKET_PATH" image inspect \ + -f '{{ index .Config.Labels "org.openclaw.sandbox-browser.contract" }}' \ + "$sandbox_image" 2>/dev/null + )"; then + sandbox_image_errors+=("$sandbox_image (missing)") + elif [[ "$browser_contract" != "$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH" ]]; then + sandbox_image_errors+=( + "$sandbox_image (browser contract=${browser_contract:-missing}, expected=$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH)" + ) + fi + ;; + *) + fail "Unknown offline sandbox image kind: $image_kind" + ;; + esac + done <<<"$sandbox_images" + + if [[ ${#sandbox_image_errors[@]} -gt 0 ]]; then + echo "WARNING: offline Docker setup cannot use required sandbox images:" >&2 + local sandbox_image_error + for sandbox_image_error in "${sandbox_image_errors[@]}"; do + echo " - $sandbox_image_error" >&2 + done + echo " Load them with 'docker load -i ' before enabling sandboxed agents." >&2 + fail "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed." + fi + + echo "Using preloaded sandbox images:" + while IFS=$'\t' read -r _ sandbox_image; do + if [[ -n "$sandbox_image" ]]; then + echo " - $sandbox_image" + fi + done <<<"$sandbox_images" + + if ! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then + fail "Offline sandbox setup requires Docker CLI in $IMAGE_NAME." + fi +} + contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] @@ -539,7 +750,10 @@ upsert_env "$ENV_FILE" \ OPENCLAW_OTEL_PRELOADED \ OPENCLAW_SKIP_ONBOARDING -if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then +if [[ -n "$OFFLINE_MODE" ]]; then + require_local_docker_image "$IMAGE_NAME" + echo "==> Using preloaded Docker image: $IMAGE_NAME" +elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" run_docker_build \ --build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \ @@ -618,9 +832,15 @@ echo "Discord (bot token):" echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token " echo "Docs: https://docs.openclaw.ai/channels" +if [[ -n "$SANDBOX_ENABLED" && -n "$OFFLINE_MODE" ]]; then + echo "" + echo "==> Sandbox preflight" + validate_offline_sandbox_prerequisites +fi + echo "" echo "==> Starting gateway" -docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway +run_gateway_up current # --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) --- if [[ -n "$SANDBOX_ENABLED" ]]; then @@ -628,13 +848,19 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then echo "==> Sandbox setup" sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile" - if [[ -f "$sandbox_dockerfile" ]]; then - echo "Building sandbox image: openclaw-sandbox:bookworm-slim" + if [[ -z "$OFFLINE_MODE" && ! -S "$DOCKER_SOCKET_PATH" ]]; then + echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2 + echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2 + SANDBOX_ENABLED="" + fi + + if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" && -f "$sandbox_dockerfile" ]]; then + echo "Building sandbox image: $DEFAULT_SANDBOX_IMAGE" run_docker_build \ - -t "openclaw-sandbox:bookworm-slim" \ + -t "$DEFAULT_SANDBOX_IMAGE" \ -f "$sandbox_dockerfile" \ "$ROOT_DIR" - else + elif [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]]; then echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2 echo " Sandbox config will be applied but no sandbox image will be built." >&2 echo " Agent exec may fail if the configured sandbox image does not exist." >&2 @@ -643,7 +869,8 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then # Defense-in-depth: verify Docker CLI in the running image before enabling # sandbox. This avoids claiming sandbox is enabled when the image cannot # launch sandbox containers. - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then + if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]] && + ! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then echo "WARNING: Docker CLI not found inside the container image." >&2 echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2 echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2 @@ -656,27 +883,21 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then # Mount Docker socket via a dedicated compose overlay. This overlay is # created only after sandbox prerequisites pass, so the socket is never # exposed when sandbox cannot actually run. - if [[ -S "$DOCKER_SOCKET_PATH" ]]; then - SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml" - cat >"$SANDBOX_COMPOSE_FILE" <"$SANDBOX_COMPOSE_FILE" <>"$SANDBOX_COMPOSE_FILE" <>"$SANDBOX_COMPOSE_FILE" < Sandbox: added Docker socket mount" - else - echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2 - echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2 - SANDBOX_ENABLED="" fi + COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE") + echo "==> Sandbox: added Docker socket mount" fi if [[ -n "$SANDBOX_ENABLED" ]]; then @@ -702,7 +923,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none" echo "Docs: https://docs.openclaw.ai/gateway/sandboxing" # Restart gateway with sandbox compose overlay to pick up socket mount + config. - docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway + run_gateway_up current else echo "WARNING: Sandbox config was partially applied. Check errors above." >&2 echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2 @@ -716,7 +937,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then rm -f "$SANDBOX_COMPOSE_FILE" fi # Ensure gateway service definition is reset without sandbox overlay mount. - docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway + run_gateway_up base --force-recreate fi else # Keep reruns deterministic: if sandbox is not active for this run, reset diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 2d64ab66d16f..965185644e51 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -21,9 +21,36 @@ async function writeDockerStub(binDir: string, logPath: string) { set -euo pipefail log="$DOCKER_STUB_LOG" fail_match="\${DOCKER_STUB_FAIL_MATCH:-}" +docker_host="" +if [[ "\${1:-}" == "--host" ]]; then + docker_host="\${2:-}" + shift 2 +fi if [[ "\${1:-}" == "compose" && "\${2:-}" == "version" ]]; then exit 0 fi +if [[ "\${1:-}" == "image" && "\${2:-}" == "inspect" ]]; then + format="" + if [[ "\${3:-}" == "-f" || "\${3:-}" == "--format" ]]; then + format="\${4:-}" + image="\${5:-}" + else + image="\${3:-}" + fi + echo "image inspect $image host=$docker_host" >>"$log" + missing_images=",\${DOCKER_STUB_MISSING_IMAGES:-}," + if [[ "$missing_images" == *",$image,"* ]]; then + exit 1 + fi + if [[ -n "$format" ]]; then + printf '%s\n' "\${DOCKER_STUB_BROWSER_CONTRACT:-}" + fi + exit 0 +fi +if [[ "\${1:-}" == "pull" ]]; then + echo "pull $*" >>"$log" + exit 0 +fi if [[ "\${1:-}" == "build" ]]; then if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then echo "build-fail $*" >>"$log" @@ -38,14 +65,54 @@ if [[ "\${1:-}" == "compose" ]]; then exit 1 fi echo "compose $*" >>"$log" + if [[ "$*" == *"config get tools.sandbox.tools --json"* ]]; then + if [[ -n "\${DOCKER_STUB_SANDBOX_TOOLS_JSON:-}" ]]; then + printf '%s\n' "$DOCKER_STUB_SANDBOX_TOOLS_JSON" + else + printf '{}\n' + fi + exit 0 + fi + if [[ "$*" == *"config get agents --json"* ]]; then + if [[ -n "\${DOCKER_STUB_AGENTS_JSON:-}" ]]; then + printf '%s\n' "$DOCKER_STUB_AGENTS_JSON" + else + printf '{}\n' + fi + exit 0 + fi + args=("$@") + for ((i = 0; i + 4 < \${#args[@]}; i++)); do + if [[ "\${args[$i]}" == "--entrypoint" && + "\${args[$((i + 1))]}" == "node" && + "\${args[$((i + 2))]}" == "openclaw-gateway" && + "\${args[$((i + 3))]}" == "-e" ]]; then + node -e "\${args[$((i + 4))]}" "\${args[@]:$((i + 5))}" + exit $? + fi + done exit 0 fi echo "unknown $*" >>"$log" exit 0 +`; + + const timeoutStub = `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == --kill-after=* ]]; then + shift +elif [[ "\${1:-}" == "--kill-after" ]]; then + shift 2 +fi +if [[ $# -gt 0 ]]; then + shift +fi +exec "$@" `; await mkdir(binDir, { recursive: true }); await writeFile(join(binDir, "docker"), stub, { mode: 0o755 }); + await writeFile(join(binDir, "timeout"), timeoutStub, { mode: 0o755 }); await writeFile(logPath, ""); } @@ -141,8 +208,9 @@ function requireSandbox(sandbox: DockerSetupSandbox | null): DockerSetupSandbox function runDockerSetup( sandbox: DockerSetupSandbox, overrides: Record = {}, + args: string[] = [], ) { - return spawnSync("bash", [sandbox.scriptPath], { + return spawnSync("bash", [sandbox.scriptPath, ...args], { cwd: sandbox.rootDir, env: createEnv(sandbox, overrides), encoding: "utf8", @@ -187,6 +255,25 @@ function findGatewayStartLineIndex(lines: string[]) { return lines.findIndex((line) => isGatewayStartLine(line)); } +function expectOfflineComposePolicy(lines: string[], options: { gatewayStarts?: boolean } = {}) { + const composeLines = collectMatchingLines(lines, (line) => line.startsWith("compose ")); + expect(composeLines.length).toBeGreaterThan(0); + for (const line of composeLines) { + if (line.includes(" run ")) { + expect(line).toContain(" run --pull never "); + } + } + const gatewayStarts = collectMatchingLines(composeLines, (line) => isGatewayStartLine(line)); + if (options.gatewayStarts === false) { + expect(gatewayStarts).toHaveLength(0); + return; + } + expect(gatewayStarts.length).toBeGreaterThan(0); + for (const line of gatewayStarts) { + expect(line).toContain(" up -d --pull never --no-build"); + } +} + async function runDockerSetupWithUnsetGatewayToken( sandbox: DockerSetupSandbox, suffix: string, @@ -457,21 +544,235 @@ describe("scripts/docker/setup.sh", () => { "FROM scratch\n", ); await resetDockerLog(activeSandbox); + const socketPath = join(activeSandbox.rootDir, "buildkit.sock"); - const result = runDockerSetup(activeSandbox, { - OPENCLAW_SANDBOX: "1", + await withUnixSocket(socketPath, async () => { + const result = runDockerSetup(activeSandbox, { + OPENCLAW_SANDBOX: "1", + OPENCLAW_DOCKER_SOCKET: socketPath, + }); + + expect(result.status).toBe(0); + const buildLines = collectMatchingLines(await readDockerLogLines(activeSandbox), (line) => + line.startsWith("build "), + ); + expect(buildLines.length).toBeGreaterThanOrEqual(2); + const buildLinesWithoutBuildKit = collectMatchingLines( + buildLines, + (line) => !line.includes("DOCKER_BUILDKIT=1"), + ); + expect(buildLinesWithoutBuildKit).toStrictEqual([]); }); + }); + + it("offline mode reuses a preloaded local image without build or pull", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_IMAGE: "ghcr.io/openclaw/openclaw:latest", + OPENCLAW_SKIP_ONBOARDING: "1", + }, + ["--offline"], + ); expect(result.status).toBe(0); - const buildLines = collectMatchingLines(await readDockerLogLines(activeSandbox), (line) => - line.startsWith("build "), + expect(result.stdout).toContain( + "Using preloaded Docker image: ghcr.io/openclaw/openclaw:latest", ); - expect(buildLines.length).toBeGreaterThanOrEqual(2); - const buildLinesWithoutBuildKit = collectMatchingLines( - buildLines, - (line) => !line.includes("DOCKER_BUILDKIT=1"), + + const lines = await readDockerLogLines(activeSandbox); + const log = lines.join("\n"); + expect(log).toContain("image inspect ghcr.io/openclaw/openclaw:latest"); + expect(log).not.toMatch(/^build /m); + expect(log).not.toMatch(/^pull /m); + expect(log).toContain("config set --batch-json"); + expectOfflineComposePolicy(lines); + }); + + it("offline mode fails before setup when the main image is missing", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_IMAGE: "ghcr.io/openclaw/openclaw:offline", + DOCKER_STUB_MISSING_IMAGES: "ghcr.io/openclaw/openclaw:offline", + }, + ["--offline"], ); - expect(buildLinesWithoutBuildKit).toStrictEqual([]); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "Offline Docker setup requires preloaded image ghcr.io/openclaw/openclaw:offline", + ); + + const log = await readDockerLog(activeSandbox); + expect(log).toContain("image inspect ghcr.io/openclaw/openclaw:offline"); + expect(log).not.toMatch(/^build /m); + expect(log).not.toMatch(/^pull /m); + expect(log).not.toContain("up -d openclaw-gateway"); + }); + + it("offline sandbox stays disabled when its configured image is missing", async () => { + const activeSandbox = requireSandbox(sandbox); + await mkdir(join(activeSandbox.rootDir, "scripts", "docker", "sandbox"), { recursive: true }); + await writeFile( + join(activeSandbox.rootDir, "scripts", "docker", "sandbox", "Dockerfile"), + "FROM scratch\n", + ); + await resetDockerLog(activeSandbox); + const socketPath = join(activeSandbox.rootDir, "sb.sock"); + + await withUnixSocket(socketPath, async () => { + const defaultImage = "registry.example/openclaw-sandbox:approved"; + const agentImage = " registry.example/openclaw-sandbox:agent "; + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_SANDBOX: "1", + OPENCLAW_SKIP_ONBOARDING: "1", + OPENCLAW_DOCKER_SOCKET: socketPath, + DOCKER_STUB_AGENTS_JSON: JSON.stringify({ + defaults: { sandbox: { docker: { image: defaultImage } } }, + list: [{ id: "custom", sandbox: { docker: { image: agentImage } } }], + }), + DOCKER_STUB_MISSING_IMAGES: agentImage, + }, + ["--offline"], + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("cannot use required sandbox images"); + expect(result.stderr).toContain(agentImage); + expect(result.stderr).toContain( + "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed", + ); + + const lines = await readDockerLogLines(activeSandbox); + const log = lines.join("\n"); + expect(log).toContain("image inspect openclaw:local"); + expect(log).not.toContain(`image inspect ${defaultImage}`); + expect(log).toContain(`image inspect ${agentImage} host=unix://${socketPath}`); + expect(log).not.toContain("image inspect openclaw-sandbox:bookworm-slim"); + expect(log).not.toMatch(/^build /m); + expect(log).not.toMatch(/^pull /m); + expect(log).not.toContain("config set agents.defaults.sandbox.mode off"); + expect(log).not.toContain("config set agents.defaults.sandbox.mode non-main"); + expectOfflineComposePolicy(lines, { gatewayStarts: false }); + }); + }); + + it("offline sandbox validates only effective Docker and browser images", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + const socketPath = join(activeSandbox.rootDir, "eff.sock"); + + await withUnixSocket(socketPath, async () => { + const defaultImage = "registry.example/openclaw-sandbox:default"; + const browserImage = "registry.example/openclaw-sandbox-browser:default"; + const ignoredImages = [ + "registry.example/openclaw-sandbox:ssh", + "registry.example/openclaw-sandbox:shared-agent", + "registry.example/openclaw-sandbox-browser:shared-agent", + "registry.example/openclaw-sandbox:off", + "registry.example/openclaw-sandbox-browser:denied", + ]; + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_SANDBOX: "1", + OPENCLAW_SKIP_ONBOARDING: "1", + OPENCLAW_DOCKER_SOCKET: socketPath, + DOCKER_STUB_AGENTS_JSON: JSON.stringify({ + defaults: { + sandbox: { + backend: "Docker", + docker: { image: defaultImage }, + browser: { enabled: true, image: browserImage }, + }, + }, + list: [ + { id: "ssh", sandbox: { backend: "ssh", docker: { image: ignoredImages[0] } } }, + { + id: "shared", + sandbox: { + scope: "shared", + docker: { image: ignoredImages[1] }, + browser: { image: ignoredImages[2] }, + }, + }, + { id: "off", sandbox: { mode: "off", docker: { image: ignoredImages[3] } } }, + { + id: "browser-denied", + sandbox: { browser: { enabled: true, image: ignoredImages[4] } }, + tools: { sandbox: { tools: { deny: ["browser"] } } }, + }, + ], + }), + DOCKER_STUB_SANDBOX_TOOLS_JSON: JSON.stringify({ alsoAllow: ["group:ui"] }), + DOCKER_STUB_BROWSER_CONTRACT: "2026-05-12-cdp-relay-auth", + DOCKER_STUB_MISSING_IMAGES: ignoredImages.join(","), + }, + ["--offline"], + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(` - ${defaultImage}`); + expect(result.stdout).toContain(` - ${browserImage}`); + + const lines = await readDockerLogLines(activeSandbox); + const log = lines.join("\n"); + expect(log).toContain(`image inspect ${defaultImage} host=unix://${socketPath}`); + expect(log).toContain(`image inspect ${browserImage} host=unix://${socketPath}`); + for (const image of ignoredImages) { + expect(log).not.toContain(`image inspect ${image}`); + } + expect(log).toContain("config set agents.defaults.sandbox.mode non-main"); + expectOfflineComposePolicy(lines); + }); + }); + + it("offline sandbox rejects an incompatible browser image", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + const socketPath = join(activeSandbox.rootDir, "br.sock"); + + await withUnixSocket(socketPath, async () => { + const browserImage = "registry.example/openclaw-sandbox-browser:stale"; + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_SANDBOX: "1", + OPENCLAW_SKIP_ONBOARDING: "1", + OPENCLAW_DOCKER_SOCKET: socketPath, + DOCKER_STUB_AGENTS_JSON: JSON.stringify({ + defaults: { sandbox: { browser: { enabled: true, image: browserImage } } }, + }), + DOCKER_STUB_SANDBOX_TOOLS_JSON: JSON.stringify({ alsoAllow: ["browser"] }), + DOCKER_STUB_BROWSER_CONTRACT: "old-contract", + }, + ["--offline"], + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `${browserImage} (browser contract=old-contract, expected=2026-05-12-cdp-relay-auth)`, + ); + expect(result.stderr).toContain( + "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed", + ); + + const lines = await readDockerLogLines(activeSandbox); + const log = lines.join("\n"); + expect(log).toContain(`image inspect ${browserImage} host=unix://${socketPath}`); + expect(log).not.toContain("config set agents.defaults.sandbox.mode off"); + expect(log).not.toContain("config set agents.defaults.sandbox.mode non-main"); + expectOfflineComposePolicy(lines, { gatewayStarts: false }); + }); }); it("precreates config identity dir for CLI device auth writes", async () => { @@ -627,51 +928,62 @@ describe("scripts/docker/setup.sh", () => { join(activeSandbox.rootDir, "docker-compose.sandbox.yml"), "services:\n openclaw-gateway:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n", ); - - const result = runDockerSetup(activeSandbox, { - OPENCLAW_SANDBOX: "1", - DOCKER_STUB_FAIL_MATCH: "--entrypoint docker openclaw-gateway --version", - }); - - expect(result.status).toBe(0); - expect(result.stderr).toContain("Sandbox requires Docker CLI"); - const log = await readDockerLog(activeSandbox); - expect(log).toContain("config set agents.defaults.sandbox.mode off"); - await expectMissingPath(join(activeSandbox.rootDir, "docker-compose.sandbox.yml")); - }); - - it("skips sandbox gateway restart when sandbox config writes fail", async () => { - const activeSandbox = requireSandbox(sandbox); - await resetDockerLog(activeSandbox); - const socketPath = join(activeSandbox.rootDir, "sandbox.sock"); + const socketPath = join(activeSandbox.rootDir, "missing-cli.sock"); await withUnixSocket(socketPath, async () => { const result = runDockerSetup(activeSandbox, { OPENCLAW_SANDBOX: "1", OPENCLAW_DOCKER_SOCKET: socketPath, - DOCKER_STUB_FAIL_MATCH: "config set agents.defaults.sandbox.scope", + DOCKER_STUB_FAIL_MATCH: "--entrypoint docker openclaw-gateway --version", }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("Sandbox requires Docker CLI"); + const log = await readDockerLog(activeSandbox); + expect(log).toContain("config set agents.defaults.sandbox.mode off"); + await expectMissingPath(join(activeSandbox.rootDir, "docker-compose.sandbox.yml")); + }); + }); + + it("keeps offline policy when sandbox config writes fail and the gateway rolls back", async () => { + const activeSandbox = requireSandbox(sandbox); + await resetDockerLog(activeSandbox); + const socketPath = join(activeSandbox.rootDir, "sandbox.sock"); + + await withUnixSocket(socketPath, async () => { + const result = runDockerSetup( + activeSandbox, + { + OPENCLAW_SANDBOX: "1", + OPENCLAW_DOCKER_SOCKET: socketPath, + DOCKER_STUB_FAIL_MATCH: "config set agents.defaults.sandbox.scope", + }, + ["--offline"], + ); + expect(result.status).toBe(0); expect(result.stderr).toContain("Failed to set agents.defaults.sandbox.scope"); expect(result.stderr).toContain("Skipping gateway restart to avoid exposing Docker socket"); - const log = await readDockerLog(activeSandbox); - const gatewayStarts = collectMatchingLines(await readDockerLogLines(activeSandbox), (line) => - isGatewayStartLine(line), - ); + const lines = await readDockerLogLines(activeSandbox); + const log = lines.join("\n"); + const gatewayStarts = collectMatchingLines(lines, (line) => isGatewayStartLine(line)); expect(gatewayStarts).toHaveLength(2); expect(log).toContain( - "run --rm --no-deps openclaw-cli config set agents.defaults.sandbox.mode non-main", + "run --pull never --rm --no-deps openclaw-cli config set agents.defaults.sandbox.mode non-main", ); expect(log).toContain("config set agents.defaults.sandbox.mode off"); const forceRecreateLine = log .split("\n") - .find((line) => line.includes("up -d --force-recreate openclaw-gateway")); + .find((line) => line.includes("--force-recreate openclaw-gateway")); expect(forceRecreateLine).toBe( - `compose compose -f ${join(activeSandbox.rootDir, "docker-compose.yml")} up -d --force-recreate openclaw-gateway`, + `compose compose -f ${join(activeSandbox.rootDir, "docker-compose.yml")} up -d --pull never --no-build --force-recreate openclaw-gateway`, ); expect(forceRecreateLine).not.toContain("docker-compose.sandbox.yml"); + expect(log).toContain( + `image inspect openclaw-sandbox:bookworm-slim host=unix://${socketPath}`, + ); + expectOfflineComposePolicy(lines); await expectMissingPath(join(activeSandbox.rootDir, "docker-compose.sandbox.yml")); }); });