重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
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 \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \
&& 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/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
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,44 @@
#!/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"
echo "==> Build"
if ! pnpm build >/tmp/openclaw-cleanup-build.log 2>&1; then
cat /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
cat /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
cat /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,25 @@
#!/usr/bin/env bash
extract_openclaw_semver() {
local raw="${1:-}"
local parsed=""
parsed="$(
printf '%s\n' "$raw" \
| tr -d '\r' \
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
| head -n 1 \
|| true
)"
printf '%s' "${parsed#v}"
}
quiet_npm() {
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
"$@"
}

View file

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
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 \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \
&& 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"]

View file

@ -0,0 +1,607 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh"
if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then
VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh"
fi
# shellcheck source=../install-sh-common/version-parse.sh
source "$VERIFY_HELPER_PATH"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
MODELS_MODE="${OPENCLAW_E2E_MODELS:-both}" # both|openai|anthropic
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-latest}"
E2E_PREVIOUS_VERSION="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}"
SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
# This image runs as a non-root user, so seed a user-local npm prefix before we
# preinstall an older global version to exercise the upgrade path.
export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}"
mkdir -p "$NPM_CONFIG_PREFIX"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS must be one of: both|openai|anthropic" >&2
exit 2
fi
if [[ "$MODELS_MODE" == "both" ]]; then
if [[ -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=both requires OPENAI_API_KEY." >&2
exit 2
fi
if [[ -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=both requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2
fi
elif [[ "$MODELS_MODE" == "openai" && -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=openai requires OPENAI_API_KEY." >&2
exit 2
elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=anthropic requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2
fi
echo "==> Resolve npm versions"
EXPECTED_VERSION="$(quiet_npm view "openclaw@${INSTALL_TAG}" version)"
if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then
echo "ERROR: unable to resolve openclaw@${INSTALL_TAG} version" >&2
exit 2
fi
if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(VERSIONS_JSON="$(quiet_npm view openclaw versions --json)" node - <<'NODE'
const versions = JSON.parse(process.env.VERSIONS_JSON || "[]");
if (!Array.isArray(versions) || versions.length === 0) process.exit(1);
process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]);
NODE
)"
fi
echo "expected=$EXPECTED_VERSION previous=$PREVIOUS_VERSION"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)"
quiet_npm install -g "openclaw@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"
if [[ "$INSTALL_TAG" == "beta" ]]; then
OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash
elif [[ "$INSTALL_TAG" != "latest" ]]; then
OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash
else
curl -fsSL "$INSTALL_URL" | bash
fi
echo "==> Verify installed version"
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")"
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2
exit 1
fi
set_image_model() {
local profile="$1"
shift
local candidate
for candidate in "$@"; do
if openclaw --profile "$profile" models set-image "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set an image model (tried: $*)" >&2
return 1
}
set_agent_model() {
local profile="$1"
local candidate
shift
for candidate in "$@"; do
if openclaw --profile "$profile" models set "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set agent model (tried: $*)" >&2
return 1
}
write_png_lr_rg() {
local out="$1"
node - <<'NODE' "$out"
const fs = require("node:fs");
const zlib = require("node:zlib");
const out = process.argv[2];
const width = 96;
const height = 64;
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const rows = [];
for (let y = 0; y < height; y++) {
const row = Buffer.alloc(1 + width * 3);
row[0] = 0; // filter: none
for (let x = 0; x < width; x++) {
const i = 1 + x * 3;
const left = x < width / 2;
row[i + 0] = left ? 255 : 0;
row[i + 1] = left ? 0 : 255;
row[i + 2] = 0;
}
rows.push(row);
}
const raw = Buffer.concat(rows);
const idat = zlib.deflateSync(raw, { level: 9 });
const png = Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", Buffer.alloc(0)),
]);
fs.writeFileSync(out, png);
NODE
}
run_agent_turn() {
local profile="$1"
local session_id="$2"
local prompt="$3"
local out_json="$4"
# Installer E2E validates install + onboard + embedded agent tooling. It does
# not need a paired Gateway control-plane hop, which is flaky/non-deterministic
# in the isolated container and already covered by gateway-specific lanes.
openclaw --profile "$profile" agent \
--local \
--session-id "$session_id" \
--message "$prompt" \
--thinking off \
--json >"$out_json" 2>&1
node - <<'NODE' "$out_json"
const fs = require("node:fs");
const path = process.argv[2];
const raw = fs.readFileSync(path, "utf8");
function extractTrailingJsonObject(input) {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("agent output was empty");
}
try {
return JSON.parse(trimmed);
} catch {
// Some local runs emit stderr diagnostics before the final JSON payload.
// Walk backward and keep the last parseable top-level object.
for (let index = trimmed.lastIndexOf("{"); index >= 0; index = trimmed.lastIndexOf("{", index - 1)) {
const candidate = trimmed.slice(index);
try {
return JSON.parse(candidate);
} catch {
// keep scanning
}
}
throw new Error(`could not extract JSON payload from agent output:\n${trimmed}`);
}
}
const parsed = extractTrailingJsonObject(raw);
fs.writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
NODE
}
assert_agent_json_has_text() {
local path="$1"
node - <<'NODE' "$path"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
if (texts.length === 0) process.exit(1);
NODE
}
assert_agent_json_ok() {
local json_path="$1"
local expect_provider="$2"
node - <<'NODE' "$json_path" "$expect_provider"
const fs = require("node:fs");
const jsonPath = process.argv[2];
const expectProvider = process.argv[3];
const p = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
if (typeof p?.status === "string" && p.status !== "ok" && p.status !== "accepted") {
console.error(`ERROR: gateway status=${p.status}`);
process.exit(1);
}
const result = p?.result ?? p;
const payloads = Array.isArray(result?.payloads) ? result.payloads : [];
const anyError = payloads.some((pl) => pl && pl.isError === true);
const combinedText = payloads.map((pl) => String(pl?.text ?? "")).filter(Boolean).join("\n").trim();
if (anyError) {
console.error(`ERROR: agent returned error payload: ${combinedText}`);
process.exit(1);
}
if (/rate_limit_error/i.test(combinedText) || /^429\\b/.test(combinedText)) {
console.error(`ERROR: agent rate limited: ${combinedText}`);
process.exit(1);
}
const meta = result?.meta;
const provider =
(typeof meta?.agentMeta?.provider === "string" && meta.agentMeta.provider.trim()) ||
(typeof meta?.provider === "string" && meta.provider.trim()) ||
"";
if (expectProvider && provider && provider !== expectProvider) {
console.error(`ERROR: expected provider=${expectProvider}, got provider=${provider}`);
process.exit(1);
}
NODE
}
extract_matching_text() {
local path="$1"
local expected="$2"
node - <<'NODE' "$path" "$expected"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const expected = String(process.argv[3] ?? "");
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
const match = texts.find((text) => text === expected);
process.stdout.write(match ?? texts[0] ?? "");
NODE
}
assert_session_used_tools() {
local jsonl="$1"
shift
node - <<'NODE' "$jsonl" "$@"
const fs = require("node:fs");
const jsonl = process.argv[2];
const required = new Set(process.argv.slice(3));
const raw = fs.readFileSync(jsonl, "utf8");
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
const seen = new Set();
const toolTypes = new Set([
"tool_use",
"tool_result",
"tool",
"tool-call",
"tool_call",
"tooluse",
"tool-use",
"toolresult",
"tool-result",
]);
function walk(node, parent) {
if (!node) return;
if (Array.isArray(node)) {
for (const item of node) walk(item, node);
return;
}
if (typeof node !== "object") return;
const obj = node;
const t = typeof obj.type === "string" ? obj.type : null;
if (t && (toolTypes.has(t) || /tool/i.test(t))) {
const name =
typeof obj.name === "string" ? obj.name :
typeof obj.toolName === "string" ? obj.toolName :
typeof obj.tool_name === "string" ? obj.tool_name :
(obj.tool && typeof obj.tool.name === "string") ? obj.tool.name :
null;
if (name) seen.add(name);
}
if (typeof obj.name === "string" && typeof obj.input === "object" && obj.input) {
// Many tool-use blocks look like { type: "...", name: "exec", input: {...} }
// but some transcripts omit/rename type.
seen.add(obj.name);
}
// OpenAI-ish tool call shapes.
if (Array.isArray(obj.tool_calls)) {
for (const c of obj.tool_calls) {
const fn = c?.function;
if (fn && typeof fn.name === "string") seen.add(fn.name);
}
}
if (obj.function && typeof obj.function.name === "string") seen.add(obj.function.name);
for (const v of Object.values(obj)) walk(v, obj);
}
for (const line of lines) {
try {
const entry = JSON.parse(line);
walk(entry, null);
} catch {
// ignore unparsable lines
}
}
const missing = [...required].filter((t) => !seen.has(t));
if (missing.length > 0) {
console.error(`Missing tools in transcript: ${missing.join(", ")}`);
console.error(`Seen tools: ${[...seen].sort().join(", ")}`);
console.error("Transcript head:");
console.error(lines.slice(0, 5).join("\n"));
process.exit(1);
}
NODE
}
run_profile() {
local profile="$1"
local port="$2"
local workspace="$3"
local agent_model_provider="$4" # "openai"|"anthropic"
echo "==> Onboard ($profile)"
if [[ "$agent_model_provider" == "openai" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice openai-api-key \
--openai-api-key "$OPENAI_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_KEY" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice token \
--token-provider anthropic \
--token "$ANTHROPIC_API_TOKEN" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
else
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
fi
echo "==> Verify workspace identity files ($profile)"
test -f "$workspace/AGENTS.md"
test -f "$workspace/IDENTITY.md"
test -f "$workspace/USER.md"
test -f "$workspace/SOUL.md"
test -f "$workspace/TOOLS.md"
echo "==> Configure models ($profile)"
local agent_model
local image_model
if [[ "$agent_model_provider" == "openai" ]]; then
agent_model="$(set_agent_model "$profile" \
"openai/gpt-5.4" \
"openai/gpt-4o-mini" \
"openai/gpt-4o")"
image_model="$(set_image_model "$profile" \
"openai/gpt-4o-mini" \
"openai/gpt-4o")"
else
agent_model="$(set_agent_model "$profile" \
"anthropic/claude-opus-4-6" \
"claude-opus-4-6")"
image_model="$(set_image_model "$profile" \
"anthropic/claude-opus-4-6" \
"claude-opus-4-6")"
fi
echo "model=$agent_model"
echo "imageModel=$image_model"
echo "==> Prepare tool fixtures ($profile)"
PROOF_TXT="$workspace/proof.txt"
PROOF_COPY="$workspace/copy.txt"
HOSTNAME_TXT="$workspace/hostname.txt"
IMAGE_PNG="$workspace/proof.png"
IMAGE_TXT="$workspace/image.txt"
SESSION_ID="e2e-tools-${profile}"
SESSION_JSONL="$HOME/.openclaw-${profile}/agents/main/sessions/${SESSION_ID}.jsonl"
PROOF_VALUE="$(node -e 'console.log(require("node:crypto").randomBytes(16).toString("hex"))')"
echo -n "$PROOF_VALUE" >"$PROOF_TXT"
write_png_lr_rg "$IMAGE_PNG"
EXPECTED_HOSTNAME="$(hostname | tr -d '\r\n')"
echo "==> Start gateway ($profile)"
GATEWAY_LOG="$workspace/gateway.log"
openclaw --profile "$profile" gateway --port "$port" --bind loopback >"$GATEWAY_LOG" 2>&1 &
GATEWAY_PID="$!"
cleanup_profile() {
if kill -0 "$GATEWAY_PID" 2>/dev/null; then
kill "$GATEWAY_PID" 2>/dev/null || true
wait "$GATEWAY_PID" 2>/dev/null || true
fi
}
trap cleanup_profile EXIT
echo "==> Wait for health ($profile)"
for _ in $(seq 1 240); do
if openclaw --profile "$profile" health --timeout 5000 --json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
openclaw --profile "$profile" health --timeout 60000 --json >/dev/null
echo "==> Agent turns ($profile)"
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN2B_JSON="/tmp/agent-${profile}-2b.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
TURN3B_JSON="/tmp/agent-${profile}-3b.json"
TURN4_JSON="/tmp/agent-${profile}-4.json"
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not exec) to read ${PROOF_TXT}. Reply with the exact contents only (no extra whitespace)." \
"$TURN1_JSON"
assert_agent_json_has_text "$TURN1_JSON"
assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider"
local reply1
reply1="$(extract_matching_text "$TURN1_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
if [[ "$reply1" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2
exit 1
fi
local prompt2
prompt2=$'Use the write tool (not exec) to write exactly this string into '"${PROOF_COPY}"$':\n'"${reply1}"$'\nReply with exactly: WROTE'
run_agent_turn "$profile" "$SESSION_ID" "$prompt2" "$TURN2_JSON"
assert_agent_json_has_text "$TURN2_JSON"
assert_agent_json_ok "$TURN2_JSON" "$agent_model_provider"
local copy_value
copy_value="$(cat "$PROOF_COPY" 2>/dev/null | tr -d '\r\n' || true)"
if [[ "$copy_value" != "$PROOF_VALUE" ]]; then
echo "ERROR: copy.txt did not match proof.txt ($profile)" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not exec) to read ${PROOF_COPY}. Reply with the exact contents only (no extra whitespace)." \
"$TURN2B_JSON"
assert_agent_json_has_text "$TURN2B_JSON"
assert_agent_json_ok "$TURN2B_JSON" "$agent_model_provider"
local reply2
reply2="$(extract_matching_text "$TURN2B_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
if [[ "$reply2" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read copy.txt correctly ($profile): $reply2" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the exec tool to run this command: hostname. Reply with the exact stdout only (trim trailing newline)." \
"$TURN3_JSON"
assert_agent_json_has_text "$TURN3_JSON"
assert_agent_json_ok "$TURN3_JSON" "$agent_model_provider"
local reply3
reply3="$(extract_matching_text "$TURN3_JSON" "$EXPECTED_HOSTNAME" | tr -d '\r\n')"
if [[ "$reply3" != "$EXPECTED_HOSTNAME" ]]; then
echo "ERROR: agent did not run hostname correctly ($profile): $reply3" >&2
exit 1
fi
local prompt3b
prompt3b=$'Use the write tool to write exactly this string into '"${HOSTNAME_TXT}"$':\n'"${reply3}"$'\nReply with exactly: WROTE'
run_agent_turn "$profile" "$SESSION_ID" "$prompt3b" "$TURN3B_JSON"
assert_agent_json_has_text "$TURN3B_JSON"
assert_agent_json_ok "$TURN3B_JSON" "$agent_model_provider"
if [[ "$(cat "$HOSTNAME_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "$EXPECTED_HOSTNAME" ]]; then
echo "ERROR: hostname.txt did not match hostname output ($profile)" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the image tool on ${IMAGE_PNG}. Determine which color is on the left half and which is on the right half. Then use the write tool to write exactly: LEFT=RED RIGHT=GREEN into ${IMAGE_TXT}. Reply with exactly: LEFT=RED RIGHT=GREEN" \
"$TURN4_JSON"
assert_agent_json_has_text "$TURN4_JSON"
assert_agent_json_ok "$TURN4_JSON" "$agent_model_provider"
if [[ "$(cat "$IMAGE_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: image.txt did not contain expected marker ($profile)" >&2
exit 1
fi
local reply4
reply4="$(extract_matching_text "$TURN4_JSON" "LEFT=RED RIGHT=GREEN")"
if [[ "$reply4" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: agent reply did not contain expected marker ($profile): $reply4" >&2
exit 1
fi
echo "==> Verify tool usage via session transcript ($profile)"
# Give the gateway a moment to flush transcripts.
sleep 1
if [[ ! -f "$SESSION_JSONL" ]]; then
echo "ERROR: missing session transcript ($profile): $SESSION_JSONL" >&2
ls -la "$HOME/.openclaw-${profile}/agents/main/sessions" >&2 || true
exit 1
fi
assert_session_used_tools "$SESSION_JSONL" read write exec image
cleanup_profile
trap - EXIT
}
if [[ "$MODELS_MODE" == "openai" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-openai" "18789" "/tmp/openclaw-e2e-openai" "openai"
fi
if [[ "$MODELS_MODE" == "anthropic" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-anthropic" "18799" "/tmp/openclaw-e2e-anthropic" "anthropic"
fi
echo "OK"

View file

@ -0,0 +1,47 @@
# 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
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,49 @@
#!/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] >= 16));
if (!ok) {
process.stderr.write(`unsupported node ${process.versions.node}\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:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
# 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,345 @@
#!/usr/bin/env bash
set -euo pipefail
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:-2026.4.10}"
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:-}"
HEARTBEAT_INTERVAL="${OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL:-60}"
INSTALL_COMMAND_TIMEOUT="${OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT:-300}"
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-9]+$ ]] || [[ "$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"
}
npm_install_global() {
local label="$1"
shift
run_with_heartbeat "$label" \
timeout --foreground "${INSTALL_COMMAND_TIMEOUT}s" \
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
install -g "$@"
}
run_install_smoke() {
if [[ -n "$FRESH_VERSION" && -n "$FRESH_TAG_URL" ]]; then
echo "package=$PACKAGE_NAME latest=$FRESH_VERSION source=$FRESH_TAG_URL"
echo "==> Install latest release tarball"
npm_install_global "install latest release tarball" --omit=optional "$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
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
update_stderr_file="$(mktemp)"
set +e
UPDATE_JSON="$(
run_with_heartbeat "openclaw update" \
env npm_config_omit=optional NPM_CONFIG_OMIT=optional \
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_status" -ne 0 ]]; then
echo "ERROR: openclaw update failed with exit code $update_status" >&2
return "$update_status"
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'
const payload = JSON.parse(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
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"
}
case "$SMOKE_MODE" in
install)
run_install_smoke
;;
update)
run_update_smoke
;;
npm-global)
run_npm_global_smoke
;;
*)
echo "ERROR: unsupported OPENCLAW_INSTALL_SMOKE_MODE=$SMOKE_MODE" >&2
exit 1
;;
esac

View file

@ -0,0 +1,654 @@
#!/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:-}"
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
SANDBOX_ENABLED=""
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
TIMEZONE="${OPENCLAW_TZ:-}"
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
}
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_BUILDKIT=1 docker build "$@"
}
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_prestart_gateway() {
docker compose "${COMPOSE_ARGS[@]}" run --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.
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 "$@"
}
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
}
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
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
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 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_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_TZ="$TIMEZONE"
# 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
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
}
# 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_GATEWAY_PORT \
OPENCLAW_BRIDGE_PORT \
OPENCLAW_GATEWAY_BIND \
OPENCLAW_GATEWAY_TOKEN \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES \
OPENCLAW_EXTENSIONS \
OPENCLAW_SANDBOX \
OPENCLAW_DOCKER_SOCKET \
DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_TZ
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $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 "$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.
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'
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 ""
run_prestart_cli onboard --mode local --no-install-daemon
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"
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
if [[ -n "$SANDBOX_ENABLED" ]]; then
echo ""
echo "==> Sandbox setup"
# Build sandbox image if Dockerfile.sandbox exists.
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
run_docker_build \
-t "openclaw-sandbox:bookworm-slim" \
-f "$ROOT_DIR/Dockerfile.sandbox" \
"$ROOT_DIR"
else
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&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 ! docker compose "${COMPOSE_ARGS[@]}" run --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.
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- ${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"
else
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
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.
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
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.
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
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: $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\""