From 46ccbacbd9b422d3419c580a0d43d80409bb5fa0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 13:39:56 -0700 Subject: [PATCH] refactor(scripts): move container setup entrypoints --- .github/labeler.yml | 3 + docker-setup.sh | 612 +------------------------- scripts/docker/setup.sh | 616 +++++++++++++++++++++++++++ scripts/podman/openclaw.container.in | 2 +- scripts/podman/setup.sh | 312 ++++++++++++++ scripts/run-openclaw-podman.sh | 14 +- setup-podman.sh | 310 +------------- src/docker-setup.e2e.test.ts | 19 +- 8 files changed, 962 insertions(+), 926 deletions(-) create mode 100755 scripts/docker/setup.sh create mode 100755 scripts/podman/setup.sh diff --git a/.github/labeler.yml b/.github/labeler.yml index 7dcc038de4c..4ee43d5e6fa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -165,7 +165,10 @@ - "Dockerfile.*" - "docker-compose.yml" - "docker-setup.sh" + - "setup-podman.sh" - ".dockerignore" + - "scripts/docker/setup.sh" + - "scripts/podman/setup.sh" - "scripts/**/*docker*" - "scripts/**/Dockerfile*" - "scripts/sandbox-*.sh" diff --git a/docker-setup.sh b/docker-setup.sh index 19e5461765b..e8d6335bf42 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -2,615 +2,11 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" -EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml" -IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" -EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}" -HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" -RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}" -SANDBOX_ENABLED="" -DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}" -TIMEZONE="${OPENCLAW_TZ:-}" +SCRIPT_PATH="$ROOT_DIR/scripts/docker/setup.sh" -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing dependency: $1" >&2 - exit 1 - fi -} - -is_truthy_value() { - local raw="${1:-}" - raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" - case "$raw" in - 1 | true | yes | on) return 0 ;; - *) return 1 ;; - esac -} - -read_config_gateway_token() { - local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json" - if [[ ! -f "$config_path" ]]; then - return 0 - fi - if command -v python3 >/dev/null 2>&1; then - python3 - "$config_path" <<'PY' -import json -import sys - -path = sys.argv[1] -try: - with open(path, "r", encoding="utf-8") as f: - cfg = json.load(f) -except Exception: - raise SystemExit(0) - -gateway = cfg.get("gateway") -if not isinstance(gateway, dict): - raise SystemExit(0) -auth = gateway.get("auth") -if not isinstance(auth, dict): - raise SystemExit(0) -token = auth.get("token") -if isinstance(token, str): - token = token.strip() - if token: - print(token) -PY - return 0 - fi - if command -v node >/dev/null 2>&1; then - node - "$config_path" <<'NODE' -const fs = require("node:fs"); -const configPath = process.argv[2]; -try { - const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); - const token = cfg?.gateway?.auth?.token; - if (typeof token === "string" && token.trim().length > 0) { - process.stdout.write(token.trim()); - } -} catch { - // Keep docker-setup resilient when config parsing fails. -} -NODE - fi -} - -read_env_gateway_token() { - local env_path="$1" - local line="" - local token="" - if [[ ! -f "$env_path" ]]; then - return 0 - fi - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%$'\r'}" - if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then - token="${line#OPENCLAW_GATEWAY_TOKEN=}" - fi - done <"$env_path" - if [[ -n "$token" ]]; then - printf '%s' "$token" - fi -} - -ensure_control_ui_allowed_origins() { - if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then - return 0 - fi - - local allowed_origin_json - local current_allowed_origins - allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")" - current_allowed_origins="$( - docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ - config get gateway.controlUi.allowedOrigins 2>/dev/null || true - )" - current_allowed_origins="${current_allowed_origins//$'\r'/}" - - if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then - echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged." - return 0 - fi - - docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ - config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null - echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." -} - -sync_gateway_mode_and_bind() { - docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ - config set gateway.mode local >/dev/null - docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ - config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null - echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup." -} - -contains_disallowed_chars() { - local value="$1" - [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] -} - -is_valid_timezone() { - local value="$1" - [[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]] -} - -validate_mount_path_value() { - local label="$1" - local value="$2" - if [[ -z "$value" ]]; then - fail "$label cannot be empty." - fi - if contains_disallowed_chars "$value"; then - fail "$label contains unsupported control characters." - fi - if [[ "$value" =~ [[:space:]] ]]; then - fail "$label cannot contain whitespace." - fi -} - -validate_named_volume() { - local value="$1" - if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then - fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume." - fi -} - -validate_mount_spec() { - local mount="$1" - if contains_disallowed_chars "$mount"; then - fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters." - fi - # Keep mount specs strict to avoid YAML structure injection. - # Expected format: source:target[:options] - if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then - fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." - fi -} - -require_cmd docker -if ! docker compose version >/dev/null 2>&1; then - echo "Docker Compose not available (try: docker compose version)" >&2 +if [[ ! -f "$SCRIPT_PATH" ]]; then + echo "Docker setup script not found at $SCRIPT_PATH" >&2 exit 1 fi -if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then - DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}" -fi -if [[ -z "$DOCKER_SOCKET_PATH" ]]; then - DOCKER_SOCKET_PATH="/var/run/docker.sock" -fi -if is_truthy_value "$RAW_SANDBOX_SETTING"; then - SANDBOX_ENABLED="1" -fi - -OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" -OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" - -validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR" -validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR" -if [[ -n "$HOME_VOLUME_NAME" ]]; then - if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then - validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME" - else - validate_named_volume "$HOME_VOLUME_NAME" - fi -fi -if contains_disallowed_chars "$EXTRA_MOUNTS"; then - fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters." -fi -if [[ -n "$SANDBOX_ENABLED" ]]; then - validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH" -fi -if [[ -n "$TIMEZONE" ]]; then - if contains_disallowed_chars "$TIMEZONE"; then - fail "OPENCLAW_TZ contains unsupported control characters." - fi - if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then - fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)." - fi - if ! is_valid_timezone "$TIMEZONE"; then - fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)." - fi -fi - -mkdir -p "$OPENCLAW_CONFIG_DIR" -mkdir -p "$OPENCLAW_WORKSPACE_DIR" -# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows -# where the container (even as root) cannot create new host subdirectories. -mkdir -p "$OPENCLAW_CONFIG_DIR/identity" -mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent" -mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions" - -export OPENCLAW_CONFIG_DIR -export OPENCLAW_WORKSPACE_DIR -export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" -export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" -export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" -export OPENCLAW_IMAGE="$IMAGE_NAME" -export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" -export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}" -export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" -export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" -export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" -export OPENCLAW_SANDBOX="$SANDBOX_ENABLED" -export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH" -export OPENCLAW_TZ="$TIMEZONE" - -# Detect Docker socket GID for sandbox group_add. -DOCKER_GID="" -if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then - DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")" -fi -export DOCKER_GID - -if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)" - if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then - OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN" - echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json" - else - DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)" - if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then - OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN" - echo "Reusing gateway token from $ROOT_DIR/.env" - elif command -v openssl >/dev/null 2>&1; then - OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" - else - OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' -import secrets -print(secrets.token_hex(32)) -PY -)" - fi - fi -fi -export OPENCLAW_GATEWAY_TOKEN - -COMPOSE_FILES=("$COMPOSE_FILE") -COMPOSE_ARGS=() - -write_extra_compose() { - local home_volume="$1" - shift - local mount - local gateway_home_mount - local gateway_config_mount - local gateway_workspace_mount - - cat >"$EXTRA_COMPOSE_FILE" <<'YAML' -services: - openclaw-gateway: - volumes: -YAML - - if [[ -n "$home_volume" ]]; then - gateway_home_mount="${home_volume}:/home/node" - gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw" - gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace" - validate_mount_spec "$gateway_home_mount" - validate_mount_spec "$gateway_config_mount" - validate_mount_spec "$gateway_workspace_mount" - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - fi - - for mount in "$@"; do - validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" - done - - cat >>"$EXTRA_COMPOSE_FILE" <<'YAML' - openclaw-cli: - volumes: -YAML - - if [[ -n "$home_volume" ]]; then - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - fi - - for mount in "$@"; do - validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" - done - - if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then - validate_named_volume "$home_volume" - cat >>"$EXTRA_COMPOSE_FILE" <>"$tmp" - seen="$seen$k " - replaced=true - break - fi - done - if [[ "$replaced" == false ]]; then - printf '%s\n' "$line" >>"$tmp" - fi - done <"$file" - fi - - for k in "${keys[@]}"; do - if [[ "$seen" != *" $k "* ]]; then - printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" - fi - done - - mv "$tmp" "$file" -} - -upsert_env "$ENV_FILE" \ - OPENCLAW_CONFIG_DIR \ - OPENCLAW_WORKSPACE_DIR \ - OPENCLAW_GATEWAY_PORT \ - OPENCLAW_BRIDGE_PORT \ - OPENCLAW_GATEWAY_BIND \ - OPENCLAW_GATEWAY_TOKEN \ - OPENCLAW_IMAGE \ - OPENCLAW_EXTRA_MOUNTS \ - OPENCLAW_HOME_VOLUME \ - OPENCLAW_DOCKER_APT_PACKAGES \ - OPENCLAW_EXTENSIONS \ - OPENCLAW_SANDBOX \ - OPENCLAW_DOCKER_SOCKET \ - DOCKER_GID \ - OPENCLAW_INSTALL_DOCKER_CLI \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ - OPENCLAW_TZ - -if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then - echo "==> Building Docker image: $IMAGE_NAME" - docker build \ - --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ - --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ - --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \ - -t "$IMAGE_NAME" \ - -f "$ROOT_DIR/Dockerfile" \ - "$ROOT_DIR" -else - echo "==> Pulling Docker image: $IMAGE_NAME" - if ! docker pull "$IMAGE_NAME"; then - echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2 - exit 1 - fi -fi - -# Ensure bind-mounted data directories are writable by the container's `node` -# user (uid 1000). Host-created dirs inherit the host user's uid which may -# differ, causing EACCES when the container tries to mkdir/write. -# Running a brief root container to chown is the portable Docker idiom -- -# it works regardless of the host uid and doesn't require host-side root. -echo "" -echo "==> Fixing data-directory permissions" -# Use -xdev to restrict chown to the config-dir mount only — without it, -# the recursive chown would cross into the workspace bind mount and rewrite -# ownership of all user project files on Linux hosts. -# After fixing the config dir, only the OpenClaw metadata subdirectory -# (.openclaw/) inside the workspace gets chowned, not the user's project files. -docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \ - 'find /home/node/.openclaw -xdev -exec chown node:node {} +; \ - [ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true' - -echo "" -echo "==> Onboarding (interactive)" -echo "Docker setup pins Gateway mode to local." -echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)." -echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND" -echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN" -echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)." -echo "Install Gateway daemon: No (managed by Docker Compose)" -echo "" -docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon - -echo "" -echo "==> Docker gateway defaults" -sync_gateway_mode_and_bind - -echo "" -echo "==> Control UI origin allowlist" -ensure_control_ui_allowed_origins - -echo "" -echo "==> Provider setup (optional)" -echo "WhatsApp (QR):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login" -echo "Telegram (bot token):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token " -echo "Discord (bot token):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token " -echo "Docs: https://docs.openclaw.ai/channels" - -echo "" -echo "==> Starting gateway" -docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway - -# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) --- -if [[ -n "$SANDBOX_ENABLED" ]]; then - echo "" - echo "==> Sandbox setup" - - # Build sandbox image if Dockerfile.sandbox exists. - if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then - echo "Building sandbox image: openclaw-sandbox:bookworm-slim" - docker build \ - -t "openclaw-sandbox:bookworm-slim" \ - -f "$ROOT_DIR/Dockerfile.sandbox" \ - "$ROOT_DIR" - else - echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&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 - fi - - # 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 - 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 - SANDBOX_ENABLED="" - fi -fi - -# Apply sandbox config only if prerequisites are met. -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: 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 -fi - -if [[ -n "$SANDBOX_ENABLED" ]]; then - # Enable sandbox in OpenClaw config. - sandbox_config_ok=true - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ - config set agents.defaults.sandbox.mode "non-main" >/dev/null; then - echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2 - sandbox_config_ok=false - fi - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ - config set agents.defaults.sandbox.scope "agent" >/dev/null; then - echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2 - sandbox_config_ok=false - fi - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ - config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then - echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2 - sandbox_config_ok=false - fi - - if [[ "$sandbox_config_ok" == true ]]; 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 - 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 - if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ - config set agents.defaults.sandbox.mode "off" >/dev/null; then - echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2 - else - echo "Sandbox mode rolled back to off due to partial sandbox config failure." - fi - if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; 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 - fi -else - # Keep reruns deterministic: if sandbox is not active for this run, reset - # persisted sandbox mode so future execs do not require docker.sock by stale - # config alone. - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ - config set agents.defaults.sandbox.mode "off" >/dev/null; then - echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2 - fi - if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then - rm -f "$ROOT_DIR/docker-compose.sandbox.yml" - fi -fi - -echo "" -echo "Gateway running with host port mapping." -echo "Access from tailnet devices via the host's tailnet IP." -echo "Config: $OPENCLAW_CONFIG_DIR" -echo "Workspace: $OPENCLAW_WORKSPACE_DIR" -echo "Token: $OPENCLAW_GATEWAY_TOKEN" -echo "" -echo "Commands:" -echo " ${COMPOSE_HINT} logs -f openclaw-gateway" -echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\"" +exec "$SCRIPT_PATH" "$@" diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh new file mode 100755 index 00000000000..cfa6fd4046e --- /dev/null +++ b/scripts/docker/setup.sh @@ -0,0 +1,616 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" +EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml" +IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" +EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}" +HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" +RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}" +SANDBOX_ENABLED="" +DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}" +TIMEZONE="${OPENCLAW_TZ:-}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing dependency: $1" >&2 + exit 1 + fi +} + +is_truthy_value() { + local raw="${1:-}" + raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$raw" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +read_config_gateway_token() { + local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json" + if [[ ! -f "$config_path" ]]; then + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$config_path" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) +except Exception: + raise SystemExit(0) + +gateway = cfg.get("gateway") +if not isinstance(gateway, dict): + raise SystemExit(0) +auth = gateway.get("auth") +if not isinstance(auth, dict): + raise SystemExit(0) +token = auth.get("token") +if isinstance(token, str): + token = token.strip() + if token: + print(token) +PY + return 0 + fi + if command -v node >/dev/null 2>&1; then + node - "$config_path" <<'NODE' +const fs = require("node:fs"); +const configPath = process.argv[2]; +try { + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + const token = cfg?.gateway?.auth?.token; + if (typeof token === "string" && token.trim().length > 0) { + process.stdout.write(token.trim()); + } +} catch { + // Keep docker-setup resilient when config parsing fails. +} +NODE + fi +} + +read_env_gateway_token() { + local env_path="$1" + local line="" + local token="" + if [[ ! -f "$env_path" ]]; then + return 0 + fi + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then + token="${line#OPENCLAW_GATEWAY_TOKEN=}" + fi + done <"$env_path" + if [[ -n "$token" ]]; then + printf '%s' "$token" + fi +} + +ensure_control_ui_allowed_origins() { + if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then + return 0 + fi + + local allowed_origin_json + local current_allowed_origins + allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")" + current_allowed_origins="$( + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config get gateway.controlUi.allowedOrigins 2>/dev/null || true + )" + current_allowed_origins="${current_allowed_origins//$'\r'/}" + + if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then + echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged." + return 0 + fi + + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null + echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." +} + +sync_gateway_mode_and_bind() { + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.mode local >/dev/null + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null + echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup." +} + +contains_disallowed_chars() { + local value="$1" + [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] +} + +is_valid_timezone() { + local value="$1" + [[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]] +} + +validate_mount_path_value() { + local label="$1" + local value="$2" + if [[ -z "$value" ]]; then + fail "$label cannot be empty." + fi + if contains_disallowed_chars "$value"; then + fail "$label contains unsupported control characters." + fi + if [[ "$value" =~ [[:space:]] ]]; then + fail "$label cannot contain whitespace." + fi +} + +validate_named_volume() { + local value="$1" + if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume." + fi +} + +validate_mount_spec() { + local mount="$1" + if contains_disallowed_chars "$mount"; then + fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters." + fi + # Keep mount specs strict to avoid YAML structure injection. + # Expected format: source:target[:options] + if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then + fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." + fi +} + +require_cmd docker +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose not available (try: docker compose version)" >&2 + exit 1 +fi + +if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then + DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}" +fi +if [[ -z "$DOCKER_SOCKET_PATH" ]]; then + DOCKER_SOCKET_PATH="/var/run/docker.sock" +fi +if is_truthy_value "$RAW_SANDBOX_SETTING"; then + SANDBOX_ENABLED="1" +fi + +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" + +validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR" +validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR" +if [[ -n "$HOME_VOLUME_NAME" ]]; then + if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then + validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME" + else + validate_named_volume "$HOME_VOLUME_NAME" + fi +fi +if contains_disallowed_chars "$EXTRA_MOUNTS"; then + fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters." +fi +if [[ -n "$SANDBOX_ENABLED" ]]; then + validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH" +fi +if [[ -n "$TIMEZONE" ]]; then + if contains_disallowed_chars "$TIMEZONE"; then + fail "OPENCLAW_TZ contains unsupported control characters." + fi + if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then + fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)." + fi + if ! is_valid_timezone "$TIMEZONE"; then + fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)." + fi +fi + +mkdir -p "$OPENCLAW_CONFIG_DIR" +mkdir -p "$OPENCLAW_WORKSPACE_DIR" +# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows +# where the container (even as root) cannot create new host subdirectories. +mkdir -p "$OPENCLAW_CONFIG_DIR/identity" +mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent" +mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions" + +export OPENCLAW_CONFIG_DIR +export OPENCLAW_WORKSPACE_DIR +export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" +export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" +export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" +export OPENCLAW_IMAGE="$IMAGE_NAME" +export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" +export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}" +export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" +export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" +export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" +export OPENCLAW_SANDBOX="$SANDBOX_ENABLED" +export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH" +export OPENCLAW_TZ="$TIMEZONE" + +# Detect Docker socket GID for sandbox group_add. +DOCKER_GID="" +if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then + DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")" +fi +export DOCKER_GID + +if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then + EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)" + if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then + OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN" + echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json" + else + DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)" + if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then + OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN" + echo "Reusing gateway token from $ROOT_DIR/.env" + elif command -v openssl >/dev/null 2>&1; then + OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" + else + OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY +)" + fi + fi +fi +export OPENCLAW_GATEWAY_TOKEN + +COMPOSE_FILES=("$COMPOSE_FILE") +COMPOSE_ARGS=() + +write_extra_compose() { + local home_volume="$1" + shift + local mount + local gateway_home_mount + local gateway_config_mount + local gateway_workspace_mount + + cat >"$EXTRA_COMPOSE_FILE" <<'YAML' +services: + openclaw-gateway: + volumes: +YAML + + if [[ -n "$home_volume" ]]; then + gateway_home_mount="${home_volume}:/home/node" + gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw" + gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace" + validate_mount_spec "$gateway_home_mount" + validate_mount_spec "$gateway_config_mount" + validate_mount_spec "$gateway_workspace_mount" + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" + fi + + for mount in "$@"; do + validate_mount_spec "$mount" + printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + done + + cat >>"$EXTRA_COMPOSE_FILE" <<'YAML' + openclaw-cli: + volumes: +YAML + + if [[ -n "$home_volume" ]]; then + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" + fi + + for mount in "$@"; do + validate_mount_spec "$mount" + printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + done + + if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then + validate_named_volume "$home_volume" + cat >>"$EXTRA_COMPOSE_FILE" <>"$tmp" + seen="$seen$k " + replaced=true + break + fi + done + if [[ "$replaced" == false ]]; then + printf '%s\n' "$line" >>"$tmp" + fi + done <"$file" + fi + + for k in "${keys[@]}"; do + if [[ "$seen" != *" $k "* ]]; then + printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" + fi + done + + mv "$tmp" "$file" +} + +upsert_env "$ENV_FILE" \ + OPENCLAW_CONFIG_DIR \ + OPENCLAW_WORKSPACE_DIR \ + OPENCLAW_GATEWAY_PORT \ + OPENCLAW_BRIDGE_PORT \ + OPENCLAW_GATEWAY_BIND \ + OPENCLAW_GATEWAY_TOKEN \ + OPENCLAW_IMAGE \ + OPENCLAW_EXTRA_MOUNTS \ + OPENCLAW_HOME_VOLUME \ + OPENCLAW_DOCKER_APT_PACKAGES \ + OPENCLAW_EXTENSIONS \ + OPENCLAW_SANDBOX \ + OPENCLAW_DOCKER_SOCKET \ + DOCKER_GID \ + OPENCLAW_INSTALL_DOCKER_CLI \ + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ + OPENCLAW_TZ + +if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then + echo "==> Building Docker image: $IMAGE_NAME" + docker build \ + --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ + --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/Dockerfile" \ + "$ROOT_DIR" +else + echo "==> Pulling Docker image: $IMAGE_NAME" + if ! docker pull "$IMAGE_NAME"; then + echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2 + exit 1 + fi +fi + +# Ensure bind-mounted data directories are writable by the container's `node` +# user (uid 1000). Host-created dirs inherit the host user's uid which may +# differ, causing EACCES when the container tries to mkdir/write. +# Running a brief root container to chown is the portable Docker idiom -- +# it works regardless of the host uid and doesn't require host-side root. +echo "" +echo "==> Fixing data-directory permissions" +# Use -xdev to restrict chown to the config-dir mount only — without it, +# the recursive chown would cross into the workspace bind mount and rewrite +# ownership of all user project files on Linux hosts. +# After fixing the config dir, only the OpenClaw metadata subdirectory +# (.openclaw/) inside the workspace gets chowned, not the user's project files. +docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \ + 'find /home/node/.openclaw -xdev -exec chown node:node {} +; \ + [ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true' + +echo "" +echo "==> Onboarding (interactive)" +echo "Docker setup pins Gateway mode to local." +echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)." +echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND" +echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN" +echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)." +echo "Install Gateway daemon: No (managed by Docker Compose)" +echo "" +docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon + +echo "" +echo "==> Docker gateway defaults" +sync_gateway_mode_and_bind + +echo "" +echo "==> Control UI origin allowlist" +ensure_control_ui_allowed_origins + +echo "" +echo "==> Provider setup (optional)" +echo "WhatsApp (QR):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login" +echo "Telegram (bot token):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token " +echo "Discord (bot token):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token " +echo "Docs: https://docs.openclaw.ai/channels" + +echo "" +echo "==> Starting gateway" +docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway + +# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) --- +if [[ -n "$SANDBOX_ENABLED" ]]; then + echo "" + echo "==> Sandbox setup" + + # Build sandbox image if Dockerfile.sandbox exists. + if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then + echo "Building sandbox image: openclaw-sandbox:bookworm-slim" + docker build \ + -t "openclaw-sandbox:bookworm-slim" \ + -f "$ROOT_DIR/Dockerfile.sandbox" \ + "$ROOT_DIR" + else + echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&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 + fi + + # 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 + 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 + SANDBOX_ENABLED="" + fi +fi + +# Apply sandbox config only if prerequisites are met. +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: 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 +fi + +if [[ -n "$SANDBOX_ENABLED" ]]; then + # Enable sandbox in OpenClaw config. + sandbox_config_ok=true + if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + config set agents.defaults.sandbox.mode "non-main" >/dev/null; then + echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2 + sandbox_config_ok=false + fi + if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + config set agents.defaults.sandbox.scope "agent" >/dev/null; then + echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2 + sandbox_config_ok=false + fi + if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then + echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2 + sandbox_config_ok=false + fi + + if [[ "$sandbox_config_ok" == true ]]; 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 + 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 + if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + config set agents.defaults.sandbox.mode "off" >/dev/null; then + echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2 + else + echo "Sandbox mode rolled back to off due to partial sandbox config failure." + fi + if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; 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 + fi +else + # Keep reruns deterministic: if sandbox is not active for this run, reset + # persisted sandbox mode so future execs do not require docker.sock by stale + # config alone. + if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set agents.defaults.sandbox.mode "off" >/dev/null; then + echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2 + fi + if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then + rm -f "$ROOT_DIR/docker-compose.sandbox.yml" + fi +fi + +echo "" +echo "Gateway running with host port mapping." +echo "Access from tailnet devices via the host's tailnet IP." +echo "Config: $OPENCLAW_CONFIG_DIR" +echo "Workspace: $OPENCLAW_WORKSPACE_DIR" +echo "Token: $OPENCLAW_GATEWAY_TOKEN" +echo "" +echo "Commands:" +echo " ${COMPOSE_HINT} logs -f openclaw-gateway" +echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\"" diff --git a/scripts/podman/openclaw.container.in b/scripts/podman/openclaw.container.in index e0ad2ac8bde..1618774b841 100644 --- a/scripts/podman/openclaw.container.in +++ b/scripts/podman/openclaw.container.in @@ -1,5 +1,5 @@ # OpenClaw gateway — Podman Quadlet (rootless) -# Installed by setup-podman.sh into openclaw's ~/.config/containers/systemd/ +# Installed by scripts/podman/setup.sh into openclaw's ~/.config/containers/systemd/ # {{OPENCLAW_HOME}} is replaced at install time. [Unit] diff --git a/scripts/podman/setup.sh b/scripts/podman/setup.sh new file mode 100755 index 00000000000..1851271bee4 --- /dev/null +++ b/scripts/podman/setup.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# One-time host setup for rootless OpenClaw in Podman: creates the openclaw +# user, builds the image, loads it into that user's Podman store, and installs +# the launch script. Run from repo root with sudo capability. +# +# Usage: ./scripts/podman/setup.sh [--quadlet|--container] +# --quadlet Install systemd Quadlet so the container runs as a user service +# --container Only install user + image + launch script; you start the container manually (default) +# Or set OPENCLAW_PODMAN_QUADLET=1 (or 0) to choose without a flag. +# +# After this, start the gateway manually: +# ./scripts/run-openclaw-podman.sh launch +# ./scripts/run-openclaw-podman.sh launch setup # onboarding wizard +# Or as the openclaw user: sudo -u openclaw /home/openclaw/run-openclaw-podman.sh +# If you used --quadlet, you can also: sudo systemctl --machine openclaw@ --user start openclaw.service +set -euo pipefail + +OPENCLAW_USER="${OPENCLAW_PODMAN_USER:-openclaw}" +REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh" +QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing dependency: $1" >&2 + exit 1 + fi +} + +is_writable_dir() { + local dir="$1" + [[ -n "$dir" && -d "$dir" && ! -L "$dir" && -w "$dir" && -x "$dir" ]] +} + +is_safe_tmp_base() { + local dir="$1" + local mode="" + local owner="" + is_writable_dir "$dir" || return 1 + mode="$(stat -Lc '%a' "$dir" 2>/dev/null || true)" + if [[ -n "$mode" ]]; then + local perm=$((8#$mode)) + if (( (perm & 0022) != 0 && (perm & 01000) == 0 )); then + return 1 + fi + fi + if is_root; then + owner="$(stat -Lc '%u' "$dir" 2>/dev/null || true)" + if [[ -n "$owner" && "$owner" != "0" ]]; then + return 1 + fi + fi + return 0 +} + +resolve_image_tmp_dir() { + if ! is_root && is_safe_tmp_base "${TMPDIR:-}"; then + printf '%s' "$TMPDIR" + return 0 + fi + if is_safe_tmp_base "/var/tmp"; then + printf '%s' "/var/tmp" + return 0 + fi + if is_safe_tmp_base "/tmp"; then + printf '%s' "/tmp" + return 0 + fi + printf '%s' "/tmp" +} + +is_root() { [[ "$(id -u)" -eq 0 ]]; } + +run_root() { + if is_root; then + "$@" + else + sudo "$@" + fi +} + +run_as_user() { + # When switching users, the caller's cwd may be inaccessible to the target + # user (e.g. a private home dir). Wrap in a subshell that cd's to a + # world-traversable directory so sudo/runuser don't fail with "cannot chdir". + # TODO: replace with fully rootless podman build to eliminate the need for + # user-switching entirely. + local user="$1" + shift + if command -v sudo >/dev/null 2>&1; then + ( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" ) + elif is_root && command -v runuser >/dev/null 2>&1; then + ( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" ) + else + echo "Need sudo (or root+runuser) to run commands as $user." >&2 + exit 1 + fi +} + +run_as_openclaw() { + # Avoid root writes into $OPENCLAW_HOME (symlink/hardlink/TOCTOU footguns). + # Anything under the target user's home should be created/modified as that user. + run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" "$@" +} + +escape_sed_replacement_pipe_delim() { + # Escape replacement metacharacters for sed "s|...|...|g" replacement text. + printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' +} + +# Quadlet: opt-in via --quadlet or OPENCLAW_PODMAN_QUADLET=1 +INSTALL_QUADLET=false +for arg in "$@"; do + case "$arg" in + --quadlet) INSTALL_QUADLET=true ;; + --container) INSTALL_QUADLET=false ;; + esac +done +if [[ -n "${OPENCLAW_PODMAN_QUADLET:-}" ]]; then + case "${OPENCLAW_PODMAN_QUADLET,,}" in + 1|yes|true) INSTALL_QUADLET=true ;; + 0|no|false) INSTALL_QUADLET=false ;; + esac +fi + +require_cmd podman +if ! is_root; then + require_cmd sudo +fi +if [[ ! -f "$REPO_PATH/Dockerfile" ]]; then + echo "Dockerfile not found at $REPO_PATH. Set OPENCLAW_REPO_PATH to the repo root." >&2 + exit 1 +fi +if [[ ! -f "$RUN_SCRIPT_SRC" ]]; then + echo "Launch script not found at $RUN_SCRIPT_SRC." >&2 + exit 1 +fi + +generate_token_hex_32() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY + return 0 + fi + if command -v od >/dev/null 2>&1; then + # 32 random bytes -> 64 lowercase hex chars + od -An -N32 -tx1 /dev/urandom | tr -d " \n" + return 0 + fi + echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2 + exit 1 +} + +user_exists() { + local user="$1" + if command -v getent >/dev/null 2>&1; then + getent passwd "$user" >/dev/null 2>&1 && return 0 + fi + id -u "$user" >/dev/null 2>&1 +} + +resolve_user_home() { + local user="$1" + local home="" + if command -v getent >/dev/null 2>&1; then + home="$(getent passwd "$user" 2>/dev/null | cut -d: -f6 || true)" + fi + if [[ -z "$home" && -f /etc/passwd ]]; then + home="$(awk -F: -v u="$user" '$1==u {print $6}' /etc/passwd 2>/dev/null || true)" + fi + if [[ -z "$home" ]]; then + home="/home/$user" + fi + printf '%s' "$home" +} + +resolve_nologin_shell() { + for cand in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin /bin/false; do + if [[ -x "$cand" ]]; then + printf '%s' "$cand" + return 0 + fi + done + printf '%s' "/usr/sbin/nologin" +} + +# Create openclaw user (non-login, with home) if missing +if ! user_exists "$OPENCLAW_USER"; then + NOLOGIN_SHELL="$(resolve_nologin_shell)" + echo "Creating user $OPENCLAW_USER ($NOLOGIN_SHELL, with home)..." + if command -v useradd >/dev/null 2>&1; then + run_root useradd -m -s "$NOLOGIN_SHELL" "$OPENCLAW_USER" + elif command -v adduser >/dev/null 2>&1; then + # Debian/Ubuntu: adduser supports --disabled-password/--gecos. Busybox adduser differs. + run_root adduser --disabled-password --gecos "" --shell "$NOLOGIN_SHELL" "$OPENCLAW_USER" + else + echo "Neither useradd nor adduser found, cannot create user $OPENCLAW_USER." >&2 + exit 1 + fi +else + echo "User $OPENCLAW_USER already exists." +fi + +OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" +OPENCLAW_UID="$(id -u "$OPENCLAW_USER" 2>/dev/null || true)" +OPENCLAW_CONFIG="$OPENCLAW_HOME/.openclaw" +LAUNCH_SCRIPT_DST="$OPENCLAW_HOME/run-openclaw-podman.sh" + +# Prefer systemd user services (Quadlet) for production. Enable lingering early so rootless Podman can run +# without an interactive login. +if command -v loginctl &>/dev/null; then + run_root loginctl enable-linger "$OPENCLAW_USER" 2>/dev/null || true +fi +if [[ -n "${OPENCLAW_UID:-}" && -d /run/user ]] && command -v systemctl &>/dev/null; then + if [[ ! -d "/run/user/$OPENCLAW_UID" ]]; then + run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "/run/user/$OPENCLAW_UID" || true + fi + run_root mkdir -p "/run/user/$OPENCLAW_UID/containers" || true + run_root chown "$OPENCLAW_UID:$OPENCLAW_UID" "/run/user/$OPENCLAW_UID/containers" || true + run_root chmod 700 "/run/user/$OPENCLAW_UID/containers" || true +fi + +mkdir_user_dirs_as_openclaw() { + run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$OPENCLAW_HOME" "$OPENCLAW_CONFIG" + run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$OPENCLAW_CONFIG/workspace" +} + +ensure_subid_entry() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 1 + fi + grep -q "^${OPENCLAW_USER}:" "$file" 2>/dev/null +} + +if ! ensure_subid_entry /etc/subuid || ! ensure_subid_entry /etc/subgid; then + echo "WARNING: ${OPENCLAW_USER} may not have subuid/subgid ranges configured." >&2 + echo "If rootless Podman fails, add 'openclaw:100000:65536' to both /etc/subuid and /etc/subgid." >&2 +fi + +mkdir_user_dirs_as_openclaw + +IMAGE_TMP_BASE="$(resolve_image_tmp_dir)" +echo "Using temp base for image export: $IMAGE_TMP_BASE" +IMAGE_TAR_DIR="$(mktemp -d "${IMAGE_TMP_BASE%/}/openclaw-podman-image.XXXXXX")" +chmod 700 "$IMAGE_TAR_DIR" +IMAGE_TAR="$IMAGE_TAR_DIR/openclaw-image.tar" +cleanup_image_tar() { + rm -rf "$IMAGE_TAR_DIR" +} +trap cleanup_image_tar EXIT + +BUILD_ARGS=() +if [[ -n "${OPENCLAW_DOCKER_APT_PACKAGES:-}" ]]; then + BUILD_ARGS+=(--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}") +fi +if [[ -n "${OPENCLAW_EXTENSIONS:-}" ]]; then + BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") +fi + +echo "Building image openclaw:local..." +podman build -t openclaw:local -f "$REPO_PATH/Dockerfile" "${BUILD_ARGS[@]}" "$REPO_PATH" +echo "Saving image to $IMAGE_TAR ..." +podman save -o "$IMAGE_TAR" openclaw:local + +echo "Loading image into $OPENCLAW_USER Podman store..." +run_as_openclaw podman load -i "$IMAGE_TAR" + +echo "Installing launch script to $LAUNCH_SCRIPT_DST ..." +run_root install -m 0755 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$RUN_SCRIPT_SRC" "$LAUNCH_SCRIPT_DST" + +if [[ ! -f "$OPENCLAW_CONFIG/.env" ]]; then + TOKEN="$(generate_token_hex_32)" + run_as_openclaw sh -lc "umask 077 && printf '%s\n' 'OPENCLAW_GATEWAY_TOKEN=$TOKEN' > '$OPENCLAW_CONFIG/.env'" + echo "Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $OPENCLAW_CONFIG/.env" +fi + +if [[ ! -f "$OPENCLAW_CONFIG/openclaw.json" ]]; then + run_as_openclaw sh -lc "umask 077 && cat > '$OPENCLAW_CONFIG/openclaw.json' <<'JSON' +{ \"gateway\": { \"mode\": \"local\" } } +JSON" + echo "Wrote minimal config to $OPENCLAW_CONFIG/openclaw.json" +fi + +if [[ "$INSTALL_QUADLET" == true ]]; then + QUADLET_DIR="$OPENCLAW_HOME/.config/containers/systemd" + QUADLET_DST="$QUADLET_DIR/openclaw.container" + echo "Installing Quadlet to $QUADLET_DST ..." + run_as_openclaw mkdir -p "$QUADLET_DIR" + OPENCLAW_HOME_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_HOME")" + sed "s|{{OPENCLAW_HOME}}|$OPENCLAW_HOME_ESCAPED|g" "$QUADLET_TEMPLATE" | \ + run_as_openclaw sh -lc "cat > '$QUADLET_DST'" + run_as_openclaw chmod 0644 "$QUADLET_DST" + + echo "Reloading and enabling user service..." + run_root systemctl --machine "${OPENCLAW_USER}@" --user daemon-reload + run_root systemctl --machine "${OPENCLAW_USER}@" --user enable --now openclaw.service + echo "Quadlet installed and service started." +else + echo "Container + launch script installed." +fi + +echo +echo "Next:" +echo " ./scripts/run-openclaw-podman.sh launch" +echo " ./scripts/run-openclaw-podman.sh launch setup" diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index 68b64915479..aa19d3350bf 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Rootless OpenClaw in Podman: run after one-time setup. # -# One-time setup (from repo root): ./setup-podman.sh +# One-time setup (from repo root): ./scripts/podman/setup.sh # Then: # ./scripts/run-openclaw-podman.sh launch # Start gateway # ./scripts/run-openclaw-podman.sh launch setup # Onboarding wizard @@ -10,7 +10,7 @@ # sudo -u openclaw /home/openclaw/run-openclaw-podman.sh # sudo -u openclaw /home/openclaw/run-openclaw-podman.sh setup # -# Legacy: "setup-host" delegates to ../setup-podman.sh +# Legacy: "setup-host" delegates to the Podman setup script set -euo pipefail @@ -35,15 +35,19 @@ OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" OPENCLAW_UID="$(id -u "$OPENCLAW_USER" 2>/dev/null || true)" LAUNCH_SCRIPT="$OPENCLAW_HOME/run-openclaw-podman.sh" -# Legacy: setup-host → run setup-podman.sh +# Legacy: setup-host → run the Podman setup script if [[ "${1:-}" == "setup-host" ]]; then shift REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + SETUP_PODMAN="$REPO_ROOT/scripts/podman/setup.sh" + if [[ -f "$SETUP_PODMAN" ]]; then + exec "$SETUP_PODMAN" "$@" + fi SETUP_PODMAN="$REPO_ROOT/setup-podman.sh" if [[ -f "$SETUP_PODMAN" ]]; then exec "$SETUP_PODMAN" "$@" fi - echo "setup-podman.sh not found at $SETUP_PODMAN. Run from repo root: ./setup-podman.sh" >&2 + echo "Podman setup script not found. Run from repo root: ./scripts/podman/setup.sh" >&2 exit 1 fi @@ -228,4 +232,4 @@ podman run --pull="$PODMAN_PULL" -d --replace \ echo "Container $CONTAINER_NAME started. Dashboard: http://127.0.0.1:${HOST_GATEWAY_PORT}/" echo "Logs: podman logs -f $CONTAINER_NAME" -echo "For auto-start/restarts, use: ./setup-podman.sh --quadlet (Quadlet + systemd user service)." +echo "For auto-start/restarts, use: ./scripts/podman/setup.sh --quadlet (Quadlet + systemd user service)." diff --git a/setup-podman.sh b/setup-podman.sh index 5b904684ffa..50a17a57bb0 100755 --- a/setup-podman.sh +++ b/setup-podman.sh @@ -1,312 +1,12 @@ #!/usr/bin/env bash -# One-time host setup for rootless OpenClaw in Podman: creates the openclaw -# user, builds the image, loads it into that user's Podman store, and installs -# the launch script. Run from repo root with sudo capability. -# -# Usage: ./setup-podman.sh [--quadlet|--container] -# --quadlet Install systemd Quadlet so the container runs as a user service -# --container Only install user + image + launch script; you start the container manually (default) -# Or set OPENCLAW_PODMAN_QUADLET=1 (or 0) to choose without a flag. -# -# After this, start the gateway manually: -# ./scripts/run-openclaw-podman.sh launch -# ./scripts/run-openclaw-podman.sh launch setup # onboarding wizard -# Or as the openclaw user: sudo -u openclaw /home/openclaw/run-openclaw-podman.sh -# If you used --quadlet, you can also: sudo systemctl --machine openclaw@ --user start openclaw.service set -euo pipefail -OPENCLAW_USER="${OPENCLAW_PODMAN_USER:-openclaw}" -REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" -RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh" -QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$ROOT_DIR/scripts/podman/setup.sh" -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing dependency: $1" >&2 - exit 1 - fi -} - -is_writable_dir() { - local dir="$1" - [[ -n "$dir" && -d "$dir" && ! -L "$dir" && -w "$dir" && -x "$dir" ]] -} - -is_safe_tmp_base() { - local dir="$1" - local mode="" - local owner="" - is_writable_dir "$dir" || return 1 - mode="$(stat -Lc '%a' "$dir" 2>/dev/null || true)" - if [[ -n "$mode" ]]; then - local perm=$((8#$mode)) - if (( (perm & 0022) != 0 && (perm & 01000) == 0 )); then - return 1 - fi - fi - if is_root; then - owner="$(stat -Lc '%u' "$dir" 2>/dev/null || true)" - if [[ -n "$owner" && "$owner" != "0" ]]; then - return 1 - fi - fi - return 0 -} - -resolve_image_tmp_dir() { - if ! is_root && is_safe_tmp_base "${TMPDIR:-}"; then - printf '%s' "$TMPDIR" - return 0 - fi - if is_safe_tmp_base "/var/tmp"; then - printf '%s' "/var/tmp" - return 0 - fi - if is_safe_tmp_base "/tmp"; then - printf '%s' "/tmp" - return 0 - fi - printf '%s' "/tmp" -} - -is_root() { [[ "$(id -u)" -eq 0 ]]; } - -run_root() { - if is_root; then - "$@" - else - sudo "$@" - fi -} - -run_as_user() { - # When switching users, the caller's cwd may be inaccessible to the target - # user (e.g. a private home dir). Wrap in a subshell that cd's to a - # world-traversable directory so sudo/runuser don't fail with "cannot chdir". - # TODO: replace with fully rootless podman build to eliminate the need for - # user-switching entirely. - local user="$1" - shift - if command -v sudo >/dev/null 2>&1; then - ( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" ) - elif is_root && command -v runuser >/dev/null 2>&1; then - ( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" ) - else - echo "Need sudo (or root+runuser) to run commands as $user." >&2 - exit 1 - fi -} - -run_as_openclaw() { - # Avoid root writes into $OPENCLAW_HOME (symlink/hardlink/TOCTOU footguns). - # Anything under the target user's home should be created/modified as that user. - run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" "$@" -} - -escape_sed_replacement_pipe_delim() { - # Escape replacement metacharacters for sed "s|...|...|g" replacement text. - printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' -} - -# Quadlet: opt-in via --quadlet or OPENCLAW_PODMAN_QUADLET=1 -INSTALL_QUADLET=false -for arg in "$@"; do - case "$arg" in - --quadlet) INSTALL_QUADLET=true ;; - --container) INSTALL_QUADLET=false ;; - esac -done -if [[ -n "${OPENCLAW_PODMAN_QUADLET:-}" ]]; then - case "${OPENCLAW_PODMAN_QUADLET,,}" in - 1|yes|true) INSTALL_QUADLET=true ;; - 0|no|false) INSTALL_QUADLET=false ;; - esac -fi - -require_cmd podman -if ! is_root; then - require_cmd sudo -fi -if [[ ! -f "$REPO_PATH/Dockerfile" ]]; then - echo "Dockerfile not found at $REPO_PATH. Set OPENCLAW_REPO_PATH to the repo root." >&2 - exit 1 -fi -if [[ ! -f "$RUN_SCRIPT_SRC" ]]; then - echo "Launch script not found at $RUN_SCRIPT_SRC." >&2 +if [[ ! -f "$SCRIPT_PATH" ]]; then + echo "Podman setup script not found at $SCRIPT_PATH" >&2 exit 1 fi -generate_token_hex_32() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex 32 - return 0 - fi - if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' -import secrets -print(secrets.token_hex(32)) -PY - return 0 - fi - if command -v od >/dev/null 2>&1; then - # 32 random bytes -> 64 lowercase hex chars - od -An -N32 -tx1 /dev/urandom | tr -d " \n" - return 0 - fi - echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2 - exit 1 -} - -user_exists() { - local user="$1" - if command -v getent >/dev/null 2>&1; then - getent passwd "$user" >/dev/null 2>&1 && return 0 - fi - id -u "$user" >/dev/null 2>&1 -} - -resolve_user_home() { - local user="$1" - local home="" - if command -v getent >/dev/null 2>&1; then - home="$(getent passwd "$user" 2>/dev/null | cut -d: -f6 || true)" - fi - if [[ -z "$home" && -f /etc/passwd ]]; then - home="$(awk -F: -v u="$user" '$1==u {print $6}' /etc/passwd 2>/dev/null || true)" - fi - if [[ -z "$home" ]]; then - home="/home/$user" - fi - printf '%s' "$home" -} - -resolve_nologin_shell() { - for cand in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin /bin/false; do - if [[ -x "$cand" ]]; then - printf '%s' "$cand" - return 0 - fi - done - printf '%s' "/usr/sbin/nologin" -} - -# Create openclaw user (non-login, with home) if missing -if ! user_exists "$OPENCLAW_USER"; then - NOLOGIN_SHELL="$(resolve_nologin_shell)" - echo "Creating user $OPENCLAW_USER ($NOLOGIN_SHELL, with home)..." - if command -v useradd >/dev/null 2>&1; then - run_root useradd -m -s "$NOLOGIN_SHELL" "$OPENCLAW_USER" - elif command -v adduser >/dev/null 2>&1; then - # Debian/Ubuntu: adduser supports --disabled-password/--gecos. Busybox adduser differs. - run_root adduser --disabled-password --gecos "" --shell "$NOLOGIN_SHELL" "$OPENCLAW_USER" - else - echo "Neither useradd nor adduser found, cannot create user $OPENCLAW_USER." >&2 - exit 1 - fi -else - echo "User $OPENCLAW_USER already exists." -fi - -OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" -OPENCLAW_UID="$(id -u "$OPENCLAW_USER" 2>/dev/null || true)" -OPENCLAW_CONFIG="$OPENCLAW_HOME/.openclaw" -LAUNCH_SCRIPT_DST="$OPENCLAW_HOME/run-openclaw-podman.sh" - -# Prefer systemd user services (Quadlet) for production. Enable lingering early so rootless Podman can run -# without an interactive login. -if command -v loginctl &>/dev/null; then - run_root loginctl enable-linger "$OPENCLAW_USER" 2>/dev/null || true -fi -if [[ -n "${OPENCLAW_UID:-}" && -d /run/user ]] && command -v systemctl &>/dev/null; then - run_root systemctl start "user@${OPENCLAW_UID}.service" 2>/dev/null || true -fi - -# Rootless Podman needs subuid/subgid for the run user -if ! grep -q "^${OPENCLAW_USER}:" /etc/subuid 2>/dev/null; then - echo "Warning: $OPENCLAW_USER has no subuid range. Rootless Podman may fail." >&2 - echo " Add a line to /etc/subuid and /etc/subgid, e.g.: $OPENCLAW_USER:100000:65536" >&2 -fi - -echo "Creating $OPENCLAW_CONFIG and workspace..." -run_as_openclaw mkdir -p "$OPENCLAW_CONFIG/workspace" -run_as_openclaw chmod 700 "$OPENCLAW_CONFIG" "$OPENCLAW_CONFIG/workspace" 2>/dev/null || true - -ENV_FILE="$OPENCLAW_CONFIG/.env" -if run_as_openclaw test -f "$ENV_FILE"; then - if ! run_as_openclaw grep -q '^OPENCLAW_GATEWAY_TOKEN=' "$ENV_FILE" 2>/dev/null; then - TOKEN="$(generate_token_hex_32)" - printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "$TOKEN" | run_as_openclaw tee -a "$ENV_FILE" >/dev/null - echo "Added OPENCLAW_GATEWAY_TOKEN to $ENV_FILE." - fi - run_as_openclaw chmod 600 "$ENV_FILE" 2>/dev/null || true -else - TOKEN="$(generate_token_hex_32)" - printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "$TOKEN" | run_as_openclaw tee "$ENV_FILE" >/dev/null - run_as_openclaw chmod 600 "$ENV_FILE" 2>/dev/null || true - echo "Created $ENV_FILE with new token." -fi - -# The gateway refuses to start unless gateway.mode=local is set in config. -# Make first-run non-interactive; users can run the wizard later to configure channels/providers. -OPENCLAW_JSON="$OPENCLAW_CONFIG/openclaw.json" -if ! run_as_openclaw test -f "$OPENCLAW_JSON"; then - printf '%s\n' '{ gateway: { mode: "local" } }' | run_as_openclaw tee "$OPENCLAW_JSON" >/dev/null - run_as_openclaw chmod 600 "$OPENCLAW_JSON" 2>/dev/null || true - echo "Created $OPENCLAW_JSON (minimal gateway.mode=local)." -fi - -echo "Building image from $REPO_PATH..." -BUILD_ARGS=() -[[ -n "${OPENCLAW_DOCKER_APT_PACKAGES:-}" ]] && BUILD_ARGS+=(--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}") -[[ -n "${OPENCLAW_EXTENSIONS:-}" ]] && BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") -podman build ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} -t openclaw:local -f "$REPO_PATH/Dockerfile" "$REPO_PATH" - -echo "Loading image into $OPENCLAW_USER's Podman store..." -TMP_IMAGE_DIR="$(resolve_image_tmp_dir)" -echo "Using temporary image dir: $TMP_IMAGE_DIR" -TMP_STAGE_DIR="$(mktemp -d -p "$TMP_IMAGE_DIR" openclaw-image.XXXXXX)" -TMP_IMAGE="$TMP_STAGE_DIR/image.tar" -chmod 700 "$TMP_STAGE_DIR" -trap 'rm -rf "$TMP_STAGE_DIR"' EXIT -podman save openclaw:local -o "$TMP_IMAGE" -chmod 600 "$TMP_IMAGE" -# Stream the image into the target user's podman load so private temp directories -# do not need to be traversable by $OPENCLAW_USER. -cat "$TMP_IMAGE" | run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" podman load -rm -rf "$TMP_STAGE_DIR" -trap - EXIT - -echo "Copying launch script to $LAUNCH_SCRIPT_DST..." -run_root cat "$RUN_SCRIPT_SRC" | run_as_openclaw tee "$LAUNCH_SCRIPT_DST" >/dev/null -run_as_openclaw chmod 755 "$LAUNCH_SCRIPT_DST" - -# Optionally install systemd quadlet for openclaw user (rootless Podman + systemd) -QUADLET_DIR="$OPENCLAW_HOME/.config/containers/systemd" -if [[ "$INSTALL_QUADLET" == true && -f "$QUADLET_TEMPLATE" ]]; then - echo "Installing systemd quadlet for $OPENCLAW_USER..." - run_as_openclaw mkdir -p "$QUADLET_DIR" - OPENCLAW_HOME_SED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_HOME")" - sed "s|{{OPENCLAW_HOME}}|$OPENCLAW_HOME_SED|g" "$QUADLET_TEMPLATE" | run_as_openclaw tee "$QUADLET_DIR/openclaw.container" >/dev/null - run_as_openclaw chmod 700 "$OPENCLAW_HOME/.config" "$OPENCLAW_HOME/.config/containers" "$QUADLET_DIR" 2>/dev/null || true - run_as_openclaw chmod 600 "$QUADLET_DIR/openclaw.container" 2>/dev/null || true - if command -v systemctl &>/dev/null; then - run_root systemctl --machine "${OPENCLAW_USER}@" --user daemon-reload 2>/dev/null || true - run_root systemctl --machine "${OPENCLAW_USER}@" --user enable openclaw.service 2>/dev/null || true - run_root systemctl --machine "${OPENCLAW_USER}@" --user start openclaw.service 2>/dev/null || true - fi -fi - -echo "" -echo "Setup complete. Start the gateway:" -echo " $RUN_SCRIPT_SRC launch" -echo " $RUN_SCRIPT_SRC launch setup # onboarding wizard" -echo "Or as $OPENCLAW_USER (e.g. from cron):" -echo " sudo -u $OPENCLAW_USER $LAUNCH_SCRIPT_DST" -echo " sudo -u $OPENCLAW_USER $LAUNCH_SCRIPT_DST setup" -if [[ "$INSTALL_QUADLET" == true ]]; then - echo "Or use systemd (quadlet):" - echo " sudo systemctl --machine ${OPENCLAW_USER}@ --user start openclaw.service" - echo " sudo systemctl --machine ${OPENCLAW_USER}@ --user status openclaw.service" -else - echo "To install systemd quadlet later: $0 --quadlet" -fi +exec "$SCRIPT_PATH" "$@" diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 3b46aac5c0c..04b3823388f 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -50,13 +50,14 @@ exit 0 async function createDockerSetupSandbox(): Promise { const rootDir = await mkdtemp(join(tmpdir(), "openclaw-docker-setup-")); - const scriptPath = join(rootDir, "docker-setup.sh"); + const scriptPath = join(rootDir, "scripts", "docker", "setup.sh"); const dockerfilePath = join(rootDir, "Dockerfile"); const composePath = join(rootDir, "docker-compose.yml"); const binDir = join(rootDir, "bin"); const logPath = join(rootDir, "docker-stub.log"); - await copyFile(join(repoRoot, "docker-setup.sh"), scriptPath); + await mkdir(join(rootDir, "scripts", "docker"), { recursive: true }); + await copyFile(join(repoRoot, "scripts", "docker", "setup.sh"), scriptPath); await chmod(scriptPath, 0o755); await writeFile(dockerfilePath, "FROM scratch\n"); await writeFile( @@ -168,7 +169,7 @@ function resolveBashForCompatCheck(): string | null { return null; } -describe("docker-setup.sh", () => { +describe("scripts/docker/setup.sh", () => { let sandbox: DockerSetupSandbox | null = null; beforeAll(async () => { @@ -439,7 +440,7 @@ describe("docker-setup.sh", () => { }); it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => { - const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8"); + const script = await readFile(join(repoRoot, "scripts", "docker", "setup.sh"), "utf8"); expect(script).not.toMatch(/^\s*declare -A\b/m); const systemBash = resolveBashForCompatCheck(); @@ -456,9 +457,13 @@ describe("docker-setup.sh", () => { return; } - const syntaxCheck = spawnSync(systemBash, ["-n", join(repoRoot, "docker-setup.sh")], { - encoding: "utf8", - }); + const syntaxCheck = spawnSync( + systemBash, + ["-n", join(repoRoot, "scripts", "docker", "setup.sh")], + { + encoding: "utf8", + }, + ); expect(syntaxCheck.status).toBe(0); expect(syntaxCheck.stderr).not.toContain("declare: -A: invalid option");