mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 11:13:16 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
34
openclaw/scripts/docker/cleanup-smoke/Dockerfile
Normal file
34
openclaw/scripts/docker/cleanup-smoke/Dockerfile
Normal 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"]
|
||||
44
openclaw/scripts/docker/cleanup-smoke/run.sh
Normal file
44
openclaw/scripts/docker/cleanup-smoke/run.sh
Normal 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"
|
||||
53
openclaw/scripts/docker/install-sh-common/cli-verify.sh
Normal file
53
openclaw/scripts/docker/install-sh-common/cli-verify.sh
Normal 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
|
||||
}
|
||||
25
openclaw/scripts/docker/install-sh-common/version-parse.sh
Normal file
25
openclaw/scripts/docker/install-sh-common/version-parse.sh
Normal 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 \
|
||||
"$@"
|
||||
}
|
||||
21
openclaw/scripts/docker/install-sh-e2e/Dockerfile
Normal file
21
openclaw/scripts/docker/install-sh-e2e/Dockerfile
Normal 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"]
|
||||
607
openclaw/scripts/docker/install-sh-e2e/run.sh
Normal file
607
openclaw/scripts/docker/install-sh-e2e/run.sh
Normal 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"
|
||||
47
openclaw/scripts/docker/install-sh-nonroot/Dockerfile
Normal file
47
openclaw/scripts/docker/install-sh-nonroot/Dockerfile
Normal 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"]
|
||||
49
openclaw/scripts/docker/install-sh-nonroot/run.sh
Normal file
49
openclaw/scripts/docker/install-sh-nonroot/run.sh
Normal 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"
|
||||
30
openclaw/scripts/docker/install-sh-smoke/Dockerfile
Normal file
30
openclaw/scripts/docker/install-sh-smoke/Dockerfile
Normal 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"]
|
||||
345
openclaw/scripts/docker/install-sh-smoke/run.sh
Normal file
345
openclaw/scripts/docker/install-sh-smoke/run.sh
Normal 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
|
||||
654
openclaw/scripts/docker/setup.sh
Normal file
654
openclaw/scripts/docker/setup.sh
Normal 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\""
|
||||
Loading…
Add table
Add a link
Reference in a new issue