mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
* fix(docker): ensure agent directory permissions in docker-setup.sh * fix(docker): restrict chown to config-dir mount, not workspace The previous 'chown -R node:node /home/node/.openclaw' call crossed into the workspace bind mount on Linux hosts, recursively rewriting ownership of all user project files in the workspace directory. Fix: use 'find -xdev' to restrict chown to the config-dir filesystem only (won't cross bind-mount boundaries). Then separately chown only the OpenClaw metadata subdirectory (.openclaw/) within the workspace, leaving the user's project files untouched. Addresses review comment on PR #28841.
410 lines
13 KiB
Bash
Executable File
410 lines
13 KiB
Bash
Executable File
#!/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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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'* ]]
|
|
}
|
|
|
|
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"
|
|
# 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_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
|
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
|
|
|
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"
|
|
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
|
|
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" <<YAML
|
|
volumes:
|
|
${home_volume}:
|
|
YAML
|
|
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
|
|
COMPOSE_HINT="docker compose"
|
|
for compose_file in "${COMPOSE_FILES[@]}"; do
|
|
COMPOSE_HINT+=" -f ${compose_file}"
|
|
done
|
|
|
|
ENV_FILE="$ROOT_DIR/.env"
|
|
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"
|
|
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
|
|
|
|
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
|
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"
|
|
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 <token>"
|
|
echo "Discord (bot token):"
|
|
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <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\""
|