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"));
});
});