Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY packages ./packages
COPY extensions ./extensions
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/prepare-git-hooks.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
&& if ! pnpm install --frozen-lockfile >/tmp/openclaw-cleanup-pnpm-install.log 2>&1; then \
cat /tmp/openclaw-cleanup-pnpm-install.log; \
exit 1; \
fi
COPY . .
COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"]

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
cd /repo
export OPENCLAW_STATE_DIR="/tmp/openclaw-test"
export OPENCLAW_CONFIG_PATH="${OPENCLAW_STATE_DIR}/openclaw.json"
read_positive_int_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [ -z "${!name+x}" ]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 )); then
echo "invalid $name: $value" >&2
return 2
fi
printf '%s\n' "$((10#$value))"
}
print_log_tail() {
local log_file="$1"
local max_bytes
max_bytes="$(read_positive_int_env OPENCLAW_CLEANUP_SMOKE_LOG_PRINT_BYTES 65536)" || return $?
if [ ! -f "$log_file" ]; then
return 0
fi
local log_bytes
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
log_bytes="0"
fi
if [ "$log_bytes" -le "$max_bytes" ]; then
cat "$log_file"
return 0
fi
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
tail -c "$max_bytes" "$log_file"
}
read_positive_int_env OPENCLAW_CLEANUP_SMOKE_LOG_PRINT_BYTES 65536 >/dev/null
ensure_cleanup_smoke_node_options() {
local current="${NODE_OPTIONS:-}"
case " $current " in
*" --max-old-space-size="* | *" --max-old-space-size "* | *" --max_old_space_size="* | *" --max_old_space_size "*)
;;
*)
current="${current:+$current }--max-old-space-size=8192"
;;
esac
export NODE_OPTIONS="$current"
}
ensure_cleanup_smoke_node_options
echo "==> Build"
if ! pnpm build >/tmp/openclaw-cleanup-build.log 2>&1; then
print_log_tail /tmp/openclaw-cleanup-build.log
exit 1
fi
echo "==> Seed state"
mkdir -p "${OPENCLAW_STATE_DIR}/credentials"
mkdir -p "${OPENCLAW_STATE_DIR}/agents/main/sessions"
echo '{}' >"${OPENCLAW_CONFIG_PATH}"
echo 'creds' >"${OPENCLAW_STATE_DIR}/credentials/marker.txt"
echo 'session' >"${OPENCLAW_STATE_DIR}/agents/main/sessions/sessions.json"
echo "==> Reset (config+creds+sessions)"
if ! pnpm openclaw reset --scope config+creds+sessions --yes --non-interactive >/tmp/openclaw-cleanup-reset.log 2>&1; then
print_log_tail /tmp/openclaw-cleanup-reset.log
exit 1
fi
test ! -f "${OPENCLAW_CONFIG_PATH}"
test ! -d "${OPENCLAW_STATE_DIR}/credentials"
test ! -d "${OPENCLAW_STATE_DIR}/agents/main/sessions"
echo "==> Recreate minimal config"
mkdir -p "${OPENCLAW_STATE_DIR}/credentials"
echo '{}' >"${OPENCLAW_CONFIG_PATH}"
echo "==> Uninstall (state only)"
if ! pnpm openclaw uninstall --state --yes --non-interactive >/tmp/openclaw-cleanup-uninstall.log 2>&1; then
print_log_tail /tmp/openclaw-cleanup-uninstall.log
exit 1
fi
test ! -d "${OPENCLAW_STATE_DIR}"
echo "OK"

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./version-parse.sh
source "$SCRIPT_DIR/version-parse.sh"
verify_installed_cli() {
local package_name="$1"
local expected_version="$2"
local cli_name="$package_name"
local cmd_path=""
local entry_path=""
local npm_root=""
local installed_version=""
cmd_path="$(command -v "$cli_name" || true)"
if [[ -z "$cmd_path" && -x "$HOME/.npm-global/bin/$package_name" ]]; then
cmd_path="$HOME/.npm-global/bin/$package_name"
fi
if [[ -z "$cmd_path" ]]; then
npm_root="$(quiet_npm root -g 2>/dev/null || true)"
if [[ -n "$npm_root" && -f "$npm_root/$package_name/dist/entry.js" ]]; then
entry_path="$npm_root/$package_name/dist/entry.js"
fi
fi
if [[ -z "$cmd_path" && -z "$entry_path" ]]; then
echo "ERROR: $package_name is not on PATH" >&2
return 1
fi
if [[ -n "$cmd_path" ]]; then
installed_version="$("$cmd_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
else
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
fi
installed_version="$(extract_openclaw_semver "$installed_version")"
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
if [[ "$installed_version" != "$expected_version" ]]; then
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2
return 1
fi
echo "==> Sanity: CLI runs"
if [[ -n "$cmd_path" ]]; then
"$cmd_path" --help >/dev/null
else
node "$entry_path" --help >/dev/null
fi
}

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
extract_openclaw_semver() {
local raw="${1:-}"
raw="${raw//$'\r'/}"
if [[ "$raw" =~ v?([0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?) ]]; then
printf '%s' "${BASH_REMATCH[1]}"
fi
}
quiet_npm() {
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
"$@"
}

View File

@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-e2e/run.sh /usr/local/bin/openclaw-install-e2e
RUN useradd --create-home --shell /bin/bash appuser
USER appuser
ENTRYPOINT ["/usr/local/bin/openclaw-install-e2e"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b
# Smoke images are pinned and short-lived, so skip distro upgrades here and
# spend the time budget on installer coverage instead.
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
if [ "${attempt}" -eq 3 ]; then exit 1; fi; \
sleep 3; \
done; \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
bash \
ca-certificates \
curl \
g++ \
make \
python3 \
sudo
# Preinstall the supported Node runtime in a cacheable build layer so the
# non-root smoke covers user-local npm prefixing and missing git without paying
# the full NodeSource bootstrap cost on every container run.
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends nodejs
RUN useradd -m -s /bin/bash app \
&& echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app
USER app
WORKDIR /home/app
ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=false
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"]

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
DEFAULT_PACKAGE="openclaw"
PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../install-sh-common/cli-verify.sh
source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh"
echo "==> Pre-flight: ensure git absent"
if command -v git >/dev/null; then
echo "git is present unexpectedly" >&2
exit 1
fi
echo "==> Pre-flight: ensure supported Node is already present"
node -e '
const version = process.versions.node.split(".").map(Number);
const ok =
version.length >= 2 &&
(version[0] > 22 || (version[0] === 22 && version[1] >= 19));
if (!ok) {
process.stderr.write(`unsupported node ${process.versions.node}\n`);
process.exit(1);
}
try {
require("node:sqlite");
} catch {
process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`);
process.exit(1);
}
'
command -v npm >/dev/null
echo "==> Run installer (non-root user)"
curl -fsSL "$INSTALL_URL" | bash
# Ensure PATH picks up user npm prefix
export PATH="$HOME/.npm-global/bin:$PATH"
echo "==> Verify git installed"
command -v git >/dev/null
EXPECTED_VERSION="${OPENCLAW_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
fi
echo "==> Verify CLI installed"
verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION"
echo "OK"

View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
# Smoke images are pinned and short-lived, so skip distro upgrades here and
# spend the time budget on installer coverage instead.
RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
if [ "${attempt}" -eq 3 ]; then exit 1; fi; \
sleep 3; \
done; \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
g++ \
make \
python3 \
sudo
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"]

View File

@@ -0,0 +1,608 @@
#!/usr/bin/env bash
set -euo pipefail
read_positive_int_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [[ -z "${!name+x}" ]]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 )); then
echo "invalid $name: $value" >&2
return 2
fi
printf "%s\n" "$((10#$value))"
}
read_nonnegative_int_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [[ -z "${!name+x}" ]]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+$ ]]; then
echo "invalid $name: $value" >&2
return 2
fi
printf "%s\n" "$((10#$value))"
}
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
SMOKE_MODE="${OPENCLAW_INSTALL_SMOKE_MODE:-install}"
SMOKE_PREVIOUS_VERSION="${OPENCLAW_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
DEFAULT_PACKAGE="openclaw"
PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
FRESH_VERSION="${OPENCLAW_INSTALL_FRESH_VERSION:-}"
FRESH_TAG_URL="${OPENCLAW_INSTALL_FRESH_TAG_URL:-}"
UPDATE_BASELINE_VERSION="${OPENCLAW_INSTALL_UPDATE_BASELINE:-latest}"
UPDATE_BASELINE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL:-}"
UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION:-}"
UPDATE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_TAG_URL:-}"
SELF_UPDATE_WARNING_FIXED_VERSION="${OPENCLAW_INSTALL_SELF_UPDATE_WARNING_FIXED_VERSION:-2026.5.25}"
FRESHNESS_VERSION="${OPENCLAW_INSTALL_FRESHNESS_VERSION:-latest}"
# npm min-release-age is days; 10000 keeps the control failure independent of normal release cadence.
FRESHNESS_MIN_RELEASE_AGE="${OPENCLAW_INSTALL_FRESHNESS_MIN_RELEASE_AGE:-10000}"
FRESHNESS_NPM_VERSION="${OPENCLAW_INSTALL_FRESHNESS_NPM_VERSION:-11.14.1}"
HEARTBEAT_INTERVAL="$(read_nonnegative_int_env OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL 60)"
INSTALL_COMMAND_TIMEOUT="$(read_positive_int_env OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT 900)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../install-sh-common/cli-verify.sh
source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh"
emit_status() {
if [[ -w /dev/tty ]]; then
printf "%s\n" "$*" >/dev/tty
else
printf "%s\n" "$*" >&2
fi
}
global_package_root() {
local npm_root
npm_root="$(quiet_npm root -g 2>/dev/null || true)"
if [[ -n "$npm_root" ]]; then
printf "%s/%s" "$npm_root" "$PACKAGE_NAME"
fi
}
describe_installed_package() {
local root="$1"
local files="missing"
local size="missing"
local version="missing"
if [[ -d "$root" ]]; then
files="$(find "$root" -type f 2>/dev/null | wc -l | tr -d " ")"
size="$(du -sh "$root" 2>/dev/null | cut -f1 || true)"
version="$(
node -e '
try {
process.stdout.write(String(require(`${process.argv[1]}/package.json`).version ?? "missing"));
} catch {
process.stdout.write("missing");
}
' "$root"
)"
fi
printf "version=%s size=%s files=%s root=%s" "$version" "$size" "$files" "$root"
}
print_install_audit() {
local label="$1"
local root
root="$(global_package_root)"
if [[ -n "$root" ]]; then
echo "==> Install audit (${label}): $(describe_installed_package "$root")"
fi
}
run_with_heartbeat() {
local label="$1"
shift
local interval="$HEARTBEAT_INTERVAL"
if [[ "$interval" == "0" ]]; then
"$@"
return
fi
local start
local command_pid
local heartbeat_pid
local status
start="$(date +%s)"
set +e
"$@" &
command_pid=$!
(
while true; do
sleep "$interval"
kill -0 "$command_pid" >/dev/null 2>&1 || exit 0
local now
local elapsed
local root
now="$(date +%s)"
elapsed=$((now - start))
root="$(global_package_root)"
if [[ -n "$root" ]]; then
emit_status "==> Still running (${label}, ${elapsed}s): $(describe_installed_package "$root")"
else
emit_status "==> Still running (${label}, ${elapsed}s)"
fi
done
) &
heartbeat_pid=$!
wait "$command_pid"
status=$?
kill "$heartbeat_pid" >/dev/null 2>&1 || true
wait "$heartbeat_pid" >/dev/null 2>&1 || true
set -e
return "$status"
}
is_self_swapped_package_process_exit() {
local stderr="$1"
[[ "$stderr" == *"[openclaw] Failed to start CLI:"* ]] &&
[[ "$stderr" == *"ERR_MODULE_NOT_FOUND"* ]] &&
[[ "$stderr" == *"/node_modules/openclaw/dist/"* ]]
}
is_version_before() {
local candidate="$1"
local floor="$2"
node - "$candidate" "$floor" <<'NODE'
const [, , candidate, floor] = process.argv;
function parse(version) {
const [core, prerelease = ""] = String(version).split("-", 2);
return {
core: core.split(".").map((part) => Number.parseInt(part, 10) || 0),
prerelease: prerelease ? prerelease.split(".") : [],
};
}
function comparePrerelease(left, right) {
if (left.length === 0 && right.length === 0) {
return 0;
}
if (left.length === 0) {
return 1;
}
if (right.length === 0) {
return -1;
}
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const l = left[index];
const r = right[index];
if (l === undefined) {
return -1;
}
if (r === undefined) {
return 1;
}
const ln = Number.parseInt(l, 10);
const rn = Number.parseInt(r, 10);
const lNumeric = String(ln) === l;
const rNumeric = String(rn) === r;
if (lNumeric && rNumeric && ln !== rn) {
return ln < rn ? -1 : 1;
}
if (lNumeric !== rNumeric) {
return lNumeric ? -1 : 1;
}
if (l !== r) {
return l < r ? -1 : 1;
}
}
return 0;
}
const left = parse(candidate);
const right = parse(floor);
for (let index = 0; index < Math.max(left.core.length, right.core.length); index += 1) {
const l = left.core[index] ?? 0;
const r = right.core[index] ?? 0;
if (l < r) {
process.exit(0);
}
if (l > r) {
process.exit(1);
}
}
const prereleaseOrder = comparePrerelease(left.prerelease, right.prerelease);
process.exit(prereleaseOrder < 0 ? 0 : 1);
NODE
}
allow_legacy_update_warning() {
[[ "${OPENCLAW_INSTALL_ALLOW_LEGACY_UPDATE_WARNING:-0}" == "1" ]] && return 0
is_version_before "$UPDATE_BASELINE_VERSION" "$SELF_UPDATE_WARNING_FIXED_VERSION"
}
npm_install_global() {
local label="$1"
shift
run_with_heartbeat "$label" \
timeout --kill-after=30s "${INSTALL_COMMAND_TIMEOUT}s" \
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
install -g "$@"
}
resolve_update_baseline_version() {
if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then
return
fi
local resolved_version
resolved_version="$(quiet_npm view "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" version 2>/dev/null || true)"
if [[ -z "$resolved_version" ]]; then
echo "ERROR: failed to resolve ${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" >&2
return 1
fi
UPDATE_BASELINE_VERSION="$resolved_version"
}
run_installer_for_package_spec() {
local install_url="$1"
local package_spec="$2"
timeout --kill-after=30s "${INSTALL_COMMAND_TIMEOUT}s" \
bash -c "curl -fsSL \"\$1\" | bash -s -- --install-method npm --version \"\$2\" --no-prompt --no-onboard" \
_ "$install_url" "$package_spec"
}
run_install_smoke() {
if [[ -n "$FRESH_VERSION" && -n "$FRESH_TAG_URL" ]]; then
echo "package=$PACKAGE_NAME latest=$FRESH_VERSION source=$FRESH_TAG_URL"
echo "==> Run official installer one-liner for latest release tarball"
OPENCLAW_NO_ONBOARD=1 OPENCLAW_NO_PROMPT=1 \
run_with_heartbeat "installer latest release tarball" \
run_installer_for_package_spec "$INSTALL_URL" "$FRESH_TAG_URL"
print_install_audit "fresh install"
echo "==> Verify installed version"
if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then
# Non-root installer smoke uses the public install script path, which
# resolves npm "latest" rather than this host-served candidate tarball.
local latest_npm_version
latest_npm_version="$(quiet_npm view "$PACKAGE_NAME" version 2>/dev/null || true)"
if [[ -n "$latest_npm_version" ]]; then
printf "%s" "$latest_npm_version" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
else
printf "%s" "$FRESH_VERSION" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
fi
fi
verify_installed_cli "$PACKAGE_NAME" "$FRESH_VERSION"
echo "OK"
return 0
fi
echo "==> Resolve npm versions"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$LATEST_VERSION"
elif [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" dist-tags.latest)"
VERSIONS_JSON="$(quiet_npm view "$PACKAGE_NAME" versions --json)"
PREVIOUS_VERSION="$(LATEST_VERSION="$LATEST_VERSION" VERSIONS_JSON="$VERSIONS_JSON" node - <<'NODE'
const latest = String(process.env.LATEST_VERSION || "");
const raw = process.env.VERSIONS_JSON || "[]";
let versions;
try {
versions = JSON.parse(raw);
} catch {
versions = raw ? [raw] : [];
}
if (!Array.isArray(versions)) {
versions = [versions];
}
if (versions.length === 0 || latest.length === 0) {
process.exit(1);
}
const latestIndex = versions.lastIndexOf(latest);
if (latestIndex <= 0) {
process.stdout.write(latest);
process.exit(0);
}
process.stdout.write(String(versions[latestIndex - 1] ?? latest));
NODE
)"
fi
echo "package=$PACKAGE_NAME latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path)"
npm_install_global "preinstall previous release" "${PACKAGE_NAME}@${PREVIOUS_VERSION}"
print_install_audit "previous install"
fi
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash -s -- --no-prompt
echo "==> Verify installed version"
if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
fi
verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION"
echo "OK"
}
run_update_smoke() {
if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION is required for update mode" >&2
return 1
fi
if [[ -z "$UPDATE_TAG_URL" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_TAG_URL is required for update mode" >&2
return 1
fi
resolve_update_baseline_version
echo "package=$PACKAGE_NAME baseline=$UPDATE_BASELINE_VERSION target=$UPDATE_EXPECT_VERSION"
echo "==> Install baseline release"
if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then
npm_install_global "install baseline release" --omit=optional "$UPDATE_BASELINE_TAG_URL"
else
npm_install_global "install baseline release" --omit=optional "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}"
fi
print_install_audit "baseline install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION"
echo "==> Run openclaw update from host-served tgz"
local update_status
local update_stderr_file
local update_stderr
local update_env=(
env
npm_config_omit=optional
NPM_CONFIG_OMIT=optional
OPENCLAW_ALLOW_ROOT=1
)
if allow_legacy_update_warning; then
update_env+=(OPENCLAW_UPDATE_IN_PROGRESS=1)
fi
update_stderr_file="$(mktemp)"
set +e
UPDATE_JSON="$(
run_with_heartbeat "openclaw update" \
"${update_env[@]}" \
openclaw update --tag "$UPDATE_TAG_URL" --yes --json 2>"$update_stderr_file"
)"
update_status=$?
set -e
update_stderr="$(cat "$update_stderr_file")"
rm -f "$update_stderr_file"
printf "%s\n" "$UPDATE_JSON"
if [[ -n "$update_stderr" ]]; then
printf "%s\n" "$update_stderr" >&2
fi
if [[ "$update_stderr" == *"config was written by version"* ]] && allow_legacy_update_warning; then
echo "WARN: legacy baseline emitted a self-update version-skew warning; fixed baselines must not" >&2
elif [[ "$update_stderr" == *"config was written by version"* ]]; then
echo "ERROR: openclaw update emitted a self-update version-skew warning" >&2
return 1
fi
if [[ "$update_status" -ne 0 ]]; then
if is_self_swapped_package_process_exit "$update_stderr"; then
echo "WARN: legacy updater process exited after self-swap; validating update JSON and installed CLI" >&2
else
echo "ERROR: openclaw update failed with exit code $update_status" >&2
return "$update_status"
fi
fi
UPDATE_JSON="$UPDATE_JSON" \
UPDATE_EXPECT_VERSION="$UPDATE_EXPECT_VERSION" \
UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \
UPDATE_TAG_URL="$UPDATE_TAG_URL" \
node - <<'NODE'
function parseFirstJsonObject(raw) {
const start = raw.indexOf("{");
if (start < 0) {
throw new Error("missing update JSON object");
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
return JSON.parse(raw.slice(start, index + 1));
}
}
}
throw new Error("unterminated update JSON object");
}
const payload = parseFirstJsonObject(process.env.UPDATE_JSON || "{}");
const expectedVersion = String(process.env.UPDATE_EXPECT_VERSION || "");
const baselineVersion = String(process.env.UPDATE_BASELINE_VERSION || "");
const expectedUrl = String(process.env.UPDATE_TAG_URL || "");
if (payload.status !== "ok") {
throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`);
}
if ((payload.before?.version ?? null) !== baselineVersion) {
throw new Error(
`expected before.version ${baselineVersion}, got ${JSON.stringify(payload.before?.version)}`,
);
}
if ((payload.after?.version ?? null) !== expectedVersion) {
throw new Error(
`expected after.version ${expectedVersion}, got ${JSON.stringify(payload.after?.version)}`,
);
}
if (payload.reason != null) {
throw new Error(`expected no failure reason, got ${JSON.stringify(payload.reason)}`);
}
const steps = Array.isArray(payload.steps) ? payload.steps : [];
const updateStep = steps.find((step) => step?.name === "global update");
if (!updateStep) {
throw new Error("missing global update step in update JSON");
}
if (Number(updateStep.exitCode ?? 1) !== 0) {
throw new Error(`global update step failed: ${JSON.stringify(updateStep)}`);
}
if (typeof updateStep.command !== "string" || !updateStep.command.includes(expectedUrl)) {
throw new Error(`global update step missing expected tgz URL: ${JSON.stringify(updateStep)}`);
}
NODE
echo "==> Verify updated version"
print_install_audit "updated install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "OK"
}
run_npm_global_smoke() {
if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION is required for npm-global mode" >&2
return 1
fi
if [[ -z "$UPDATE_TAG_URL" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_TAG_URL is required for npm-global mode" >&2
return 1
fi
resolve_update_baseline_version
echo "package=$PACKAGE_NAME baseline=$UPDATE_BASELINE_VERSION target=$UPDATE_EXPECT_VERSION"
echo "==> Direct npm global install candidate"
npm_install_global "direct npm global install candidate" "$UPDATE_TAG_URL"
print_install_audit "direct npm fresh install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "==> Direct npm global install baseline"
if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then
npm_install_global "direct npm global install baseline" "$UPDATE_BASELINE_TAG_URL"
else
npm_install_global "direct npm global install baseline" "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}"
fi
print_install_audit "direct npm baseline install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION"
echo "==> Direct npm global update candidate"
npm_install_global "direct npm global update candidate" "$UPDATE_TAG_URL"
print_install_audit "direct npm updated install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "OK"
}
run_freshness_smoke() {
local freshness_spec="${PACKAGE_NAME}@${FRESHNESS_VERSION}"
local expected_version
local current_npm_version
local policy_home
local plain_stdout_file
local plain_stderr_file
local plain_status
policy_home="$(mktemp -d)"
plain_stdout_file="$(mktemp)"
plain_stderr_file="$(mktemp)"
printf "min-release-age=%s\n" "$FRESHNESS_MIN_RELEASE_AGE" >"${policy_home}/.npmrc"
current_npm_version="$(npm --version 2>/dev/null || true)"
if [[ "$current_npm_version" != "$FRESHNESS_NPM_VERSION" ]]; then
echo "==> Install npm with min-release-age support: npm@$FRESHNESS_NPM_VERSION"
npm_install_global "install npm freshness-capable release" "npm@${FRESHNESS_NPM_VERSION}"
fi
expected_version="$(quiet_npm view "$freshness_spec" version 2>/dev/null || true)"
if [[ -z "$expected_version" ]]; then
echo "ERROR: failed to resolve $freshness_spec" >&2
return 1
fi
echo "package=$PACKAGE_NAME version=$FRESHNESS_VERSION resolved=$expected_version npm=$(npm --version) min_release_age=$FRESHNESS_MIN_RELEASE_AGE"
echo "==> Verify user npm freshness policy blocks plain npm install"
set +e
HOME="$policy_home" NPM_CONFIG_USERCONFIG="${policy_home}/.npmrc" \
timeout --kill-after=30s "${INSTALL_COMMAND_TIMEOUT}s" \
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
install -g "$freshness_spec" \
>"$plain_stdout_file" 2>"$plain_stderr_file"
plain_status=$?
set -e
if [[ "$plain_status" -eq 0 ]]; then
echo "ERROR: plain npm install unexpectedly succeeded under min-release-age policy" >&2
return 1
fi
if ! grep -Eiq "No matching version|No versions available|ETARGET|ENOVERSIONS|notarget|min-release-age|minimum release age|before" \
"$plain_stdout_file" "$plain_stderr_file"; then
echo "ERROR: plain npm install failed without expected freshness evidence" >&2
cat "$plain_stdout_file"
cat "$plain_stderr_file" >&2
return 1
fi
echo "==> Run installer with same npm freshness policy"
env \
HOME="$policy_home" \
NPM_CONFIG_USERCONFIG="${policy_home}/.npmrc" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_NO_PROMPT=1 \
bash -c 'curl -fsSL "$1" | bash -s -- --install-method npm --version "$2" --no-prompt --no-onboard' \
_ "$INSTALL_URL" "$FRESHNESS_VERSION"
echo "==> Verify installed version"
print_install_audit "freshness install"
verify_installed_cli "$PACKAGE_NAME" "$expected_version"
echo "OK"
}
case "$SMOKE_MODE" in
install)
run_install_smoke
;;
update)
run_update_smoke
;;
npm-global)
run_npm_global_smoke
;;
freshness)
run_freshness_smoke
;;
*)
echo "ERROR: unsupported OPENCLAW_INSTALL_SMOKE_MODE=$SMOKE_MODE" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,21 @@
FROM debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
CMD ["sleep", "infinity"]

