mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-23 15:37:44 +00:00
The official Docker images were built on Debian 12 bookworm, whose coreutils 9.1 lacks mv --exchange/--no-copy, so Doctor's btrfs NOCOW repair was always skipped in containers (e.g. Synology NAS). Build, runtime and sandbox images now use Debian 13 trixie (coreutils 9.7) with pinned digests and the NOCOW repair utilities; Node stays on 24 to match release CI. Installer test coverage keeps bookworm alongside trixie. Default/slim image +6.1 MB, browser +32.9 MB. Verified: default/slim/browser builds, isolated Gateway, Chromium, and a real btrfs NOCOW repair. Fixes #171930
388 lines
14 KiB
Bash
Executable file
388 lines
14 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
|
|
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
|
|
exec /bin/bash "$0" "$@"
|
|
fi
|
|
# Guards the multi-node-install update fix.
|
|
#
|
|
# Sets up two independent Node installations inside a Docker container, installs
|
|
# OpenClaw under node-A, registers the gateway service pointing at node-A, then
|
|
# switches PATH so node-B comes first and runs `openclaw update`. Verifies that:
|
|
#
|
|
# 1. The update stays on node-A's package root and service runtime.
|
|
# 2. The gateway restarts from the preserved entrypoint and becomes healthy.
|
|
# 3. JSON and unattended TTY updates both complete without creating a shell profile.
|
|
#
|
|
# Usage:
|
|
# ./scripts/e2e/multi-node-update-docker.sh
|
|
#
|
|
# Requires: Docker, a built openclaw-current.tgz (or will build one).
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-multi-node-update-e2e" OPENCLAW_MULTI_NODE_UPDATE_E2E_IMAGE)"
|
|
SKIP_BUILD="${OPENCLAW_MULTI_NODE_UPDATE_E2E_SKIP_BUILD:-0}"
|
|
DOCKER_RUN_TIMEOUT="${OPENCLAW_MULTI_NODE_DOCKER_TIMEOUT:-300s}"
|
|
RUN_ID="${OPENCLAW_MULTI_NODE_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
|
ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update/$RUN_ID}"
|
|
|
|
mkdir -p "$ARTIFACT_DIR"
|
|
chmod -R a+rwX "$ARTIFACT_DIR" || true
|
|
trap 'docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"' EXIT
|
|
|
|
docker_e2e_build_or_reuse "$IMAGE_NAME" multi-node-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
|
|
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz multi-node-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
|
|
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
|
|
|
echo "=== Running multi-node-update Docker E2E ==="
|
|
|
|
CONTAINER_EXIT=0
|
|
docker_e2e_run_with_harness \
|
|
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
|
-e CI=true \
|
|
-e OPENCLAW_NO_ONBOARD=1 \
|
|
-e OPENCLAW_NO_PROMPT=1 \
|
|
-e OPENCLAW_SKIP_PROVIDERS=1 \
|
|
-e OPENCLAW_SKIP_CHANNELS=1 \
|
|
-e OPENCLAW_DISABLE_BONJOUR=1 \
|
|
-e OPENAI_API_KEY=sk-multi-node-test \
|
|
-v "$ARTIFACT_DIR:/tmp/artifacts" \
|
|
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
|
--user root \
|
|
-e HOME=/root \
|
|
"$IMAGE_NAME" \
|
|
timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash -lc '
|
|
set -euo pipefail
|
|
source scripts/lib/openclaw-e2e-instance.sh
|
|
|
|
ARTIFACTS=/tmp/artifacts
|
|
exec > >(tee "$ARTIFACTS/run.log") 2>&1
|
|
|
|
echo "========================================"
|
|
echo " Multi-Node Update Bug Reproduction"
|
|
echo "========================================"
|
|
echo ""
|
|
|
|
echo "── Step 1: Setting up two Node installations ──"
|
|
|
|
# node-A is the system node that ships with the Docker image (node:24-trixie-slim).
|
|
NODE_A="$(command -v node)"
|
|
NODE_A_DIR="$(dirname "$NODE_A")"
|
|
NODE_A_VERSION="$("$NODE_A" --version)"
|
|
echo "node-A: $NODE_A ($NODE_A_VERSION)"
|
|
|
|
NPM_PREFIX_A="/opt/npm-prefix-a"
|
|
NPM_PREFIX_B="/opt/npm-prefix-b"
|
|
mkdir -p "$NPM_PREFIX_A/bin" "$NPM_PREFIX_A/lib" "$NPM_PREFIX_B/bin" "$NPM_PREFIX_B/lib"
|
|
|
|
# node-B is a second, full Node installation created by copying the entire
|
|
# node prefix. This simulates having two real node installs (e.g. Homebrew +
|
|
# nvm, or system node + volta).
|
|
NODE_B_ROOT="/opt/node-b"
|
|
NODE_A_PREFIX="$(dirname "$NODE_A_DIR")"
|
|
mkdir -p "$NODE_B_ROOT"
|
|
cp -a "$NODE_A_PREFIX/bin" "$NODE_B_ROOT/bin"
|
|
cp -a "$NODE_A_PREFIX/lib" "$NODE_B_ROOT/lib"
|
|
chmod -R +x "$NODE_B_ROOT/bin/"*
|
|
export npm_config_prefix_orig="${npm_config_prefix:-}"
|
|
"$NODE_B_ROOT/bin/node" "$NODE_B_ROOT/bin/npm" config set prefix "$NPM_PREFIX_B" --global 2>/dev/null || true
|
|
NODE_B="$NODE_B_ROOT/bin/node"
|
|
NODE_B_VERSION="$("$NODE_B" --version)"
|
|
echo "node-B: $NODE_B ($NODE_B_VERSION)"
|
|
|
|
echo ""
|
|
echo "── Step 2: Install OpenClaw under node-A ──"
|
|
|
|
export npm_config_prefix="$NPM_PREFIX_A"
|
|
export NPM_CONFIG_PREFIX="$NPM_PREFIX_A"
|
|
export npm_config_loglevel=error
|
|
export npm_config_fund=false
|
|
export npm_config_audit=false
|
|
export PATH="$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH"
|
|
|
|
openclaw_e2e_install_package "$ARTIFACTS/install-a.log" "OpenClaw package under node-A prefix" "$NPM_PREFIX_A"
|
|
echo "Checking openclaw location..."
|
|
|
|
OPENCLAW_A="$(command -v openclaw)"
|
|
echo "openclaw binary: $OPENCLAW_A"
|
|
echo "openclaw version: $(openclaw --version 2>/dev/null || echo unknown)"
|
|
|
|
PACKAGE_ROOT_A="$NPM_PREFIX_A/lib/node_modules/openclaw"
|
|
echo "Package root A: $PACKAGE_ROOT_A"
|
|
ls -la "$PACKAGE_ROOT_A/package.json" 2>/dev/null || echo "WARNING: package.json not found at A"
|
|
|
|
echo ""
|
|
echo "── Step 3: Install the systemd service (gateway) using node-A ──"
|
|
|
|
# Reuse the service fixture that models manager-loaded definitions and restarts.
|
|
GATEWAY_UNIT_PATH="/root/.config/systemd/user/openclaw-gateway.service"
|
|
GATEWAY_DAEMON_LOG="$ARTIFACTS/gateway-daemon.log"
|
|
export OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_LOG="$ARTIFACTS/systemctl-shim.log"
|
|
export OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_DAEMON_LOG="$GATEWAY_DAEMON_LOG"
|
|
export OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE="$ARTIFACTS/gateway.pid"
|
|
source scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh
|
|
install_update_restart_systemctl_shim
|
|
|
|
echo "Installing gateway service..."
|
|
mkdir -p "$(dirname "$GATEWAY_UNIT_PATH")"
|
|
if ! openclaw gateway install --json >"$ARTIFACTS/gateway-install.json" 2>"$ARTIFACTS/gateway-install.err"; then
|
|
echo "FAIL: gateway install failed before update"
|
|
cat "$ARTIFACTS/gateway-install.json" 2>/dev/null || true
|
|
cat "$ARTIFACTS/gateway-install.err" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
if ! openclaw gateway status --json \
|
|
>"$ARTIFACTS/gateway-status-before-update.json" \
|
|
2>"$ARTIFACTS/gateway-status-before-update.err"; then
|
|
echo "FAIL: gateway status failed before update"
|
|
cat "$ARTIFACTS/gateway-status-before-update.err" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
if ! GATEWAY_STATUS_FILE="$ARTIFACTS/gateway-status-before-update.json" node --input-type=module <<"NODE"
|
|
import fs from "node:fs";
|
|
const status = JSON.parse(fs.readFileSync(process.env.GATEWAY_STATUS_FILE, "utf8"));
|
|
if (status.service?.runtime?.status !== "running") {
|
|
console.error(`expected running gateway service before update, got \${status.service?.runtime?.status ?? "missing"}`);
|
|
process.exit(1);
|
|
}
|
|
NODE
|
|
then
|
|
echo "FAIL: gateway service was not running before update"
|
|
cat "$ARTIFACTS/gateway-status-before-update.json" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "── Step 4: Inspect what node path was baked into the service ──"
|
|
|
|
if [ -f "$GATEWAY_UNIT_PATH" ]; then
|
|
echo "Service command before update:"
|
|
grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/command-before-update.txt"
|
|
echo ""
|
|
EXEC_START_BEFORE="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)"
|
|
BAKED_NODE_BEFORE="$(echo "$EXEC_START_BEFORE" | sed "s/^ExecStart=//" | awk "{print \$1}")"
|
|
echo "Baked node path BEFORE update: $BAKED_NODE_BEFORE"
|
|
else
|
|
echo "FAIL: Gateway unit file was not created at $GATEWAY_UNIT_PATH"
|
|
echo "gateway install output:"
|
|
cat "$ARTIFACTS/gateway-install.json" 2>/dev/null || true
|
|
cat "$ARTIFACTS/gateway-install.err" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "── Step 5: Switch PATH so node-B comes first ──"
|
|
|
|
# Simulate the user scenario: their PATH changes (e.g. they installed
|
|
# a second Node via nvm, brew, etc.) and the new node-B comes first.
|
|
# Crucially, node-B has its own working npm with its own global prefix,
|
|
# but openclaw is NOT installed there.
|
|
export PATH="$NPM_PREFIX_B/bin:$NODE_B_ROOT/bin:$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH"
|
|
export npm_config_prefix="$NPM_PREFIX_B"
|
|
export NPM_CONFIG_PREFIX="$NPM_PREFIX_B"
|
|
|
|
echo "node-B npm prefix: $($NODE_B_ROOT/bin/node $NODE_B_ROOT/bin/npm prefix -g 2>/dev/null || echo unknown)"
|
|
echo "which node: $(command -v node)"
|
|
echo "which openclaw: $(command -v openclaw)"
|
|
echo "process.execPath will be: $(node -e "console.log(process.execPath)")"
|
|
|
|
echo ""
|
|
for OUTPUT_MODE in json tty; do
|
|
echo "── Step 6: Run openclaw update ($OUTPUT_MODE) ──"
|
|
|
|
UPDATE_FAILED=0
|
|
GATEWAY_HEALTH_FAILED=0
|
|
|
|
# Both updates must preserve node-A even though the invoking runtime is node-B.
|
|
UPDATE_EXIT=0
|
|
UPDATE_LOG="$ARTIFACTS/update.$OUTPUT_MODE.log"
|
|
if [ "$OUTPUT_MODE" = json ]; then
|
|
openclaw update --yes --json \
|
|
--tag /tmp/openclaw-current.tgz \
|
|
>"$ARTIFACTS/update.json" 2>"$UPDATE_LOG" || UPDATE_EXIT=$?
|
|
node --input-type=module - "$ARTIFACTS/update.json" "$PACKAGE_ROOT_A" <<"NODE"
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
const result = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
assert.equal(result.status, "ok");
|
|
assert.equal(result.root, process.argv[3]);
|
|
NODE
|
|
else
|
|
TTY_PROFILE_DIR="$(mktemp -d /tmp/openclaw-update-tty-profile.XXXXXX)"
|
|
# Python is part of the bare image. Keep a real terminal open without sending
|
|
# consent input; a stray prompt must time out and fail, never accept a default.
|
|
SHELL=/bin/zsh ZDOTDIR="$TTY_PROFILE_DIR" python3 - >"$UPDATE_LOG" 2>&1 <<"PYTHON" || UPDATE_EXIT=$?
|
|
import os
|
|
import pty
|
|
|
|
wait_status = pty.spawn([
|
|
"timeout", "--kill-after=10s", "120s", "bash", "-c",
|
|
"test -t 0 && test -t 1 && exec \"$@\"", "openclaw-tty",
|
|
"openclaw", "update", "--yes", "--tag", "/tmp/openclaw-current.tgz",
|
|
], stdin_read=lambda _: b"")
|
|
exit_code = os.waitstatus_to_exitcode(wait_status)
|
|
raise SystemExit(exit_code if exit_code >= 0 else 128 - exit_code)
|
|
PYTHON
|
|
if [ -e "$TTY_PROFILE_DIR/.zshrc" ]; then
|
|
echo "FAIL: unattended TTY update created an optional shell profile"
|
|
exit 1
|
|
fi
|
|
rmdir "$TTY_PROFILE_DIR"
|
|
if [ "$UPDATE_EXIT" -eq 0 ]; then
|
|
echo "OK: TTY update --yes completed without input or shell profile changes"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "Update exit code: $UPDATE_EXIT"
|
|
if [ "$UPDATE_EXIT" -ne 0 ]; then
|
|
UPDATE_FAILED=1
|
|
openclaw_e2e_print_log "$UPDATE_LOG"
|
|
fi
|
|
|
|
# Keep inspecting after a non-zero update so the log shows whether the unit was
|
|
# rewritten, but fail immediately if update never reached the service refresh.
|
|
if [ "$UPDATE_EXIT" -ne 0 ] && ! grep -q "gateway" "$UPDATE_LOG" 2>/dev/null; then
|
|
echo "FAIL: openclaw update failed before reaching the package install step"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "── Step 7: Inspect the service unit AFTER update ──"
|
|
|
|
if [ -f "$GATEWAY_UNIT_PATH" ]; then
|
|
echo "Service command after $OUTPUT_MODE update:"
|
|
grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/command-after-$OUTPUT_MODE.txt"
|
|
echo ""
|
|
EXEC_START_AFTER="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)"
|
|
BAKED_NODE_AFTER="$(echo "$EXEC_START_AFTER" | sed "s/^ExecStart=//" | awk "{print \$1}")"
|
|
echo "Baked node path AFTER update: $BAKED_NODE_AFTER"
|
|
else
|
|
echo "No unit file after update."
|
|
fi
|
|
|
|
echo ""
|
|
echo "── Step 8: Verify results ──"
|
|
|
|
BAKED_NODE_BEFORE="${BAKED_NODE_BEFORE:-unknown}"
|
|
BAKED_NODE_AFTER="${BAKED_NODE_AFTER:-unknown}"
|
|
|
|
echo "Node A: $NODE_A"
|
|
echo "Node B: $NODE_B"
|
|
echo "Baked BEFORE update: $BAKED_NODE_BEFORE"
|
|
echo "Baked AFTER update: $BAKED_NODE_AFTER"
|
|
echo "Package root A: $PACKAGE_ROOT_A"
|
|
echo ""
|
|
|
|
if [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; then
|
|
echo "BUG CONFIRMED: Gateway service now points at node-B ($NODE_B)"
|
|
echo " but OpenClaw package is still under node-A prefix ($PACKAGE_ROOT_A)."
|
|
echo " The gateway will use node-B to run an entrypoint that may reference"
|
|
echo " node-A dependencies or may not exist under node-B global prefix."
|
|
elif [ "$BAKED_NODE_AFTER" = "$BAKED_NODE_BEFORE" ]; then
|
|
echo "FIXED: Gateway service still points at the original node ($BAKED_NODE_AFTER)"
|
|
else
|
|
echo "CHANGED: Node path changed from $BAKED_NODE_BEFORE to $BAKED_NODE_AFTER"
|
|
fi
|
|
|
|
if [ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ]; then
|
|
echo "WARNING: OpenClaw was ALSO installed under node-B prefix (split install)"
|
|
else
|
|
echo "OK: OpenClaw is NOT under node-B prefix (expected: only under node-A)"
|
|
fi
|
|
|
|
ENTRYPOINT_FAILED=0
|
|
if ENTRYPOINT_PATH="$(node scripts/e2e/lib/doctor-install-switch/assert-exec-start.mjs entrypoint-exists "$GATEWAY_UNIT_PATH")"; then
|
|
echo "OK: Entrypoint exists: $ENTRYPOINT_PATH"
|
|
else
|
|
ENTRYPOINT_FAILED=1
|
|
fi
|
|
|
|
if [ -f "$UPDATE_LOG" ]; then
|
|
if grep -qi "Shell OpenClaw root differs" "$UPDATE_LOG" 2>/dev/null; then
|
|
echo "OK: Update warned about split root"
|
|
fi
|
|
if grep -qi "Managed gateway service Node" "$UPDATE_LOG" 2>/dev/null; then
|
|
echo "OK: Update showed the managed service Node path"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "── Step 9: Verify the gateway after $OUTPUT_MODE update ──"
|
|
|
|
if [ -f "$GATEWAY_UNIT_PATH" ]; then
|
|
if ! systemctl --user is-active --quiet openclaw-gateway.service; then
|
|
echo "FAIL: update did not leave the managed gateway running"
|
|
exit 1
|
|
fi
|
|
if PORT=18789 node <<NODE
|
|
const url = "http://127.0.0.1:" + process.env.PORT + "/healthz";
|
|
const deadline = Date.now() + 30000;
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
let last = "timeout";
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
// Keep each request inside the outer probe budget so a stalled fetch cannot outlive it.
|
|
const remainingMs = Math.max(1, deadline - Date.now());
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(remainingMs) });
|
|
if (response.ok) {
|
|
process.exit(0);
|
|
}
|
|
last = "HTTP " + response.status;
|
|
} catch (error) {
|
|
last = error instanceof Error ? error.message : String(error);
|
|
}
|
|
const remainingDelayMs = deadline - Date.now();
|
|
if (remainingDelayMs <= 0) {
|
|
break;
|
|
}
|
|
await sleep(Math.min(500, remainingDelayMs));
|
|
}
|
|
console.error(last);
|
|
process.exit(1);
|
|
NODE
|
|
then
|
|
echo "OK: Gateway healthz probe succeeded"
|
|
else
|
|
echo "BUG: Gateway healthz probe failed with the post-update unit"
|
|
GATEWAY_HEALTH_FAILED=1
|
|
openclaw_e2e_print_log "$GATEWAY_DAEMON_LOG"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "========================================"
|
|
echo " Reproduction complete."
|
|
echo " Artifacts saved to /tmp/artifacts/"
|
|
echo "========================================"
|
|
|
|
if { [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; } ||
|
|
[ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ] ||
|
|
[ "$ENTRYPOINT_FAILED" -ne 0 ] ||
|
|
[ "$UPDATE_FAILED" -ne 0 ] ||
|
|
[ "$GATEWAY_HEALTH_FAILED" -ne 0 ]; then
|
|
exit 1
|
|
fi
|
|
done
|
|
systemctl --user stop openclaw-gateway.service
|
|
' || CONTAINER_EXIT=$?
|
|
|
|
echo ""
|
|
echo "=== Artifacts ==="
|
|
echo "Logs saved to: $ARTIFACT_DIR/"
|
|
ls -la "$ARTIFACT_DIR/" 2>/dev/null || true
|
|
|
|
if [ -f "$ARTIFACT_DIR/run.log" ]; then
|
|
echo ""
|
|
echo "=== Key results ==="
|
|
grep -E "^(BUG|FIXED|OK|CHANGED|WARNING)" "$ARTIFACT_DIR/run.log" || echo "(no key results found)"
|
|
fi
|
|
|
|
if [ "$CONTAINER_EXIT" -ne 0 ]; then
|
|
echo ""
|
|
echo "FAIL: Docker container exited with code $CONTAINER_EXIT"
|
|
fi
|
|
exit "$CONTAINER_EXIT"
|