Files
ai-suite/openclaw_ctl
T

1183 lines
37 KiB
Bash

#!/usr/bin/env bash
# Trevor SANDY
# Last Update April, 30 2026
# Copyright (C) 2026 by Trevor SANDY
#
# Change Updates
# https://github.com/openclaw/openclaw/blob/main/scripts/docker/setup.sh
# 490e6d6 - 30/04/2026
# 66f4b52 - 28/04/2026
set -euo pipefail
: "${APP_NAME:='AI-Suite'}"
: "${DEBUG_ON:=false}"
: "${PLATFORM:='unknown'}"
: "${PRIMARY_ARG:=''}"
: "${ENV_MODE:=''}"
: "${SILENT:=0}"
# Reset BASH time counter
SECONDS=0
# Colors
SGR=''
END=''
# Core SGR support detection
if [[ "${SILENT:-0}" == 0 && -t 1 ]]; then
if colors=$(tput colors 2>/dev/null) && [[ "$colors" -ge 8 ]]; then
SGR=$'\033['
END="${SGR}0m"
fi
fi
# Primitive style flags
BOLD=''
DIM=''
ITALIC=''
UNDERLINE=''
# Base colors
RED=''
GREEN=''
YELLOW=''
BLUE=''
MAGENTA=''
CYAN=''
WHITE=''
# Composite tokens
DIM_CYAN=''
BOLD_MAGENTA=''
ITALIC_RED_BG=''
UNDERLINE_YELLOW=''
# Semantic tokens
HEADER=''
BODY=' - '
COLON=':'
APP="${APP_NAME}${COLON}"
ERROR="${APP} ERROR"
INFO="${APP} INFO"
# Apply SGR layer once
if [[ -n "$SGR" ]]; then
BOLD='1;'
DIM='2;'
ITALIC='3;'
UNDERLINE='4;'
RED="${SGR}31m"
GREEN="${SGR}32m"
YELLOW="${SGR}33m"
BLUE="${SGR}34m"
MAGENTA="${SGR}35m"
CYAN="${SGR}36m"
WHITE="${SGR}37m"
DIM_CYAN="${SGR}${DIM}36m"
BOLD_MAGENTA="${SGR}${BOLD}95m"
ITALIC_RED_BG="${SGR}${ITALIC}41m"
UNDERLINE_YELLOW="${SGR}${UNDERLINE}93m"
HEADER="${SGR}${BOLD}${UNDERLINE}92m"
BODY="${SGR}37m -${END} ${SGR}32m"
COLON="${SGR}97m:"
APP="${SGR}${ITALIC}94m${APP_NAME}${COLON}${END}"
fi
# Log level names
declare -A LOG_LEVEL_NAME=(
[NOTICE]='NOTICE'
[CRITICAL]='CRITICAL'
[ERROR]='ERROR'
[WARNING]='WARNING'
[DEBUG]='DEBUG'
[INFO]='INFO'
)
# Log level styles
declare -A LOG_LEVEL_STYLE=(
[NOTICE]="${SGR}${BOLD}95m" # MAGENTA
[CRITICAL]="${SGR}${BOLD}41m" # RED_BG
[ERROR]="${SGR}${BOLD}91m" # RED
[WARNING]="${SGR}${BOLD}${UNDERLINE}93m" # YELLOW
[DEBUG]="${SGR}${BOLD}97m" # WHITE
[INFO]="${SGR}36m" # CYAN
)
# Log message styles
declare -A LOG_MESSAGE_STYLE=(
[NOTICE]="$BOLD_MAGENTA"
[CRITICAL]="$ITALIC_RED_BG"
[ERROR]="$RED"
[WARNING]="$UNDERLINE_YELLOW"
[DEBUG]="$WHITE"
[INFO]="$DIM_CYAN"
)
# Prefix cache (precomputed for speed)
log_header_prefix() {
local -n _ref=$1
local level=$2
local prefix
if [[ -n "$SGR" ]]; then
prefix="${APP} ${LOG_LEVEL_STYLE[$level]}${LOG_LEVEL_NAME[$level]}${END}"
else
prefix="${APP} ${LOG_LEVEL_NAME[$level]}"
fi
_ref=$prefix
}
declare -A LOG_HEADER_PREFIX
for level in "${!LOG_LEVEL_NAME[@]}"; do
log_header_prefix LOG_HEADER_PREFIX["$level"] "$level"
done
LOG_TIMESTAMP="${LOG_TIMESTAMP:-false}"
# Injection-aware logger
log() {
local level="$1"; shift
[[ "$level" == DEBUG && "$DEBUG_ON" != true ]] && return
local header="${LOG_HEADER_PREFIX[$level]}"
local message="${LOG_MESSAGE_STYLE[$level]}"
if [[ -n "$SGR" && "$*" == *$'\033['* ]]; then
message="${END}$*"
else
message="${message}$*"
fi
if [[ "$LOG_TIMESTAMP" == true ]]; then
printf -v header '%(%Y-%m-%d %H:%M:%S)T %s' -1 "$header"
fi
if [[ "$level" == CRITICAL || "$level" == ERROR ]]; then
printf '%s %s%s\n' "$header" "$message" "$END" >&2
else
printf '%s %s%s\n' "$header" "$message" "$END"
fi
}
# Semantic tokens
log_header_prefix ERROR 'ERROR'
log_header_prefix INFO 'INFO'
# Wrappers
log_notice() { log NOTICE "$*"; }
log_critical() { log CRITICAL "$*"; }
log_error() { log ERROR "$*"; }
log_warning() { log WARNING "$*"; }
log_debug() { log DEBUG "$*"; }
log_info() { log INFO "$*"; }
fail() {
log_critical "$*"
exit 1
}
strip_sgr() {
local line sgr=$'\033'
while IFS= read -r line; do
while [[ $line == *"${sgr}["*m* ]]; do
line="${line/${sgr}\[[0-79;]\{1,11\}m/}"
done
printf '%s\n' "$line"
done
# - The substitution regex explained:
# line original line
# / substitution delimeter '/'
# $sgr match the SGR escape sequence '\x1b' before the color or attribute code
# \[ matches the first open bracket - escape '\[' to distinguish from regex [
# [0-79;]\{1,11\} matches '1 to 11' of any character in '012345679;' - escape the curly braces
# with '\{' to keep the shell from mangling them
# we have 11 times due to bold, dim, italic, underline and color * 2 plus reset * 1
# m match the SGR escape sequence reset character 'm' - this trails the color code
# / substitution delimeter '/'
}
# Real-time tee with SGR stripping
SCRIPT="${BASH_SOURCE[0]##*/}"
if [[ "$SCRIPT" == "${0##*/}" ]]; then
LOG_PATH="${AC_LOG_PATH:-$PWD}"
LOG="$LOG_PATH/$SCRIPT.log"
[[ -f "$LOG" && -r "$LOG" ]] && rm "$LOG"
# Strip SGR color sequence codes from output"
exec > >(tee >(strip_sgr >>"$LOG"))
exec 2> >(tee >(strip_sgr >>"$LOG") >&2)
fi
# Capture elapsed execution time
# shellcheck disable=SC2329
finish_elapsed_time() {
set +x
local ELAPSED
ELAPSED="$((SECONDS / 3600))hrs $(((SECONDS / 60) % 60))min $((SECONDS % 60))sec"
printf '%s\n' "${INFO} ${CYAN}Elapsed time:${END} ${GREEN}$ELAPSED${END}"
printf '%s\n' "${INFO} ${GREEN}-------------------------------------------${END}"
}
completion=''
# shellcheck disable=SC2329
finish () {
local header="${END}${HEADER}"
local action="Set .env"
local status="Completed"
case "$PRIMARY_ARG" in
--setenv) : ;;
--onboarding) action="Onboarding" ;;
--configuration) action="Configuration" ;;
--gateway) action="Gateway" ;;
*) action="Unknown" ;;
esac
case "$completion" in
Success!) : ;;
Partial!)
header="${END}⚠️ ${HEADER}"
status="Finished"
;;
*)
header="${END}${SGR}${BOLD}${UNDERLINE}91m"
status="Terminated"
;;
esac
log_info "${header}OpenClaw - $action $status"
#-------------------------------------------
finish_elapsed_time
}
trap finish EXIT
########################################
# ENVIRONMENT
########################################
log_info "${HEADER}OpenClaw - Environment"
#-------------------------------------------
detect_arch() {
local -n _ref=$1
case $(uname -m) in
x86_64) _ref='amd64' ;;
aarch64 | arm64) _ref='arm64' ;;
armv7l) _ref='arm' ;;
i686 | i386) _ref='386' ;;
*) _ref='err' ;;
esac
}
detect_os() {
local -n _ref=$1
case $(uname | tr '[:upper:]' '[:lower:]') in
linux*) _ref='linux' ;;
darwin*) _ref='darwin' ;;
*) _ref='err' ;;
esac
}
is_wsl() {
case "$(uname -r)" in
*icrosoft*WSL2 | *icrosoft*wsl2) return ;;
*icrosoft) fail "Microsoft WSL1 is not supported. Use WSL2 with 'wsl --set-version <distro> 2'" ;;
*) return 1 ;;
esac
}
os=''
detect_os os
case "$os" in
linux*)
if is_wsl; then
PLATFORM="wsl"
else
PLATFORM="linux"
fi
;;
darwin*) PLATFORM="mac" ;;
err) fail "Unsupported platform." ;;
esac
arch=''
detect_arch arch
if [[ "$arch" == "err" ]]; then fail "Unsupported CPU architecture"; fi
log_info "${BODY}OS:${END} ${WHITE}$os"
log_info "${BODY}PLATFORM:${END} ${WHITE}$PLATFORM"
log_info "${BODY}ARCHITECTURE:${END} ${WHITE}$arch"
########################################
# ROOT / CONFIG
########################################
log_info "${HEADER}OpenClaw - Variables"
#-------------------------------------------
ROOT_DIR="$(pwd)"
OPENCLAW_DIR=""
log_info "${BODY}ROOT_DIR:${END} ${WHITE}$ROOT_DIR"
case "$(basename "$ROOT_DIR")" in
openclaw)
OPENCLAW_DIR="${ROOT_DIR}"
ROOT_DIR="$(cd "$ROOT_DIR/../" && pwd)"
;;
ai-suite)
OPENCLAW_DIR="$(cd "$ROOT_DIR/openclaw" && pwd)"
;;
*) fail "Must be run from 'openclaw' or 'ai-suite'." ;;
esac
log_info "${BODY}OPENCLAW_DIR:${END} ${WHITE}$OPENCLAW_DIR"
# shellcheck disable=SC1091
source "$OPENCLAW_DIR/scripts/lib/docker-build.sh"
COMPOSE_FILE="$OPENCLAW_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$OPENCLAW_DIR/docker-compose.extra.yml"
SANDBOX_COMPOSE_FILE="$OPENCLAW_DIR/docker-compose.sandbox.yml"
#PRIVATE_COMPOSE_FILE="$ROOT_DIR/docker-compose.override.private.yml"
#PUBLIC_COMPOSE_FILE="$ROOT_DIR/docker-compose.override.public.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}"
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
ENV_FILE="$OPENCLAW_DIR/.env"
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
SANDBOX_ENABLED=""
TIMEZONE="${OPENCLAW_TZ:-}"
RAW_SKIP_ONBOARDING="${OPENCLAW_SKIP_ONBOARDING:-}"
SKIP_ONBOARDING=""
PERMISSIONS="${PERMISSIONS:-}"
HOME_DIR="$HOME"
[[ "$PLATFORM" == "wsl" ]] && \
HOME_DIR="$(wslpath "$(cmd.exe /c "<nul set /p=%USERPROFILE%" 2>/dev/null)")"
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME_DIR/.openclaw}"
log_info "${BODY}OPENCLAW_CONFIG_DIR:${END} ${WHITE}$OPENCLAW_CONFIG_DIR"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME_DIR/.openclaw/workspace}"
log_info "${BODY}OPENCLAW_WORKSPACE_DIR:${END} ${WHITE}$OPENCLAW_WORKSPACE_DIR"
OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
OPENCLAW_DOCKER_GID=""
OPENCLAW_INSTALL_DOCKER_CLI=""
########################################
# ARGUMENT PARSING (single primary arg)
########################################
if [[ $# -gt 0 ]]; then
log_info "${HEADER}OpenClaw - Arguments"
#-------------------------------------------
PRIMARY_ARG="$1"
log_info "${BODY}Primary Argument:${END} ${WHITE}$PRIMARY_ARG"
shift || true
fi
case "$PRIMARY_ARG" in
--setenv) ENV_MODE="1" ;;
--onboarding|--configuration|--gateway) : ;;
*) echo -e "${ERROR} ${RED}Unknown primary argument.${END}" >&2 ;;
esac
# Parse remaining flags
ENVIRONMENT="${ENVIRONMENT:-private}"
while [ $# -gt 0 ]; do
case "$1" in
--debug) DEBUG_ON="true" ;;
--sandbox) RAW_SANDBOX_SETTING="1" ;;
# --environment)
# shift
# if [[ $# -eq 0 ]]; then
# echo -e "${WARNING} ${YELLOW}--environment option requires 'public/private' argument.${END}"
# else
# ENVIRONMENT="$1"
# fi
# ;;
*)
echo -e "${NOTICE} Extra argument ignored: ${WHITE}$1${END}" >&2
;;
esac
shift
done
########################################
# UTILITIES
########################################
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
fail "Missing dependency: $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
}
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
}
run_docker_image() {
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
log_info "${BLUE}==>${END} ${GREEN}Building Docker image:${END} ${WHITE}$IMAGE_NAME"
run_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 "$OPENCLAW_DIR/Dockerfile" \
"$OPENCLAW_DIR"
else
log_info "${BLUE}==>${END} ${GREEN}Pulling Docker image:${END} ${WHITE}$IMAGE_NAME"
if ! docker pull "$IMAGE_NAME"; then
fail "Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions."
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.
#
# 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.
fix_permissions() {
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Fixing data-directory permissions"
run_prestart_gateway --user root --entrypoint sh openclaw-gateway -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'
PERMISSIONS=1
}
sync_gateway_config() {
local allowed_origin_json=""
local current_allowed_origins=""
local batch_json=""
if [[ "${OPENCLAW_GATEWAY_BIND}" != "loopback" ]]; then
allowed_origin_json="$(printf '["http://localhost:%s","http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_GATEWAY_PORT")"
current_allowed_origins="$(
run_prestart_cli config get gateway.controlUi.allowedOrigins 2>/dev/null || true
)"
current_allowed_origins="${current_allowed_origins//$'\r'/}"
fi
batch_json="$(printf '[{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"%s"}' "$OPENCLAW_GATEWAY_BIND")"
if [[ -n "$allowed_origin_json" ]]; then
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
log_info "${GREEN}Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
else
batch_json+=",{\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":$allowed_origin_json}"
fi
fi
batch_json+="]"
if ! run_prestart_cli config set --batch-json "$batch_json" >/dev/null; then
: #fail "Could not complete run configuration"
fi
log_info "${GREEN}Pinned gateway.mode=local and gateway.bind=${END}${WHITE}$OPENCLAW_GATEWAY_BIND for Docker setup."
if [[ -n "$allowed_origin_json" ]]; then
if [[ -z "$current_allowed_origins" || "$current_allowed_origins" == "null" || "$current_allowed_origins" == "[]" ]]; then
log_info "${GREEN}Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
fi
fi
}
########################################
# QUERY MODE
########################################
is_env_mode() {
if is_truthy_value "$ENV_MODE"; then
log_info "${HEADER}OpenClaw - Set .env"
#-------------------------------------------
return 0
fi
return 1
}
upsert_env() {
local file="$1"
shift
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
# Use a delimited string instead of an associative array so the script
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
local seen=" "
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
local key="${line%%=*}"
local replaced=false
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
log_info "${BODY}$k:${END} ${WHITE}${!k-}"
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"
log_info "${BODY}$k:${END} ${WHITE}${!k-}"
fi
done
mv "$tmp" "$file"
}
run_set_env() {
export OPENCLAW_CONFIG_DIR
export OPENCLAW_WORKSPACE_DIR
export OPENCLAW_DISABLE_BONJOUR="${OPENCLAW_DISABLE_BONJOUR:-}"
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_DOCKER_SETUP=1
export OPENCLAW_TZ="$TIMEZONE"
export COMPOSE_IGNORE_ORPHANS="${COMPOSE_IGNORE_ORPHANS:-'true'}"
export OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-}"
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}"
export OTEL_EXPORTER_OTLP_PROTOCOL="${OTEL_EXPORTER_OTLP_PROTOCOL:-}"
export OTEL_SERVICE_NAME="${OTEL_SERVICE_NAME:-}"
export OTEL_SEMCONV_STABILITY_OPT_IN="${OTEL_SEMCONV_STABILITY_OPT_IN:-}"
export OPENCLAW_OTEL_PRELOADED="${OPENCLAW_OTEL_PRELOADED:-}"
export OPENCLAW_SKIP_ONBOARDING="$SKIP_ONBOARDING"
upsert_env "$ENV_FILE" \
OPENCLAW_CONFIG_DIR \
OPENCLAW_WORKSPACE_DIR \
OPENCLAW_DISABLE_BONJOUR \
OPENCLAW_GATEWAY_TOKEN \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES \
OPENCLAW_EXTENSIONS \
OPENCLAW_SANDBOX \
OPENCLAW_DOCKER_SOCKET \
OPENCLAW_DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_TZ \
COMPOSE_IGNORE_ORPHANS \
OTEL_EXPORTER_OTLP_ENDPOINT \
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT \
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT \
OTEL_EXPORTER_OTLP_PROTOCOL \
OTEL_SERVICE_NAME \
OTEL_SEMCONV_STABILITY_OPT_IN \
OPENCLAW_OTEL_PRELOADED \
OPENCLAW_SKIP_ONBOARDING
completion='Success!'
}
########################################
# EXEC MODE
########################################
# Dockerfile uses BuildKit-only syntax (RUN --mount=type=cache). Force
# BuildKit so hosts defaulting to the legacy builder do not fail.
run_docker_build() {
docker_build_exec "$@"
}
run_prestart_gateway() {
docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@"
}
# During setup, avoid the shared-network openclaw-cli service because it
# requires the gateway container's network namespace to already exist. That
# creates a circular dependency for config writes that are needed before the
# gateway can start cleanly.
run_prestart_cli() {
run_prestart_gateway --entrypoint node openclaw-gateway \
dist/index.js "$@"
}
run_runtime_cli() {
local compose_scope="${1:-current}"
local deps_mode="${2:-with-deps}"
shift 2
local -a compose_args
local -a run_args=(run --rm)
case "$compose_scope" in
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;;
*) fail "Unknown runtime CLI compose scope: $compose_scope" ;;
esac
case "$deps_mode" in
with-deps) ;;
no-deps) run_args+=(--no-deps) ;;
*) fail "Unknown runtime CLI deps mode: $deps_mode" ;;
esac
docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@"
}
run_onboarding() {
if [[ -n "$SKIP_ONBOARDING" ]]; then
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Skip onboarding (OPENCLAW_SKIP_ONBOARDING is set)"
return
fi
if ! fix_permissions; then
log_error "Could not fix permissions for run onboarding."
fi
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Onboarding (interactive)"
log_info "${GREEN}Docker setup pins Gateway mode to local."
log_info "${GREEN}Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
log_info "${GREEN}Current runtime bind:${END} ${WHITE}$OPENCLAW_GATEWAY_BIND"
if is_truthy_value "$OPENCLAW_DISABLE_BONJOUR"; then
log_info "${GREEN}Bonjour/mDNS advertising:${END} ${WHITE}force disabled (OPENCLAW_DISABLE_BONJOUR=$OPENCLAW_DISABLE_BONJOUR)."
elif [[ -z "$OPENCLAW_DISABLE_BONJOUR" ]]; then
log_info "${GREEN}Bonjour/mDNS advertising:${END} ${WHITE}auto (disabled inside the Gateway container unless explicitly enabled)."
else
log_info "${GREEN}Bonjour/mDNS advertising:${END} ${WHITE}explicitly enabled (OPENCLAW_DISABLE_BONJOUR=$OPENCLAW_DISABLE_BONJOUR)."
fi
log_info "${GREEN}Gateway token:${END} ${WHITE}$OPENCLAW_GATEWAY_TOKEN"
log_info "${GREEN}Tailscale exposure:${END} ${WHITE}Off (use host-level tailnet/Tailscale setup separately)."
log_info "${GREEN}Install Gateway daemon:${END} ${WHITE}No (managed by Docker Compose)"
log_info ""
run_prestart_cli onboard --mode local --no-install-daemon
}
run_configuration() {
[[ -z "$PERMISSIONS" ]] && \
if ! fix_permissions; then
log_error "Could not fix permissions for run configuration."
fi
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Docker gateway defaults"
sync_gateway_config
completion='Success!'
}
run_gateway() {
[[ -z "$PERMISSIONS" ]] && \
if ! fix_permissions; then
log_error "Could not fix permissions for run gateway."
fi
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Provider setup (optional)"
log_info "${GREEN}WhatsApp (QR):"
log_info " ${WHITE}${COMPOSE_HINT} run --rm openclaw-cli channels login"
log_info "${GREEN}Telegram (bot token):"
log_info " ${WHITE}${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token <token>"
log_info "${GREEN}Discord (bot token):"
log_info " ${WHITE}${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
log_info "${GREEN}Docs:${END} ${WHITE}https://docs.openclaw.ai/channels"
log_info ""
log_info "${BLUE}==>${END} ${GREEN}Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
if [[ -n "$SANDBOX_ENABLED" ]]; then
enable_sandbox
fi
log_info ""
log_info "${GREEN}Gateway running with host port mapping."
log_info "${GREEN}Access from tailnet devices via the host's tailnet IP."
log_info "${GREEN}Config:${END} ${WHITE}$OPENCLAW_CONFIG_DIR"
log_info "${GREEN}Workspace:${END} ${WHITE}$OPENCLAW_WORKSPACE_DIR"
log_info "${GREEN}Token:${END} ${WHITE}$OPENCLAW_GATEWAY_TOKEN"
log_info ""
log_info "${GREEN}Commands:"
log_info " ${WHITE}${COMPOSE_HINT} logs -f openclaw-gateway"
log_info " ${WHITE}${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""
completion='Success!'
}
########################################
# EXTRA COMPOSE
########################################
write_extra_compose() {
local home_volume="$1"
shift || true
local mount
local gateway_home_mount=""
local gateway_config_mount=""
local gateway_workspace_mount=""
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
openclaw-gateway:
volumes:
YAML
########################################
# Home + core mounts
########################################
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"
# shellcheck disable=SC2129
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
########################################
# Extra mounts
########################################
for mount in "$@"; do
[[ -z "$mount" ]] && continue
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
########################################
# CLI service (mirror mounts)
########################################
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
openclaw-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
# shellcheck disable=SC2129
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
[[ -z "$mount" ]] && continue
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
########################################
# Named volume declaration (if needed)
########################################
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
validate_named_volume "$home_volume"
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
########################################
# SANDBOX MODULE (ISOLATED + SAFE)
########################################
enable_sandbox() {
log_info ""
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
log_info "${BLUE}==>${END} ${WHITE}Sandbox setup"
# Build sandbox image if Dockerfile.sandbox exists.
if [[ -f "$OPENCLAW_DIR/Dockerfile.sandbox" ]]; then
log_info "${GREEN}Building sandbox image:${END} ${WHITE}openclaw-sandbox:bookworm-slim"
run_docker_build \
-t "openclaw-sandbox:bookworm-slim" \
-f "$OPENCLAW_DIR/Dockerfile.sandbox" \
"$OPENCLAW_DIR"
else
log_warning "${YELLOW}Dockerfile.sandbox not found in $OPENCLAW_DIR" >&2
log_notice " Sandbox config will be applied but no sandbox image will be built." >&2
log_notice " 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
log_warning "${YELLOW}Docker CLI not found inside the container image." >&2
log_notice " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
log_notice " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
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="$OPENCLAW_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- ${DOCKER_SOCKET_PATH}:/var/run/docker.sock
YAML
if [[ -n "${OPENCLAW_DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${OPENCLAW_DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
log_info "${BLUE}==>${END} ${GREEN}Sandbox:${END} ${WHITE}added Docker socket mount"
else
log_warning "${YELLOW}OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
log_notice " 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 ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
log_warning "${YELLOW}Failed to set agents.defaults.sandbox.mode" >&2
sandbox_config_ok=false
fi
if ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.scope "agent" >/dev/null; then
log_warning "${YELLOW}Failed to set agents.defaults.sandbox.scope" >&2
sandbox_config_ok=false
fi
if ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
log_warning "${YELLOW}Failed to set agents.defaults.sandbox.workspaceAccess" >&2
sandbox_config_ok=false
fi
if [[ "$sandbox_config_ok" == true ]]; then
log_info "${GREEN}Sandbox enabled:${END} ${WHITE}mode=non-main, scope=agent, workspaceAccess=none"
log_info "${GREEN}Docs:${END} ${WHITE}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
log_warning "${YELLOW}Sandbox config was partially applied. Check errors above." >&2
log_info " ${YELLOW}Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
if ! run_runtime_cli base no-deps \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
log_warning "${YELLOW}Failed to roll back agents.defaults.sandbox.mode to off" >&2
else
log_info "${MAGENTA}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 ! run_runtime_cli current with-deps \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
log_warning "${YELLOW}Failed to reset agents.defaults.sandbox.mode to off" >&2
fi
if [[ -f "$OPENCLAW_DIR/docker-compose.sandbox.yml" ]]; then
rm -f "$OPENCLAW_DIR/docker-compose.sandbox.yml"
fi
fi
}
########################################
# STATE
########################################
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
fail "Docker Compose not available (try: docker compose version)"
fi
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"
log_info "${BODY}Reusing gateway token from:${END} ${WHITE}$OPENCLAW_CONFIG_DIR/openclaw.json"
else
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$OPENCLAW_DIR/.env" || true)"
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
log_info "${BODY}Reusing gateway token from:${END} ${WHITE}$OPENCLAW_DIR/.env"
elif command -v openssl >/dev/null 2>&1; then
log_info "${BODY}Generating gateway token from:${END} ${WHITE}openssl rand -hex 32"
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
else
log_info "${BODY}Generating gateway token from:${END} ${WHITE}Python secrets.token_hex(32)"
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
)"
fi
fi
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
if is_truthy_value "$RAW_SKIP_ONBOARDING"; then
SKIP_ONBOARDING="1"
fi
# Detect Docker socket GID for sandbox group_add.
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
OPENCLAW_DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
fi
# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
# Docker socket mount is deferred until sandbox prerequisites are verified.
if [[ -n "$SANDBOX_ENABLED" ]]; then
if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
OPENCLAW_INSTALL_DOCKER_CLI=1
fi
fi
if is_env_mode; then
run_set_env
exit 0
else
log_info "${HEADER}OpenClaw - Execution Mode"
#-------------------------------------------
fi
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 [[ -n "$EXTRA_MOUNTS" ]]; then
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
fi
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 early 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"
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=("-p" "ai-suite")
# if [[ "$ENVIRONMENT" == "public" ]]; then
# COMPOSE_FILES+=("$PUBLIC_COMPOSE_FILE")
# else
# COMPOSE_FILES+=("$PRIVATE_COMPOSE_FILE")
# fi
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
else
write_extra_compose "$HOME_VOLUME_NAME"
fi
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
# Keep a base compose arg set without sandbox overlay so rollback paths can
# force a known-safe gateway service definition (no docker.sock mount).
BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
########################################
# DISPATCHER
########################################
run_docker_image
case "$PRIMARY_ARG" in
--onboarding|"")
run_onboarding
run_configuration
;;
--configuration)
run_configuration
;;
--gateway)
run_gateway
;;
esac