#!/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:-}" 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 } contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] } 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 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 mkdir -p "$OPENCLAW_CONFIG_DIR" mkdir -p "$OPENCLAW_WORKSPACE_DIR" 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_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then if 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 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 echo "==> Building Docker image: $IMAGE_NAME" docker build \ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ -t "$IMAGE_NAME" \ -f "$ROOT_DIR/Dockerfile" \ "$ROOT_DIR" echo "" echo "==> Onboarding (interactive)" echo "When prompted:" echo " - Gateway bind: lan" echo " - Gateway auth: token" echo " - Gateway token: $OPENCLAW_GATEWAY_TOKEN" echo " - Tailscale exposure: Off" echo " - Install Gateway daemon: No" echo "" docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon 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 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\""