View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
LABEL org.openclaw.sandbox-browser.contract="2026-05-12-cdp-relay-auth"
ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
websockify \
x11vnc \
xvfb
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
EXPOSE 9222 5900 6080
CMD ["openclaw-sandbox-browser"]

View File

@@ -0,0 +1,60 @@
# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
FROM ${BASE_IMAGE}
USER root
ENV DEBIAN_FRONTEND=noninteractive
ARG PACKAGES="curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
ARG INSTALL_NODE=1
ARG NODE_MAJOR=24
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
ARG INSTALL_BREW=1
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
ARG FINAL_USER=sandbox
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES}
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
if [ "${INSTALL_NODE}" = "1" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates curl; \
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -; \
apt-get install -y --no-install-recommends nodejs; \
node --version; \
npm --version; \
fi
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
curl -fsSL https://bun.sh/install | bash; \
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
fi
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
mkdir -p "${BREW_INSTALL_DIR}"; \
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
fi
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
USER ${FINAL_USER}

966
scripts/docker/setup.sh Executable file
View File

@@ -0,0 +1,966 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
source "$ROOT_DIR/scripts/lib/host-timeout.sh"
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:-}"
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
SANDBOX_ENABLED=""
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
TIMEZONE="${OPENCLAW_TZ:-}"
RAW_SKIP_ONBOARDING="${OPENCLAW_SKIP_ONBOARDING:-}"
SKIP_ONBOARDING=""
DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}"
OFFLINE_MODE=""
DEFAULT_SANDBOX_IMAGE="openclaw-sandbox:bookworm-slim"
DEFAULT_SANDBOX_BROWSER_IMAGE="openclaw-sandbox-browser:bookworm-slim"
SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH="2026-05-12-cdp-relay-auth"
fail() {
echo "ERROR: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--offline)
OFFLINE_MODE="1"
;;
*)
fail "Unknown option: $1"
;;
esac
shift
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing dependency: $1" >&2
exit 1
fi
}
run_docker_build() {
# Dockerfile uses BuildKit-only syntax (RUN --mount=type=cache). Force
# BuildKit so hosts defaulting to the legacy builder do not fail.
docker_build_exec "$@"
}
run_docker_pull() {
local image="$1"
openclaw_host_timeout_cmd "$DOCKER_PULL_TIMEOUT" docker pull "$image"
}
require_local_docker_image() {
local image="$1"
if docker image inspect "$image" >/dev/null 2>&1; then
return 0
fi
fail "Offline Docker setup requires preloaded image $image. Load it with 'docker load -i <image.tar>' before running scripts/docker/setup.sh --offline."
}
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
}
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
echo "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+="]"
run_prestart_cli config set --batch-json "$batch_json" >/dev/null
echo "Pinned gateway.mode=local and gateway.bind=$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
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
fi
fi
}
run_compose_one_off() {
local -a run_args=(run)
if [[ -n "$OFFLINE_MODE" ]]; then
run_args+=(--pull never)
fi
docker compose "${COMPOSE_ARGS[@]}" "${run_args[@]}" "$@"
}
run_prestart_gateway() {
run_compose_one_off --rm --no-deps "$@"
}
run_prestart_cli() {
# 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.
# Host OPENCLAW_* paths are Compose bind-mount sources. Setup-time CLI writes
# must still resolve state/config paths inside the container.
run_prestart_gateway \
-e HOME=/home/node \
-e OPENCLAW_HOME=/home/node \
-e OPENCLAW_STATE_DIR=/home/node/.openclaw \
-e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw.json \
-e OPENCLAW_CONFIG_DIR=/home/node/.openclaw \
-e OPENCLAW_WORKSPACE_DIR=/home/node/.openclaw/workspace \
--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)
if [[ -n "$OFFLINE_MODE" ]]; then
run_args+=(--pull never)
fi
run_args+=(--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_gateway_up() {
local compose_scope="${1:-current}"
shift
local -a compose_args
local -a up_args=(up -d)
case "$compose_scope" in
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;;
*) fail "Unknown gateway compose scope: $compose_scope" ;;
esac
if [[ -n "$OFFLINE_MODE" ]]; then
up_args+=(--pull never --no-build)
fi
up_args+=("$@")
docker compose "${compose_args[@]}" "${up_args[@]}" openclaw-gateway
}
resolve_offline_sandbox_images() {
local agents_json sandbox_tools_json
agents_json="$(run_prestart_cli config get agents --json 2>/dev/null || true)"
if [[ -z "$agents_json" ]]; then
agents_json="{}"
fi
sandbox_tools_json="$(
run_prestart_cli config get tools.sandbox.tools --json 2>/dev/null || true
)"
if [[ -z "$sandbox_tools_json" ]]; then
sandbox_tools_json="{}"
fi
printf '%s' "$agents_json" | run_prestart_gateway \
-T --entrypoint node openclaw-gateway -e '
const fs = require("node:fs");
const agents = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
const globalToolPolicy = JSON.parse(process.argv[3] || "{}");
const defaultSandbox = agents?.defaults?.sandbox ?? {};
const defaultDockerImage = defaultSandbox?.docker?.image ?? process.argv[1];
const defaultBrowserImage = defaultSandbox?.browser?.image ?? process.argv[2];
const images = new Set();
const configuredEntries = Array.isArray(agents?.list)
? agents.list.filter((entry) => entry !== null && typeof entry === "object")
: [];
const entries = configuredEntries.length > 0 ? configuredEntries : [{ sandbox: {} }];
const matchesBrowser = (rawPattern) => {
const pattern = String(rawPattern ?? "").trim().toLowerCase();
if (pattern === "group:openclaw" || pattern === "group:ui") {
return true;
}
if (!pattern) {
return false;
}
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped.replaceAll("*", ".*")}$`).test("browser");
};
const permitsBrowser = (entry) => {
const agentPolicy = entry?.tools?.sandbox?.tools ?? {};
const allow = Array.isArray(agentPolicy.allow)
? agentPolicy.allow
: Array.isArray(globalToolPolicy?.allow)
? globalToolPolicy.allow
: undefined;
const alsoAllow = Array.isArray(agentPolicy.alsoAllow)
? agentPolicy.alsoAllow
: Array.isArray(globalToolPolicy?.alsoAllow)
? globalToolPolicy.alsoAllow
: undefined;
const deny = Array.isArray(agentPolicy.deny)
? agentPolicy.deny
: Array.isArray(globalToolPolicy?.deny)
? globalToolPolicy.deny
: undefined;
// Browser is absent from the default allowlist and present in the default
// denylist. Explicit allow patterns re-enable it unless an explicit deny wins.
const explicitAllows = [...(allow ?? []), ...(alsoAllow ?? [])];
const allowedByAllowlist = Array.isArray(allow)
? allow.length === 0 || explicitAllows.some(matchesBrowser)
: (alsoAllow ?? []).some(matchesBrowser);
const denied = Array.isArray(deny)
? deny.some(matchesBrowser)
: !explicitAllows.some(matchesBrowser);
return allowedByAllowlist && !denied;
};
for (const entry of entries) {
const sandbox = entry?.sandbox ?? {};
const mode = sandbox.mode ?? "non-main";
const backend = (
sandbox.backend?.trim() ||
defaultSandbox.backend?.trim() ||
"docker"
).toLowerCase();
if (mode === "off" || backend !== "docker") {
continue;
}
// Setup writes defaults scope=agent. Explicit per-agent scope still wins,
// and shared scope intentionally ignores per-agent Docker/browser overrides.
const scope = sandbox.scope ?? "agent";
const agentDocker = scope === "shared" ? undefined : sandbox.docker;
images.add(`sandbox\t${agentDocker?.image ?? defaultDockerImage}`);
const agentBrowser = scope === "shared" ? undefined : sandbox.browser;
const browserEnabled = agentBrowser?.enabled ?? defaultSandbox?.browser?.enabled ?? false;
if (browserEnabled && permitsBrowser(entry)) {
images.add(`browser\t${agentBrowser?.image ?? defaultBrowserImage}`);
}
}
process.stdout.write([...images].join("\n"));
' "$DEFAULT_SANDBOX_IMAGE" "$DEFAULT_SANDBOX_BROWSER_IMAGE" "$sandbox_tools_json"
}
validate_offline_sandbox_prerequisites() {
if [[ ! -S "$DOCKER_SOCKET_PATH" ]]; then
fail "Offline sandbox setup requires a Docker socket at $DOCKER_SOCKET_PATH."
fi
local sandbox_images
sandbox_images="$(resolve_offline_sandbox_images)"
local -a sandbox_image_errors=()
local image_kind sandbox_image browser_contract
while IFS=$'\t' read -r image_kind sandbox_image; do
[[ -n "$image_kind" ]] || continue
case "$image_kind" in
sandbox)
if ! docker --host "unix://$DOCKER_SOCKET_PATH" image inspect "$sandbox_image" >/dev/null 2>&1; then
sandbox_image_errors+=("$sandbox_image (missing)")
fi
;;
browser)
if ! browser_contract="$(
docker --host "unix://$DOCKER_SOCKET_PATH" image inspect \
-f '{{ index .Config.Labels "org.openclaw.sandbox-browser.contract" }}' \
"$sandbox_image" 2>/dev/null
)"; then
sandbox_image_errors+=("$sandbox_image (missing)")
elif [[ "$browser_contract" != "$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH" ]]; then
sandbox_image_errors+=(
"$sandbox_image (browser contract=${browser_contract:-missing}, expected=$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH)"
)
fi
;;
*)
fail "Unknown offline sandbox image kind: $image_kind"
;;
esac
done <<<"$sandbox_images"
if [[ ${#sandbox_image_errors[@]} -gt 0 ]]; then
echo "WARNING: offline Docker setup cannot use required sandbox images:" >&2
local sandbox_image_error
for sandbox_image_error in "${sandbox_image_errors[@]}"; do
echo " - $sandbox_image_error" >&2
done
echo " Load them with 'docker load -i <sandbox-image.tar>' before enabling sandboxed agents." >&2
fail "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed."
fi
echo "Using preloaded sandbox images:"
while IFS=$'\t' read -r _ sandbox_image; do
if [[ -n "$sandbox_image" ]]; then
echo " - $sandbox_image"
fi
done <<<"$sandbox_images"
if ! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
fail "Offline sandbox setup requires Docker CLI in $IMAGE_NAME."
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
}
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" =~ ^[^,:]+:[^,:]+(:[^,:]+)?$ ]]; then
fail "Invalid mount format '$mount'. Expected source:target[:options] without commas or control characters."
fi
}
quote_yaml_string() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '"%s"' "$value"
}
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
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
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
OPENCLAW_AUTH_PROFILE_SECRET_DIR="${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-$HOME/.openclaw-auth-profile-secrets}"
validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR"
validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR"
validate_mount_path_value "OPENCLAW_AUTH_PROFILE_SECRET_DIR" "$OPENCLAW_AUTH_PROFILE_SECRET_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
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"
mkdir -p "$OPENCLAW_AUTH_PROFILE_SECRET_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_AUTH_PROFILE_SECRET_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_DISABLE_BONJOUR="${OPENCLAW_DISABLE_BONJOUR:-}"
export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_IMAGE_APT_PACKAGES="${OPENCLAW_IMAGE_APT_PACKAGES-${OPENCLAW_DOCKER_APT_PACKAGES:-}}"
export OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}"
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
export OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="${OPENCLAW_DOCKER_BUILD_NODE_OPTIONS---max-old-space-size=8192}"
export OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="${OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB:-}"
export OPENCLAW_DOCKER_BUILD_SKIP_DTS="${OPENCLAW_DOCKER_BUILD_SKIP_DTS:-1}"
export OPENCLAW_INSTALL_BROWSER="${OPENCLAW_INSTALL_BROWSER:-}"
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 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"
# Detect Docker socket GID for sandbox group_add.
DOCKER_GID=""
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
fi
export DOCKER_GID
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"
else
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)"
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
echo "Reusing gateway token from $ROOT_DIR/.env"
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
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
local gateway_auth_profile_secret_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"
gateway_auth_profile_secret_mount="${OPENCLAW_AUTH_PROFILE_SECRET_DIR}:/home/node/.config/openclaw"
validate_mount_spec "$gateway_home_mount"
validate_mount_spec "$gateway_config_mount"
validate_mount_spec "$gateway_workspace_mount"
validate_mount_spec "$gateway_auth_profile_secret_mount"
printf ' - %s\n' "$(quote_yaml_string "$gateway_home_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_config_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_workspace_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_auth_profile_secret_mount")" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$(quote_yaml_string "$mount")" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
openclaw-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s\n' "$(quote_yaml_string "$gateway_home_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_config_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_workspace_mount")" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$(quote_yaml_string "$gateway_auth_profile_secret_mount")" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$(quote_yaml_string "$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
}
# 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
export OPENCLAW_INSTALL_DOCKER_CLI=1
fi
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
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_AUTH_PROFILE_SECRET_DIR \
OPENCLAW_GATEWAY_PORT \
OPENCLAW_BRIDGE_PORT \
OPENCLAW_GATEWAY_BIND \
OPENCLAW_DISABLE_BONJOUR \
OPENCLAW_GATEWAY_TOKEN \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_IMAGE_APT_PACKAGES \
OPENCLAW_IMAGE_PIP_PACKAGES \
OPENCLAW_EXTENSIONS \
OPENCLAW_DOCKER_BUILD_NODE_OPTIONS \
OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB \
OPENCLAW_DOCKER_BUILD_SKIP_DTS \
OPENCLAW_INSTALL_BROWSER \
OPENCLAW_SANDBOX \
OPENCLAW_DOCKER_SOCKET \
DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_TZ \
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
if [[ -n "$OFFLINE_MODE" ]]; then
require_local_docker_image "$IMAGE_NAME"
echo "==> Using preloaded Docker image: $IMAGE_NAME"
elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
run_docker_build \
--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \
--build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}" \
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
--build-arg "OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=${OPENCLAW_DOCKER_BUILD_NODE_OPTIONS}" \
--build-arg "OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=${OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB}" \
--build-arg "OPENCLAW_DOCKER_BUILD_SKIP_DTS=${OPENCLAW_DOCKER_BUILD_SKIP_DTS}" \
--build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}" \
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
else
echo "==> Pulling Docker image: $IMAGE_NAME"
if ! run_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.
run_prestart_gateway --user root --entrypoint sh openclaw-gateway -c \
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
chown node:node /home/node/.config; \
find /home/node/.config/openclaw -xdev -exec chown node:node {} +; \
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
echo ""
if [[ -n "$SKIP_ONBOARDING" ]]; then
echo "==> Skipping onboarding (OPENCLAW_SKIP_ONBOARDING is set)"
else
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"
if is_truthy_value "$OPENCLAW_DISABLE_BONJOUR"; then
echo "Bonjour/mDNS advertising: force disabled (OPENCLAW_DISABLE_BONJOUR=$OPENCLAW_DISABLE_BONJOUR)."
elif [[ -z "$OPENCLAW_DISABLE_BONJOUR" ]]; then
echo "Bonjour/mDNS advertising: auto (disabled inside the Gateway container unless explicitly enabled)."
else
echo "Bonjour/mDNS advertising: explicitly enabled (OPENCLAW_DISABLE_BONJOUR=$OPENCLAW_DISABLE_BONJOUR)."
fi
echo "Gateway token: stored in Docker environment/config (not printed)."
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
echo "Install Gateway daemon: No (managed by Docker Compose)"
echo ""
run_prestart_cli onboard \
--mode local \
--no-install-daemon \
--gateway-auth token \
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
--skip-ui \
--suppress-gateway-token-output
fi
echo ""
echo "==> Docker gateway defaults"
sync_gateway_config
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"
if [[ -n "$SANDBOX_ENABLED" && -n "$OFFLINE_MODE" ]]; then
echo ""
echo "==> Sandbox preflight"
validate_offline_sandbox_prerequisites
fi
echo ""
echo "==> Starting gateway"
run_gateway_up current
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
if [[ -n "$SANDBOX_ENABLED" ]]; then
echo ""
echo "==> Sandbox setup"
sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile"
if [[ -z "$OFFLINE_MODE" && ! -S "$DOCKER_SOCKET_PATH" ]]; then
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" && -f "$sandbox_dockerfile" ]]; then
echo "Building sandbox image: $DEFAULT_SANDBOX_IMAGE"
run_docker_build \
-t "$DEFAULT_SANDBOX_IMAGE" \
-f "$sandbox_dockerfile" \
"$ROOT_DIR"
elif [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]]; then
echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2
echo " Sandbox config will be applied but no sandbox image will be built." >&2
echo " 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 [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]] &&
! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
echo "WARNING: Docker CLI not found inside the container image." >&2
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
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.
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- $(quote_yaml_string "${DOCKER_SOCKET_PATH}:/var/run/docker.sock")
YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> Sandbox: added Docker socket mount"
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
echo "WARNING: 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
echo "WARNING: 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
echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
sandbox_config_ok=false
fi
if [[ "$sandbox_config_ok" == true ]]; then
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
run_gateway_up current
else
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
echo " 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
echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
else
echo "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.
run_gateway_up base --force-recreate
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
echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
fi
if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
fi
fi
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: stored in Docker environment/config (not printed)."
echo ""
echo "Commands:"
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
echo " ${COMPOSE_HINT} exec openclaw-gateway sh -lc 'node dist/index.js health --token \"\$OPENCLAW_GATEWAY_TOKEN\"'"