mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -46,6 +46,29 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Airgapped rerun">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Complete onboarding">
|
||||
The setup script runs onboarding automatically. It will:
|
||||
|
||||
|
||||
+244
-23
@@ -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
|
||||
|
||||
+347
-35
@@ -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:-<no value>}"
|
||||
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<string, string | undefined> = {},
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user