Files
openclaw/scripts/podman/setup.sh
Sally O'Malley df5b9ef0c6 update podman setup and docs (#55388)
* update podman setup and docs

Signed-off-by: sallyom <somalley@redhat.com>

* podman: persist runtime env defaults

Co-authored-by: albertxos <kickban3000@gmail.com>
Signed-off-by: sallyom <somalley@redhat.com>

* podman: harden env and path handling, other setup updates

Signed-off-by: sallyom <somalley@redhat.com>

* podman: allow symlinked home path components

Signed-off-by: sallyom <somalley@redhat.com>

* update podman docs

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: albertxos <kickban3000@gmail.com>
2026-03-27 11:47:35 -04:00

459 lines
14 KiB
Bash
Executable File

#!/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)}"
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"
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 ]]; }
fail() {
echo "$*" >&2
exit 1
}
validate_single_line_value() {
local label="$1"
local value="$2"
if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then
fail "Invalid $label: control characters are not allowed."
fi
}
validate_absolute_path() {
local label="$1"
local value="$2"
validate_single_line_value "$label" "$value"
[[ "$value" == /* ]] || fail "Invalid $label: expected an absolute path."
[[ "$value" != *"//"* ]] || fail "Invalid $label: repeated slashes are not allowed."
[[ "$value" != *"/./"* && "$value" != */. && "$value" != *"/../"* && "$value" != */.. ]] ||
fail "Invalid $label: dot path segments are not allowed."
}
validate_mount_source_path() {
local label="$1"
local value="$2"
validate_absolute_path "$label" "$value"
[[ "$value" != *:* ]] || fail "Invalid $label: ':' is not allowed in Podman bind-mount source paths."
}
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"
}
ensure_safe_existing_dir() {
local label="$1"
local dir="$2"
validate_absolute_path "$label" "$dir"
[[ -d "$dir" ]] || fail "Missing $label: $dir"
[[ ! -L "$dir" ]] || fail "Unsafe $label: symlinks are not allowed ($dir)"
}
stat_uid() {
local path="$1"
if stat -f '%u' "$path" >/dev/null 2>&1; then
stat -f '%u' "$path"
else
stat -Lc '%u' "$path"
fi
}
stat_mode() {
local path="$1"
if stat -f '%Lp' "$path" >/dev/null 2>&1; then
stat -f '%Lp' "$path"
else
stat -Lc '%a' "$path"
fi
}
ensure_private_existing_dir_owned_by_user() {
local label="$1"
local dir="$2"
local uid=""
local mode=""
ensure_safe_existing_dir "$label" "$dir"
uid="$(stat_uid "$dir")"
[[ "$uid" == "$(id -u)" ]] || fail "Unsafe $label: not owned by current user ($dir)"
mode="$(stat_mode "$dir")"
(( (8#$mode & 0022) == 0 )) || fail "Unsafe $label: group/other writable ($dir)"
}
ensure_safe_write_file_path() {
local label="$1"
local file="$2"
local dir
validate_absolute_path "$label" "$file"
if [[ -e "$file" ]]; then
[[ ! -L "$file" ]] || fail "Unsafe $label: symlinks are not allowed ($file)"
[[ -f "$file" ]] || fail "Unsafe $label: expected a regular file ($file)"
fi
dir="$(dirname "$file")"
ensure_safe_existing_dir "${label} parent directory" "$dir"
}
write_file_atomically() {
local file="$1"
local mode="$2"
local dir=""
local tmp=""
ensure_safe_write_file_path "output file" "$file"
dir="$(dirname "$file")"
tmp="$(mktemp "$dir/.tmp.XXXXXX")"
cat >"$tmp"
chmod "$mode" "$tmp"
mv -f "$tmp" "$file"
}
validate_port() {
local label="$1"
local value="$2"
local numeric=""
[[ "$value" =~ ^[0-9]{1,5}$ ]] || fail "Invalid $label: must be numeric."
numeric=$((10#$value))
(( numeric >= 1 && numeric <= 65535 )) || fail "Invalid $label: out of range."
}
escape_sed_replacement_pipe_delim() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}
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"
}
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
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
}
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"
}
upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local tmp
local dir
ensure_safe_write_file_path "env file" "$file"
dir="$(dirname "$file")"
tmp="$(mktemp "$dir/.env.tmp.XXXXXX")"
if [[ -f "$file" ]]; then
awk -v k="$key" -v v="$value" '
BEGIN { found = 0 }
$0 ~ ("^" k "=") { print k "=" v; found = 1; next }
{ print }
END { if (!found) print k "=" v }
' "$file" >"$tmp"
else
printf '%s=%s\n' "$key" "$value" >"$tmp"
fi
mv "$tmp" "$file"
chmod 600 "$file" 2>/dev/null || true
}
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"
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
if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]]; then
echo "Building image $OPENCLAW_IMAGE ..."
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 ..."
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 <<EOF
OPENCLAW_GATEWAY_TOKEN=$TOKEN
EOF
)
echo "Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $ENV_FILE"
fi
upsert_env_var "$ENV_FILE" "OPENCLAW_PODMAN_CONTAINER" "$OPENCLAW_CONTAINER_NAME"
upsert_env_var "$ENV_FILE" "OPENCLAW_PODMAN_IMAGE" "$OPENCLAW_IMAGE"
CONFIG_JSON="$OPENCLAW_CONFIG_DIR/openclaw.json"
if [[ ! -f "$CONFIG_JSON" ]]; then
(
umask 077
write_file_atomically "$CONFIG_JSON" 600 <<JSON
{
"gateway": {
"mode": "local",
"controlUi": {
"allowedOrigins": [
"http://127.0.0.1:${SEED_GATEWAY_PORT}",
"http://localhost:${SEED_GATEWAY_PORT}"
]
}
}
}
JSON
)
echo "Wrote minimal config to $CONFIG_JSON"
fi
seed_local_control_ui_origins "$CONFIG_JSON" "$SEED_GATEWAY_PORT"
if [[ "$INSTALL_QUADLET" == true ]]; then
QUADLET_DIR="$OPENCLAW_HOME/.config/containers/systemd"
QUADLET_DST="$QUADLET_DIR/openclaw.container"
echo "Installing Quadlet to $QUADLET_DST ..."
mkdir -p "$QUADLET_DIR"
ensure_safe_existing_dir "quadlet directory" "$QUADLET_DIR"
OPENCLAW_HOME_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_HOME")"
OPENCLAW_CONFIG_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_CONFIG_DIR")"
OPENCLAW_WORKSPACE_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_WORKSPACE_DIR")"
OPENCLAW_IMAGE_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_IMAGE")"
OPENCLAW_CONTAINER_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_CONTAINER_NAME")"
sed \
-e "s|{{OPENCLAW_HOME}}|$OPENCLAW_HOME_ESCAPED|g" \
-e "s|{{OPENCLAW_CONFIG_DIR}}|$OPENCLAW_CONFIG_ESCAPED|g" \
-e "s|{{OPENCLAW_WORKSPACE_DIR}}|$OPENCLAW_WORKSPACE_ESCAPED|g" \
-e "s|{{IMAGE_NAME}}|$OPENCLAW_IMAGE_ESCAPED|g" \
-e "s|{{CONTAINER_NAME}}|$OPENCLAW_CONTAINER_ESCAPED|g" \
"$QUADLET_TEMPLATE" | write_file_atomically "$QUADLET_DST" 644
if command -v systemctl >/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"