#!/usr/bin/env bash # One-time host setup for rootless OpenClaw in Podman. Uses the current # non-root user throughout, builds or pulls the image into that user's Podman # store, writes config under ~/.openclaw by default, and uses the repo-local # launch script at ./scripts/run-openclaw-podman.sh. # # Usage: ./scripts/podman/setup.sh [--quadlet|--container] # --quadlet Install a Podman Quadlet as the current user's systemd service # --container Only install image + config; 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 # Or, if you used --quadlet: # systemctl --user start openclaw.service set -euo pipefail REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" source "$REPO_PATH/scripts/lib/build-metadata.sh" source "$REPO_PATH/scripts/lib/host-timeout.sh" # shellcheck source=scripts/podman/common.sh source "$REPO_PATH/scripts/podman/common.sh" RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh" QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in" OPENCLAW_USER="$(id -un)" OPENCLAW_HOME="${HOME:-}" OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-}" OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-${OPENCLAW_IMAGE:-openclaw:local}}" OPENCLAW_CONTAINER_NAME="${OPENCLAW_PODMAN_CONTAINER:-openclaw}" PLATFORM_NAME="$(uname -s 2>/dev/null || echo unknown)" HOST_GATEWAY_PORT="${OPENCLAW_PODMAN_GATEWAY_HOST_PORT:-${OPENCLAW_GATEWAY_PORT:-18789}}" QUADLET_GATEWAY_PORT="18789" PODMAN_PULL_TIMEOUT="${OPENCLAW_PODMAN_SETUP_PULL_TIMEOUT:-600s}" PODMAN_BUILD_TIMEOUT="${OPENCLAW_PODMAN_SETUP_BUILD_TIMEOUT:-1800s}" require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing dependency: $1" >&2 exit 1 fi } is_root() { [[ "$(id -u)" -eq 0 ]]; } run_podman_pull() { local image="$1" openclaw_host_timeout_cmd "$PODMAN_PULL_TIMEOUT" podman pull "$image" } run_podman_build() { openclaw_host_timeout_cmd "$PODMAN_BUILD_TIMEOUT" podman build "$@" } validate_container_name() { local value="$1" validate_single_line_value "container name" "$value" [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || fail "Invalid container name: $value" } validate_image_name() { local value="$1" validate_single_line_value "image name" "$value" case "$value" in oci-archive:*|docker-archive:*|dir:*|oci:*|containers-storage:*|docker-daemon:*|archive:* ) fail "Invalid image name: transport prefixes are not allowed: $value" ;; esac [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] || fail "Invalid image name: $value" } escape_sed_replacement_pipe_delim() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' } seed_local_control_ui_origins() { local file="$1" local port="$2" local dir="" local tmp="" ensure_safe_write_file_path "config file" "$file" if ! command -v python3 >/dev/null 2>&1; then echo "Warning: python3 not found; unable to seed gateway.controlUi.allowedOrigins in $file." >&2 return 0 fi dir="$(dirname "$file")" tmp="$(mktemp "$dir/.config.tmp.XXXXXX")" if ! python3 - "$file" "$port" "$tmp" <<'PY' import json import sys path = sys.argv[1] port = sys.argv[2] tmp = sys.argv[3] try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) except json.JSONDecodeError as exc: print( f"Warning: unable to seed gateway.controlUi.allowedOrigins in {path}: existing config is not strict JSON ({exc}). Leaving file unchanged.", file=sys.stderr, ) raise SystemExit(1) if not isinstance(data, dict): raise SystemExit(f"{path}: expected top-level object") gateway = data.setdefault("gateway", {}) if not isinstance(gateway, dict): raise SystemExit(f"{path}: expected gateway object") gateway.setdefault("mode", "local") control_ui = gateway.setdefault("controlUi", {}) if not isinstance(control_ui, dict): raise SystemExit(f"{path}: expected gateway.controlUi object") allowed = control_ui.get("allowedOrigins") managed_localhosts = {"127.0.0.1", "localhost"} desired = [ f"http://127.0.0.1:{port}", f"http://localhost:{port}", ] if not isinstance(allowed, list): allowed = [] cleaned = [] for origin in allowed: if not isinstance(origin, str): continue normalized = origin.strip() if not normalized: continue if normalized.startswith("http://"): host_port = normalized[len("http://") :] host = host_port.split(":", 1)[0] if host in managed_localhosts: continue cleaned.append(normalized) control_ui["allowedOrigins"] = cleaned + desired with open(tmp, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) fh.write("\n") PY then rm -f "$tmp" return 0 fi [[ -s "$tmp" ]] || { rm -f "$tmp" return 0 } chmod 600 "$tmp" 2>/dev/null || true mv -f "$tmp" "$file" } 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 if [[ "$INSTALL_QUADLET" == true && "$PLATFORM_NAME" != "Linux" ]]; then fail "--quadlet is only supported on Linux with systemd user services." fi SEED_GATEWAY_PORT="$HOST_GATEWAY_PORT" if [[ "$INSTALL_QUADLET" == true ]]; then SEED_GATEWAY_PORT="$QUADLET_GATEWAY_PORT" fi require_cmd podman if is_root; then echo "Run scripts/podman/setup.sh as your normal user so Podman stays rootless." >&2 exit 1 fi if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]] && [[ ! -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 if [[ -z "$OPENCLAW_HOME" ]]; then OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" fi if [[ -z "$OPENCLAW_HOME" ]]; then echo "Unable to resolve HOME for user $OPENCLAW_USER." >&2 exit 1 fi if [[ -z "$OPENCLAW_CONFIG_DIR" ]]; then OPENCLAW_CONFIG_DIR="$OPENCLAW_HOME/.openclaw" fi if [[ -z "$OPENCLAW_WORKSPACE_DIR" ]]; then OPENCLAW_WORKSPACE_DIR="$OPENCLAW_CONFIG_DIR/workspace" fi validate_absolute_path "home directory" "$OPENCLAW_HOME" validate_mount_source_path "config directory" "$OPENCLAW_CONFIG_DIR" validate_mount_source_path "workspace directory" "$OPENCLAW_WORKSPACE_DIR" validate_container_name "$OPENCLAW_CONTAINER_NAME" validate_image_name "$OPENCLAW_IMAGE" validate_port "gateway host port" "$HOST_GATEWAY_PORT" validate_port "seed gateway port" "$SEED_GATEWAY_PORT" install -d -m 700 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_WORKSPACE_DIR" ensure_private_existing_dir_owned_by_user "config directory" "$OPENCLAW_CONFIG_DIR" ensure_private_existing_dir_owned_by_user "workspace directory" "$OPENCLAW_WORKSPACE_DIR" OPENCLAW_IMAGE_APT_PACKAGES="${OPENCLAW_IMAGE_APT_PACKAGES-${OPENCLAW_DOCKER_APT_PACKAGES:-}}" OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}" BUILD_ARGS=() BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$REPO_PATH")" BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)" BUILD_ARGS+=(--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}") if [[ "$BUILD_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then BUILD_ARGS+=(--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}") fi if [[ -n "$OPENCLAW_IMAGE_APT_PACKAGES" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}") fi if [[ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}") fi if [[ -n "${OPENCLAW_EXTENSIONS:-}" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") fi if [[ -n "${OPENCLAW_INSTALL_BROWSER:-}" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}") fi if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]]; then echo "Building image $OPENCLAW_IMAGE ..." run_podman_build -t "$OPENCLAW_IMAGE" -f "$REPO_PATH/Dockerfile" "${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"}" "$REPO_PATH" else if podman image exists "$OPENCLAW_IMAGE" >/dev/null 2>&1; then echo "Using existing image $OPENCLAW_IMAGE" else echo "Pulling image $OPENCLAW_IMAGE ..." run_podman_pull "$OPENCLAW_IMAGE" fi fi ENV_FILE="$OPENCLAW_CONFIG_DIR/.env" if [[ ! -f "$ENV_FILE" ]]; then TOKEN="$(generate_token_hex_32)" ( umask 077 write_file_atomically "$ENV_FILE" 600 </dev/null 2>&1; then echo "Reloading and starting user service..." if systemctl --user daemon-reload && systemctl --user start openclaw.service; then echo "Quadlet installed and service started." else echo "Quadlet installed, but automatic start failed." >&2 echo "Try: systemctl --user daemon-reload && systemctl --user start openclaw.service" >&2 if command -v loginctl >/dev/null 2>&1; then echo "For boot persistence on headless hosts, you may also need: sudo loginctl enable-linger $(whoami)" >&2 fi fi else echo "systemctl not found; Quadlet installed but not started." >&2 fi else echo "Container setup complete." fi echo echo "Next:" echo " ./scripts/run-openclaw-podman.sh launch" echo " ./scripts/run-openclaw-podman.sh launch setup" echo " openclaw --container $OPENCLAW_CONTAINER_NAME dashboard --no-open"