feat(docker): support offline setup reruns (#89062)

Add a strict offline rerun mode for Docker setup. Preflight required images, Docker socket/browser policy, and prevent pulls or builds across setup, restart, and rollback paths.

Fixes #70443.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Reviewed-by: @steipete
This commit is contained in:
Alix-007
2026-06-17 19:50:46 +08:00
committed by GitHub
parent e2292d18e2
commit 0e46fd1081
3 changed files with 614 additions and 58 deletions
+244 -23
View File
@@ -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 <image.tar>' 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 <sandbox-image.tar>' 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 <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" <<YAML
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- $(quote_yaml_string "${DOCKER_SOCKET_PATH}:/var/run/docker.sock")
YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> 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