mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 16:23:53 +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
49
openclaw/scripts/e2e/Dockerfile
Normal file
49
openclaw/scripts/e2e/Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash appuser \
|
||||
&& mkdir -p /app \
|
||||
&& chown appuser:appuser /app
|
||||
|
||||
ENV HOME="/home/appuser"
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
USER appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
|
||||
COPY --chown=appuser:appuser extensions ./extensions
|
||||
COPY --chown=appuser:appuser patches ./patches
|
||||
COPY --chown=appuser:appuser 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=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts openclaw.mjs ./
|
||||
COPY --chown=appuser:appuser src ./src
|
||||
COPY --chown=appuser:appuser test ./test
|
||||
COPY --chown=appuser:appuser scripts ./scripts
|
||||
COPY --chown=appuser:appuser docs ./docs
|
||||
COPY --chown=appuser:appuser packages ./packages
|
||||
COPY --chown=appuser:appuser skills ./skills
|
||||
COPY --chown=appuser:appuser ui ./ui
|
||||
COPY --chown=appuser:appuser extensions ./extensions
|
||||
COPY --chown=appuser:appuser vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit
|
||||
COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
|
||||
COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI
|
||||
|
||||
RUN pnpm build
|
||||
# Onboard Docker E2E does not exercise the Control UI itself; it only needs the
|
||||
# asset-existence check to pass so configure/onboard can continue.
|
||||
RUN mkdir -p dist/control-ui \
|
||||
&& printf '%s\n' '<!doctype html><title>OpenClaw Control UI</title>' > dist/control-ui/index.html
|
||||
|
||||
CMD ["bash"]
|
||||
29
openclaw/scripts/e2e/Dockerfile.qr-import
Normal file
29
openclaw/scripts/e2e/Dockerfile.qr-import
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash appuser \
|
||||
&& mkdir -p /app \
|
||||
&& chown appuser:appuser /app
|
||||
|
||||
ENV HOME="/home/appuser"
|
||||
|
||||
USER appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
|
||||
COPY --chown=appuser:appuser patches ./patches
|
||||
|
||||
# This image only exercises the root qrcode-terminal dependency path.
|
||||
# Keep the pre-install copy set limited to the manifests needed for root
|
||||
# workspace resolution so unrelated extension edits do not bust the layer.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||
if ! pnpm install --frozen-lockfile --ignore-scripts >/tmp/openclaw-qr-pnpm-install.log 2>&1; then \
|
||||
cat /tmp/openclaw-qr-pnpm-install.log; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
COPY --chown=appuser:appuser . .
|
||||
173
openclaw/scripts/e2e/doctor-install-switch-docker.sh
Normal file
173
openclaw/scripts/e2e/doctor-install-switch-docker.sh
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
IMAGE_NAME="openclaw-doctor-install-switch-e2e"
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged doctor-switch-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running doctor install switch E2E..."
|
||||
docker run --rm -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
# Keep logs focused; the npm global install step can emit noisy deprecation warnings.
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
|
||||
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
|
||||
export PATH="/tmp/openclaw-bin:$PATH"
|
||||
mkdir -p /tmp/openclaw-bin
|
||||
|
||||
cat > /tmp/openclaw-bin/systemctl <<"SYSTEMCTL"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
args=("$@")
|
||||
if [[ "${args[0]:-}" == "--user" ]]; then
|
||||
args=("${args[@]:1}")
|
||||
fi
|
||||
cmd="${args[0]:-}"
|
||||
case "$cmd" in
|
||||
status)
|
||||
exit 0
|
||||
;;
|
||||
is-enabled)
|
||||
unit="${args[1]:-}"
|
||||
unit_path="$HOME/.config/systemd/user/${unit}"
|
||||
if [ -f "$unit_path" ]; then
|
||||
echo "enabled"
|
||||
exit 0
|
||||
fi
|
||||
echo "disabled" >&2
|
||||
exit 1
|
||||
;;
|
||||
show)
|
||||
echo "ActiveState=inactive"
|
||||
echo "SubState=dead"
|
||||
echo "MainPID=0"
|
||||
echo "ExecMainStatus=0"
|
||||
echo "ExecMainCode=0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
SYSTEMCTL
|
||||
chmod +x /tmp/openclaw-bin/systemctl
|
||||
|
||||
cat > /tmp/openclaw-bin/loginctl <<"LOGINCTL"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$*" == *"show-user"* ]]; then
|
||||
echo "Linger=yes"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"enable-linger"* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
LOGINCTL
|
||||
chmod +x /tmp/openclaw-bin/loginctl
|
||||
|
||||
# Install the npm-global variant from the local /app source.
|
||||
# `npm pack` can emit script output; keep only the tarball name.
|
||||
pkg_tgz="$(npm pack --ignore-scripts --silent /app | tail -n 1 | tr -d '\r')"
|
||||
if [ ! -f "/app/$pkg_tgz" ]; then
|
||||
echo "npm pack failed (expected /app/$pkg_tgz)"
|
||||
exit 1
|
||||
fi
|
||||
npm_log="/tmp/openclaw-doctor-switch-npm-install.log"
|
||||
if ! npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz" >"$npm_log" 2>&1; then
|
||||
cat "$npm_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm_bin="/tmp/npm-prefix/bin/openclaw"
|
||||
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
|
||||
if [ -f "$npm_root/dist/index.mjs" ]; then
|
||||
npm_entry="$npm_root/dist/index.mjs"
|
||||
else
|
||||
npm_entry="$npm_root/dist/index.js"
|
||||
fi
|
||||
|
||||
if [ -f "/app/dist/index.mjs" ]; then
|
||||
git_entry="/app/dist/index.mjs"
|
||||
else
|
||||
git_entry="/app/dist/index.js"
|
||||
fi
|
||||
git_cli="/app/openclaw.mjs"
|
||||
|
||||
assert_entrypoint() {
|
||||
local unit_path="$1"
|
||||
local expected="$2"
|
||||
local exec_line=""
|
||||
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "Missing ExecStart in $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
exec_line="${exec_line#ExecStart=}"
|
||||
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
|
||||
entrypoint="${entrypoint%\"}"
|
||||
entrypoint="${entrypoint#\"}"
|
||||
if [ "$entrypoint" != "$expected" ]; then
|
||||
echo "Expected entrypoint $expected, got $entrypoint"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Each flow: install service with one variant, run doctor from the other,
|
||||
# and verify ExecStart entrypoint switches accordingly.
|
||||
run_flow() {
|
||||
local name="$1"
|
||||
local install_cmd="$2"
|
||||
local install_expected="$3"
|
||||
local doctor_cmd="$4"
|
||||
local doctor_expected="$5"
|
||||
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
|
||||
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
|
||||
|
||||
echo "== Flow: $name =="
|
||||
home_dir=$(mktemp -d "/tmp/openclaw-switch-${name}.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
export USER="testuser"
|
||||
|
||||
if ! eval "$install_cmd" >"$install_log" 2>&1; then
|
||||
cat "$install_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
if [ ! -f "$unit_path" ]; then
|
||||
echo "Missing unit file: $unit_path"
|
||||
exit 1
|
||||
fi
|
||||
assert_entrypoint "$unit_path" "$install_expected"
|
||||
|
||||
if ! eval "$doctor_cmd" >"$doctor_log" 2>&1; then
|
||||
cat "$doctor_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assert_entrypoint "$unit_path" "$doctor_expected"
|
||||
}
|
||||
|
||||
run_flow \
|
||||
"npm-to-git" \
|
||||
"$npm_bin daemon install --force" \
|
||||
"$npm_entry" \
|
||||
"node $git_cli doctor --repair --force --yes" \
|
||||
"$git_entry"
|
||||
|
||||
run_flow \
|
||||
"git-to-npm" \
|
||||
"node $git_cli daemon install --force" \
|
||||
"$git_entry" \
|
||||
"$npm_bin doctor --repair --force --yes" \
|
||||
"$npm_entry"
|
||||
'
|
||||
144
openclaw/scripts/e2e/gateway-network-docker.sh
Normal file
144
openclaw/scripts/e2e/gateway-network-docker.sh
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
IMAGE_NAME="openclaw-gateway-network-e2e"
|
||||
|
||||
PORT="18789"
|
||||
TOKEN="e2e-$(date +%s)-$$"
|
||||
NET_NAME="openclaw-net-e2e-$$"
|
||||
GW_NAME="openclaw-gateway-e2e-$$"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$GW_NAME" >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged gateway-network-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Creating Docker network..."
|
||||
docker network create "$NET_NAME" >/dev/null
|
||||
|
||||
echo "Starting gateway container..."
|
||||
docker run -d \
|
||||
--name "$GW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CRON=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail; entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" >/dev/null
|
||||
|
||||
echo "Waiting for gateway to come up..."
|
||||
ready=0
|
||||
for _ in $(seq 1 40); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
|
||||
import net from \"node:net\";
|
||||
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
}, 400);
|
||||
socket.on(\"connect\", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.on(\"error\", () => {
|
||||
clearTimeout(timeout);
|
||||
process.exit(1);
|
||||
});
|
||||
' >/dev/null 2>&1"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log 2>/dev/null"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "Gateway failed to start"
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then
|
||||
docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true
|
||||
else
|
||||
docker logs "$GW_NAME" 2>&1 | tail -n 120 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running client container (connect + health)..."
|
||||
run_logged gateway-network-client docker run --rm \
|
||||
--network "$NET_NAME" \
|
||||
-e "GW_URL=ws://$GW_NAME:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "node --import tsx - <<'NODE'
|
||||
import { WebSocket } from \"ws\";
|
||||
import { PROTOCOL_VERSION } from \"./src/gateway/protocol/index.ts\";
|
||||
|
||||
const url = process.env.GW_URL;
|
||||
const token = process.env.GW_TOKEN;
|
||||
if (!url || !token) throw new Error(\"missing GW_URL/GW_TOKEN\");
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(\"ws open timeout\")), 5000);
|
||||
ws.once(\"open\", () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function onceFrame(filter, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(\"timeout\")), timeoutMs);
|
||||
const handler = (data) => {
|
||||
const obj = JSON.parse(String(data));
|
||||
if (!filter(obj)) return;
|
||||
clearTimeout(t);
|
||||
ws.off(\"message\", handler);
|
||||
resolve(obj);
|
||||
};
|
||||
ws.on(\"message\", handler);
|
||||
});
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: \"req\",
|
||||
id: \"c1\",
|
||||
method: \"connect\",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: \"test\",
|
||||
displayName: \"docker-net-e2e\",
|
||||
version: \"dev\",
|
||||
platform: process.platform,
|
||||
mode: \"test\",
|
||||
},
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
||||
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
||||
|
||||
ws.close();
|
||||
console.log(\"ok\");
|
||||
NODE"
|
||||
|
||||
echo "OK"
|
||||
142
openclaw/scripts/e2e/lib/parallels-macos-common.sh
Normal file
142
openclaw/scripts/e2e/lib/parallels-macos-common.sh
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
parallels_macos_resolve_desktop_user() {
|
||||
local vm_name="$1"
|
||||
local user
|
||||
user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)"
|
||||
if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then
|
||||
printf '%s\n' "$user"
|
||||
return 0
|
||||
fi
|
||||
prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }'
|
||||
}
|
||||
|
||||
parallels_macos_resolve_desktop_home() {
|
||||
local vm_name="$1"
|
||||
local user="$2"
|
||||
local home
|
||||
home="$(
|
||||
prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk '/NFSHomeDirectory:/ { print $2; exit }'
|
||||
)"
|
||||
if [[ -n "$home" ]]; then
|
||||
printf '%s\n' "$home"
|
||||
else
|
||||
printf '/Users/%s\n' "$user"
|
||||
fi
|
||||
}
|
||||
|
||||
parallels_macos_current_user_available() {
|
||||
local vm_name="$1"
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1
|
||||
}
|
||||
|
||||
parallels_macos_desktop_user_exec_with_secret_file() {
|
||||
local vm_name="$1"
|
||||
local user_flag="$2"
|
||||
local user_name="$3"
|
||||
local home="$4"
|
||||
local path_value="$5"
|
||||
local api_key_env="$6"
|
||||
local api_key_value="$7"
|
||||
shift 7
|
||||
|
||||
local secret_path
|
||||
secret_path="/tmp/openclaw-secret-${api_key_env:-env}-$RANDOM-$RANDOM"
|
||||
|
||||
if [[ -n "$api_key_env" && -n "$api_key_value" ]]; then
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$secret_path"
|
||||
else
|
||||
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$secret_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
local wrapper
|
||||
local wrapper_path
|
||||
wrapper_path="/tmp/openclaw-secret-env-wrapper-$RANDOM-$RANDOM.sh"
|
||||
wrapper='#!/bin/bash
|
||||
set -e
|
||||
cleanup() {
|
||||
rm -f "${OPENCLAW_WRAPPER_FILE:-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
if [ -n "${OPENCLAW_SECRET_ENV_NAME:-}" ] && [ -n "${OPENCLAW_SECRET_FILE:-}" ] && [ -f "$OPENCLAW_SECRET_FILE" ]; then
|
||||
secret_value="$(cat "$OPENCLAW_SECRET_FILE")"
|
||||
rm -f "$OPENCLAW_SECRET_FILE"
|
||||
export "${OPENCLAW_SECRET_ENV_NAME}=${secret_value}"
|
||||
fi
|
||||
"$@"
|
||||
'
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
--current-user /usr/bin/base64 -D -o "$wrapper_path"
|
||||
else
|
||||
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
|
||||
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$wrapper_path"
|
||||
fi
|
||||
|
||||
if [[ "$user_flag" == "current-user" ]]; then
|
||||
prlctl exec "$vm_name" --current-user /usr/bin/env \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
prlctl exec "$vm_name" /usr/bin/sudo -H -u "$user_name" /usr/bin/env \
|
||||
"HOME=$home" \
|
||||
"USER=$user_name" \
|
||||
"LOGNAME=$user_name" \
|
||||
"PATH=$path_value" \
|
||||
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
|
||||
"OPENCLAW_SECRET_FILE=$secret_path" \
|
||||
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
|
||||
/bin/bash "$wrapper_path" "$@"
|
||||
}
|
||||
|
||||
parallels_macos_desktop_user_exec() {
|
||||
local vm_name="$1"
|
||||
local api_key_env="$2"
|
||||
local api_key_value="$3"
|
||||
shift 3
|
||||
|
||||
if parallels_macos_current_user_available "$vm_name"; then
|
||||
parallels_macos_desktop_user_exec_with_secret_file \
|
||||
"$vm_name" \
|
||||
"current-user" \
|
||||
"" \
|
||||
"" \
|
||||
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$api_key_env" \
|
||||
"$api_key_value" \
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
local user home
|
||||
user="$(parallels_macos_resolve_desktop_user "$vm_name")"
|
||||
[[ -n "$user" ]] || {
|
||||
printf 'unable to resolve macOS desktop user for sudo fallback\n' >&2
|
||||
return 1
|
||||
}
|
||||
home="$(parallels_macos_resolve_desktop_home "$vm_name" "$user")"
|
||||
printf 'warn: macOS --current-user unavailable; using root sudo fallback for %s\n' "$user" >&2
|
||||
parallels_macos_desktop_user_exec_with_secret_file \
|
||||
"$vm_name" \
|
||||
"sudo" \
|
||||
"$user" \
|
||||
"$home" \
|
||||
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$api_key_env" \
|
||||
"$api_key_value" \
|
||||
"$@"
|
||||
}
|
||||
247
openclaw/scripts/e2e/mcp-channels-docker-client.ts
Normal file
247
openclaw/scripts/e2e/mcp-channels-docker-client.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
assert,
|
||||
ClaudeChannelNotificationSchema,
|
||||
ClaudePermissionNotificationSchema,
|
||||
connectGateway,
|
||||
connectMcpClient,
|
||||
extractTextFromGatewayPayload,
|
||||
type ClaudeChannelNotification,
|
||||
maybeApprovePendingBridgePairing,
|
||||
waitFor,
|
||||
} from "./mcp-channels-harness.ts";
|
||||
|
||||
async function main() {
|
||||
const gatewayUrl = process.env.GW_URL?.trim();
|
||||
const gatewayToken = process.env.GW_TOKEN?.trim();
|
||||
assert(gatewayUrl, "missing GW_URL");
|
||||
assert(gatewayToken, "missing GW_TOKEN");
|
||||
|
||||
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
|
||||
let mcpHandle = await connectMcpClient({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
});
|
||||
let mcp = mcpHandle.client;
|
||||
|
||||
try {
|
||||
if (await maybeApprovePendingBridgePairing(gateway)) {
|
||||
await Promise.allSettled([mcp.close(), mcpHandle.transport.close()]);
|
||||
mcpHandle = await connectMcpClient({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
});
|
||||
mcp = mcpHandle.client;
|
||||
}
|
||||
|
||||
const listed = (await mcp.callTool({
|
||||
name: "conversations_list",
|
||||
arguments: {},
|
||||
})) as {
|
||||
structuredContent?: { conversations?: Array<Record<string, unknown>> };
|
||||
};
|
||||
const conversation = listed.structuredContent?.conversations?.find(
|
||||
(entry) => entry.sessionKey === "agent:main:main",
|
||||
);
|
||||
assert(conversation, "expected seeded conversation in conversations_list");
|
||||
assert(conversation.channel === "imessage", "expected seeded channel");
|
||||
assert(conversation.to === "+15551234567", "expected seeded target");
|
||||
|
||||
const fetched = (await mcp.callTool({
|
||||
name: "conversation_get",
|
||||
arguments: { session_key: "agent:main:main" },
|
||||
})) as {
|
||||
structuredContent?: { conversation?: Record<string, unknown> };
|
||||
isError?: boolean;
|
||||
};
|
||||
assert(!fetched.isError, "conversation_get should succeed");
|
||||
assert(
|
||||
fetched.structuredContent?.conversation?.sessionKey === "agent:main:main",
|
||||
"conversation_get returned wrong session",
|
||||
);
|
||||
|
||||
const history = (await mcp.callTool({
|
||||
name: "messages_read",
|
||||
arguments: { session_key: "agent:main:main", limit: 10 },
|
||||
})) as {
|
||||
structuredContent?: { messages?: Array<Record<string, unknown>> };
|
||||
};
|
||||
const messages = history.structuredContent?.messages ?? [];
|
||||
assert(messages.length >= 2, "expected seeded transcript messages");
|
||||
const attachmentMessage = messages.find((entry) => {
|
||||
const raw = entry.__openclaw;
|
||||
return raw && typeof raw === "object" && (raw as { id?: unknown }).id === "msg-attachment";
|
||||
});
|
||||
assert(attachmentMessage, "expected seeded attachment message");
|
||||
|
||||
const attachments = (await mcp.callTool({
|
||||
name: "attachments_fetch",
|
||||
arguments: { session_key: "agent:main:main", message_id: "msg-attachment" },
|
||||
})) as {
|
||||
structuredContent?: { attachments?: Array<Record<string, unknown>> };
|
||||
isError?: boolean;
|
||||
};
|
||||
assert(!attachments.isError, "attachments_fetch should succeed");
|
||||
assert(
|
||||
(attachments.structuredContent?.attachments?.length ?? 0) === 1,
|
||||
"expected one seeded attachment",
|
||||
);
|
||||
|
||||
const waited = (await Promise.all([
|
||||
mcp.callTool({
|
||||
name: "events_wait",
|
||||
arguments: {
|
||||
session_key: "agent:main:main",
|
||||
after_cursor: 0,
|
||||
timeout_ms: 10_000,
|
||||
},
|
||||
}) as Promise<{
|
||||
structuredContent?: { event?: Record<string, unknown> };
|
||||
}>,
|
||||
gateway.request("chat.inject", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: "assistant live event",
|
||||
}),
|
||||
]).then(([result]) => result)) as {
|
||||
structuredContent?: { event?: Record<string, unknown> };
|
||||
};
|
||||
const assistantEvent = waited.structuredContent?.event;
|
||||
assert(assistantEvent, "expected events_wait result");
|
||||
assert(assistantEvent.type === "message", "expected message event");
|
||||
assert(assistantEvent.role === "assistant", "expected assistant event role");
|
||||
assert(assistantEvent.text === "assistant live event", "expected assistant event text");
|
||||
const assistantCursor = typeof assistantEvent.cursor === "number" ? assistantEvent.cursor : 0;
|
||||
|
||||
const polled = (await mcp.callTool({
|
||||
name: "events_poll",
|
||||
arguments: { session_key: "agent:main:main", after_cursor: 0, limit: 10 },
|
||||
})) as {
|
||||
structuredContent?: { events?: Array<Record<string, unknown>> };
|
||||
};
|
||||
assert(
|
||||
(polled.structuredContent?.events ?? []).some(
|
||||
(entry) => entry.text === "assistant live event",
|
||||
),
|
||||
"expected assistant event in events_poll",
|
||||
);
|
||||
|
||||
const channelMessage = `hello from docker ${randomUUID()}`;
|
||||
const userEvent = (await Promise.all([
|
||||
mcp.callTool({
|
||||
name: "events_wait",
|
||||
arguments: {
|
||||
session_key: "agent:main:main",
|
||||
after_cursor: assistantCursor,
|
||||
timeout_ms: 10_000,
|
||||
},
|
||||
}) as Promise<{
|
||||
structuredContent?: { event?: Record<string, unknown> };
|
||||
}>,
|
||||
gateway.request("chat.send", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: channelMessage,
|
||||
idempotencyKey: randomUUID(),
|
||||
}),
|
||||
]).then(([result]) => result)) as {
|
||||
structuredContent?: { event?: Record<string, unknown> };
|
||||
};
|
||||
const rawGatewayUserMessage = await waitFor("raw gateway user session.message", () =>
|
||||
gateway.events.find(
|
||||
(entry) =>
|
||||
entry.event === "session.message" &&
|
||||
entry.payload.sessionKey === "agent:main:main" &&
|
||||
extractTextFromGatewayPayload(entry.payload) === channelMessage,
|
||||
),
|
||||
);
|
||||
if (userEvent.structuredContent?.event?.text !== channelMessage) {
|
||||
throw new Error(
|
||||
`expected user event after chat.send: ${JSON.stringify(
|
||||
{
|
||||
userEvent: userEvent.structuredContent?.event ?? null,
|
||||
rawGatewayUserMessage: rawGatewayUserMessage ?? null,
|
||||
recentGatewayEvents: gateway.events.slice(-10).map((entry) => ({
|
||||
event: entry.event,
|
||||
sessionKey: entry.payload.sessionKey,
|
||||
text: extractTextFromGatewayPayload(entry.payload),
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
assert(rawGatewayUserMessage, "expected raw gateway session.message after chat.send");
|
||||
|
||||
let helpNotification: ClaudeChannelNotification;
|
||||
try {
|
||||
helpNotification = await waitFor(
|
||||
"Claude channel notification",
|
||||
() =>
|
||||
mcpHandle.rawMessages
|
||||
.map((entry) => ClaudeChannelNotificationSchema.safeParse(entry))
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.success &&
|
||||
entry.data.params.meta.session_key === "agent:main:main" &&
|
||||
entry.data.params.content === channelMessage,
|
||||
)?.data.params,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`timeout waiting for Claude channel notification: ${JSON.stringify(
|
||||
{
|
||||
rawMessages: mcpHandle.rawMessages.slice(-10),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
assert(helpNotification.content === channelMessage, "expected Claude channel content");
|
||||
|
||||
await mcp.notification({
|
||||
method: "notifications/claude/channel/permission_request",
|
||||
params: {
|
||||
request_id: "abcde",
|
||||
tool_name: "Bash",
|
||||
description: "run npm test",
|
||||
input_preview: '{"cmd":"npm test"}',
|
||||
},
|
||||
});
|
||||
|
||||
await gateway.request("chat.send", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: "yes abcde",
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
const permission = await waitFor(
|
||||
"Claude permission notification",
|
||||
() =>
|
||||
mcpHandle.rawMessages
|
||||
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
|
||||
.find((entry) => entry.success && entry.data.params.request_id === "abcde")?.data.params,
|
||||
);
|
||||
assert(permission.behavior === "allow", "expected allow permission reply");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
sessionKey: "agent:main:main",
|
||||
rawNotifications: mcpHandle.rawMessages.filter(
|
||||
(entry) =>
|
||||
ClaudeChannelNotificationSchema.safeParse(entry).success ||
|
||||
ClaudePermissionNotificationSchema.safeParse(entry).success,
|
||||
).length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([mcp.close(), mcpHandle.transport.close(), gateway.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
89
openclaw/scripts/e2e/mcp-channels-docker.sh
Normal file
89
openclaw/scripts/e2e/mcp-channels-docker.sh
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw-mcp-channels-e2e}"
|
||||
PORT="18789"
|
||||
TOKEN="mcp-e2e-$(date +%s)-$$"
|
||||
CONTAINER_NAME="openclaw-mcp-e2e-$$"
|
||||
CLIENT_LOG="$(mktemp -t openclaw-mcp-client-log.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$CLIENT_LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged mcp-channels-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running in-container gateway + MCP smoke..."
|
||||
set +e
|
||||
docker run --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CRON=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-e "GW_URL=ws://127.0.0.1:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
entry=dist/index.mjs
|
||||
[ -f \"\$entry\" ] || entry=dist/index.js
|
||||
node --import tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log
|
||||
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/mcp-channels-gateway.log 2>&1 &
|
||||
gateway_pid=\$!
|
||||
cleanup_inner() {
|
||||
kill \"\$gateway_pid\" >/dev/null 2>&1 || true
|
||||
wait \"\$gateway_pid\" >/dev/null 2>&1 || true
|
||||
}
|
||||
dump_gateway_log_on_error() {
|
||||
status=\$?
|
||||
if [ \"\$status\" -ne 0 ]; then
|
||||
tail -n 80 /tmp/mcp-channels-gateway.log 2>/dev/null || true
|
||||
fi
|
||||
cleanup_inner
|
||||
exit \"\$status\"
|
||||
}
|
||||
trap cleanup_inner EXIT
|
||||
trap dump_gateway_log_on_error ERR
|
||||
for _ in \$(seq 1 80); do
|
||||
if node --input-type=module -e '
|
||||
import net from \"node:net\";
|
||||
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
}, 400);
|
||||
socket.on(\"connect\", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.on(\"error\", () => {
|
||||
clearTimeout(timeout);
|
||||
process.exit(1);
|
||||
});
|
||||
' >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
node --import tsx scripts/e2e/mcp-channels-docker-client.ts
|
||||
" >"$CLIENT_LOG" 2>&1
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "Docker MCP smoke failed"
|
||||
cat "$CLIENT_LOG"
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
echo "OK"
|
||||
403
openclaw/scripts/e2e/mcp-channels-harness.ts
Normal file
403
openclaw/scripts/e2e/mcp-channels-harness.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import { PROTOCOL_VERSION } from "../../src/gateway/protocol/index.ts";
|
||||
import { formatErrorMessage } from "../../src/infra/errors.ts";
|
||||
import { rawDataToString } from "../../src/infra/ws.ts";
|
||||
import { readStringValue } from "../../src/shared/string-coerce.ts";
|
||||
|
||||
export const ClaudeChannelNotificationSchema = z.object({
|
||||
method: z.literal("notifications/claude/channel"),
|
||||
params: z.object({
|
||||
content: z.string(),
|
||||
meta: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ClaudePermissionNotificationSchema = z.object({
|
||||
method: z.literal("notifications/claude/channel/permission"),
|
||||
params: z.object({
|
||||
request_id: z.string(),
|
||||
behavior: z.enum(["allow", "deny"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ClaudeChannelNotification = z.infer<typeof ClaudeChannelNotificationSchema>["params"];
|
||||
|
||||
export type GatewayRpcClient = {
|
||||
request<T>(method: string, params?: unknown): Promise<T>;
|
||||
events: Array<{ event: string; payload: Record<string, unknown> }>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
export type McpClientHandle = {
|
||||
client: Client;
|
||||
transport: StdioClientTransport;
|
||||
rawMessages: unknown[];
|
||||
};
|
||||
|
||||
const GATEWAY_WS_TIMEOUT_MS = 30_000;
|
||||
const GATEWAY_CONNECT_RETRY_WINDOW_MS = 45_000;
|
||||
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextFromGatewayPayload(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const message = payload?.message;
|
||||
if (!message || typeof message !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === "string" && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const first = content[0];
|
||||
if (!first || typeof first !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return readStringValue((first as { text?: unknown }).text);
|
||||
}
|
||||
|
||||
export async function waitFor<T>(
|
||||
label: string,
|
||||
predicate: () => T | undefined,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<T> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const value = predicate();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}`);
|
||||
}
|
||||
|
||||
export async function connectGateway(params: {
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<GatewayRpcClient> {
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (Date.now() - startedAt < GATEWAY_CONNECT_RETRY_WINDOW_MS) {
|
||||
attempt += 1;
|
||||
try {
|
||||
return await connectGatewayOnce(params);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (!isRetryableGatewayConnectError(lastError)) {
|
||||
throw lastError;
|
||||
}
|
||||
await delay(Math.min(500 * attempt, 2_000));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("gateway ws open timeout");
|
||||
}
|
||||
|
||||
async function connectGatewayOnce(params: {
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<GatewayRpcClient> {
|
||||
const ws = new WebSocket(params.url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("gateway ws open timeout")),
|
||||
GATEWAY_WS_TIMEOUT_MS,
|
||||
);
|
||||
timeout.unref?.();
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
const requestedScopes = ["operator.read", "operator.write", "operator.pairing", "operator.admin"];
|
||||
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
||||
|
||||
ws.on("message", (data) => {
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(rawDataToString(data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!frame || typeof frame !== "object") {
|
||||
return;
|
||||
}
|
||||
const typed = frame as {
|
||||
type?: unknown;
|
||||
event?: unknown;
|
||||
payload?: unknown;
|
||||
id?: unknown;
|
||||
ok?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: unknown } | null;
|
||||
};
|
||||
if (typed.type === "event" && typeof typed.event === "string") {
|
||||
events.push({
|
||||
event: typed.event,
|
||||
payload:
|
||||
typed.payload && typeof typed.payload === "object"
|
||||
? (typed.payload as Record<string, unknown>)
|
||||
: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typed.type !== "res" || typeof typed.id !== "string") {
|
||||
return;
|
||||
}
|
||||
const match = pending.get(typed.id);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
pending.delete(typed.id);
|
||||
if (typed.ok === true) {
|
||||
match.resolve(typed.result);
|
||||
return;
|
||||
}
|
||||
match.reject(
|
||||
new Error(
|
||||
typed.error && typeof typed.error.message === "string"
|
||||
? typed.error.message
|
||||
: "gateway request failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
ws.once("close", (code, reason) => {
|
||||
const error = new Error(`gateway closed (${code}): ${rawDataToString(reason)}`);
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
const connectId = randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-tui",
|
||||
displayName: "docker-mcp-channels",
|
||||
version: "1.0.0",
|
||||
platform: process.platform,
|
||||
mode: "ui",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: requestedScopes,
|
||||
caps: [],
|
||||
auth: { token: params.token },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(connectId);
|
||||
reject(new Error("gateway connect timeout"));
|
||||
}, GATEWAY_WS_TIMEOUT_MS);
|
||||
timeout.unref?.();
|
||||
pending.set(connectId, {
|
||||
resolve: () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const id = randomUUID();
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("gateway sessions.subscribe timeout"));
|
||||
}, GATEWAY_WS_TIMEOUT_MS);
|
||||
timeout.unref?.();
|
||||
pending.set(id, {
|
||||
resolve: () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "sessions.subscribe",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
request(method, requestParams) {
|
||||
const id = randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method,
|
||||
params: requestParams ?? {},
|
||||
}),
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`gateway request timeout: ${method}`));
|
||||
}, 10_000);
|
||||
timeout.unref?.();
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
events,
|
||||
async close() {
|
||||
if (ws.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 2_000);
|
||||
timeout.unref?.();
|
||||
ws.once("close", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
ws.close();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isRetryableGatewayConnectError(error: Error): boolean {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("gateway ws open timeout") ||
|
||||
message.includes("gateway connect timeout") ||
|
||||
message.includes("gateway closed") ||
|
||||
message.includes("econnrefused") ||
|
||||
message.includes("socket hang up")
|
||||
);
|
||||
}
|
||||
|
||||
export async function connectMcpClient(params: {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
}): Promise<McpClientHandle> {
|
||||
const tokenDir = "/tmp/openclaw-mcp-client";
|
||||
const tokenFile = `${tokenDir}/gateway.token`;
|
||||
mkdirSync(tokenDir, { recursive: true });
|
||||
writeFileSync(tokenFile, `${params.gatewayToken}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
const transport = new StdioClientTransport({
|
||||
command: "node",
|
||||
args: [
|
||||
"/app/openclaw.mjs",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--url",
|
||||
params.gatewayUrl,
|
||||
"--token-file",
|
||||
tokenFile,
|
||||
"--claude-channel-mode",
|
||||
"on",
|
||||
],
|
||||
cwd: "/app",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
|
||||
OPENCLAW_STATE_DIR: "/tmp/openclaw-mcp-client",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(`[openclaw mcp] ${String(chunk)}`);
|
||||
});
|
||||
const rawMessages: unknown[] = [];
|
||||
// The MCP stdio transport here exposes a writable onmessage callback at
|
||||
// runtime, not an EventTarget-style addEventListener API.
|
||||
// oxlint-disable-next-line unicorn/prefer-add-event-listener
|
||||
transport.onmessage = (message) => {
|
||||
rawMessages.push(message);
|
||||
};
|
||||
|
||||
const client = new Client({ name: "docker-mcp-channels", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
return { client, transport, rawMessages };
|
||||
}
|
||||
|
||||
export async function maybeApprovePendingBridgePairing(
|
||||
gateway: GatewayRpcClient,
|
||||
): Promise<boolean> {
|
||||
let pairingState:
|
||||
| {
|
||||
pending?: Array<{ requestId?: string; role?: string }>;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
pairingState = await gateway.request<{
|
||||
pending?: Array<{ requestId?: string; role?: string }>;
|
||||
}>("device.pair.list", {});
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
if (message.includes("missing scope: operator.pairing")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!pairingState) {
|
||||
return false;
|
||||
}
|
||||
const pendingRequest = pairingState.pending?.find((entry) => entry.role === "operator");
|
||||
if (!pendingRequest?.requestId) {
|
||||
return false;
|
||||
}
|
||||
await gateway.request("device.pair.approve", { requestId: pendingRequest.requestId });
|
||||
return true;
|
||||
}
|
||||
134
openclaw/scripts/e2e/mcp-channels-seed.ts
Normal file
134
openclaw/scripts/e2e/mcp-channels-seed.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
applyProviderConfigWithDefaultModelPreset,
|
||||
type ModelDefinitionConfig,
|
||||
type OpenClawConfig,
|
||||
} from "../../src/plugin-sdk/provider-onboard.ts";
|
||||
|
||||
const DOCKER_OPENAI_MODEL_REF = "openai/gpt-5.4";
|
||||
const DOCKER_OPENAI_MODEL: ModelDefinitionConfig = {
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
api: "openai-responses",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
|
||||
const configPath =
|
||||
process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(stateDir, "openclaw.json");
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const sessionFile = path.join(sessionsDir, "sess-main.jsonl");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
|
||||
const seededConfig = applyProviderConfigWithDefaultModelPreset(
|
||||
{
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
{
|
||||
providerId: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://127.0.0.1:9/v1",
|
||||
defaultModel: DOCKER_OPENAI_MODEL,
|
||||
defaultModelId: DOCKER_OPENAI_MODEL.id,
|
||||
aliases: [{ modelRef: DOCKER_OPENAI_MODEL_REF, alias: "GPT" }],
|
||||
primaryModelRef: DOCKER_OPENAI_MODEL_REF,
|
||||
},
|
||||
);
|
||||
const openAiProvider = seededConfig.models?.providers?.openai;
|
||||
if (!openAiProvider) {
|
||||
throw new Error("failed to seed OpenAI provider config");
|
||||
}
|
||||
openAiProvider.apiKey = "sk-docker-smoke-test";
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(seededConfig, null, 2), "utf-8");
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
lastChannel: "imessage",
|
||||
lastTo: "+15551234567",
|
||||
lastAccountId: "imessage-default",
|
||||
lastThreadId: "thread-42",
|
||||
displayName: "Docker MCP Channel Smoke",
|
||||
derivedTitle: "Docker MCP Channel Smoke",
|
||||
lastMessagePreview: "seeded transcript",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
|
||||
JSON.stringify({
|
||||
id: "msg-1",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello from seeded transcript" }],
|
||||
timestamp: now,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-attachment",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "seeded image attachment" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: "abc",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: now + 1,
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
stateDir,
|
||||
configPath,
|
||||
storePath,
|
||||
sessionFile,
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
585
openclaw/scripts/e2e/onboard-docker.sh
Normal file
585
openclaw/scripts/e2e/onboard-docker.sh
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
IMAGE_NAME="openclaw-onboard-e2e"
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged onboard-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running onboarding E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
|
||||
# tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler.
|
||||
if [ -f dist/index.mjs ]; then
|
||||
OPENCLAW_ENTRY="dist/index.mjs"
|
||||
elif [ -f dist/index.js ]; then
|
||||
OPENCLAW_ENTRY="dist/index.js"
|
||||
else
|
||||
echo "Missing dist/index.(m)js (build output):"
|
||||
ls -la dist || true
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
|
||||
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
|
||||
export PATH="/tmp/openclaw-bin:$PATH"
|
||||
mkdir -p /tmp/openclaw-bin
|
||||
cat > /tmp/openclaw-bin/trash <<'"'"'TRASH'"'"'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trash_dir="$HOME/.Trash"
|
||||
mkdir -p "$trash_dir"
|
||||
for target in "$@"; do
|
||||
[ -e "$target" ] || continue
|
||||
base="$(basename "$target")"
|
||||
dest="$trash_dir/$base"
|
||||
if [ -e "$dest" ]; then
|
||||
dest="$trash_dir/${base}-$(date +%s)-$$"
|
||||
fi
|
||||
mv "$target" "$dest"
|
||||
done
|
||||
TRASH
|
||||
chmod +x /tmp/openclaw-bin/trash
|
||||
|
||||
send() {
|
||||
local payload="$1"
|
||||
local delay="${2:-0.4}"
|
||||
# Let prompts render before sending keystrokes.
|
||||
sleep "$delay"
|
||||
printf "%b" "$payload" >&3 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_log() {
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-45}"
|
||||
local quiet_on_timeout="${3:-false}"
|
||||
local needle_compact
|
||||
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alpha:]")"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
|
||||
import fs from \"node:fs\";
|
||||
const file = process.env.WIZARD_LOG_PATH;
|
||||
const needle = process.env.NEEDLE ?? \"\";
|
||||
let text = \"\";
|
||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||
// Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly.
|
||||
if (text.length > 120000) text = text.slice(-120000);
|
||||
const normalizeScriptOutput = (value) =>
|
||||
value
|
||||
// util-linux script can emit each byte on its own CRLF-delimited line.
|
||||
// Collapse those first so ANSI/control stripping works on real sequences.
|
||||
.replace(/\\r?\\n/g, \"\")
|
||||
.replace(/\\r/g, \"\");
|
||||
const stripAnsi = (value) =>
|
||||
normalizeScriptOutput(value)
|
||||
// OSC: ESC ] ... BEL or ESC \\
|
||||
.replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\")
|
||||
// CSI: ESC [ ... cmd
|
||||
.replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\");
|
||||
// Letters-only: script output sometimes fragments ANSI sequences into digits/letters that
|
||||
// can otherwise break substring matching.
|
||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z]+/g, \"\");
|
||||
const haystack = compact(text);
|
||||
const compactNeedle = compact(needle);
|
||||
if (!compactNeedle) process.exit(1);
|
||||
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
||||
"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ $(( $(date +%s) - start_s )) -ge "$timeout_s" ]; then
|
||||
if [ "$quiet_on_timeout" = "true" ]; then
|
||||
return 1
|
||||
fi
|
||||
echo "Timeout waiting for log: $needle"
|
||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||
tail -n 140 "$WIZARD_LOG_PATH" || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
start_gateway() {
|
||||
node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
||||
GATEWAY_PID="$!"
|
||||
}
|
||||
|
||||
wait_for_gateway() {
|
||||
for _ in $(seq 1 20); do
|
||||
if node --input-type=module -e "
|
||||
import net from 'node:net';
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
}, 500);
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
process.exit(1);
|
||||
});
|
||||
" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
|
||||
if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Gateway failed to start"
|
||||
cat /tmp/gateway-e2e.log || true
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
local gw_pid="$1"
|
||||
if [ -n "$gw_pid" ]; then
|
||||
kill "$gw_pid" 2>/dev/null || true
|
||||
wait "$gw_pid" || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_wizard_cmd() {
|
||||
local case_name="$1"
|
||||
local home_dir="$2"
|
||||
local command="$3"
|
||||
local send_fn="$4"
|
||||
local with_gateway="${5:-false}"
|
||||
local validate_fn="${6:-}"
|
||||
|
||||
echo "== Wizard case: $case_name =="
|
||||
set_isolated_openclaw_env "$home_dir"
|
||||
|
||||
input_fifo="$(mktemp -u "/tmp/openclaw-onboard-${case_name}.XXXXXX")"
|
||||
mkfifo "$input_fifo"
|
||||
local log_path="/tmp/openclaw-onboard-${case_name}.log"
|
||||
WIZARD_LOG_PATH="$log_path"
|
||||
export WIZARD_LOG_PATH
|
||||
# Run under script to keep an interactive TTY for clack prompts.
|
||||
script -q -f -c "$command" "$log_path" < "$input_fifo" >/dev/null 2>&1 &
|
||||
wizard_pid=$!
|
||||
exec 3> "$input_fifo"
|
||||
|
||||
local gw_pid=""
|
||||
if [ "$with_gateway" = "true" ]; then
|
||||
start_gateway
|
||||
gw_pid="$GATEWAY_PID"
|
||||
wait_for_gateway
|
||||
fi
|
||||
|
||||
"$send_fn"
|
||||
|
||||
if ! wait "$wizard_pid"; then
|
||||
wizard_status=$?
|
||||
exec 3>&-
|
||||
rm -f "$input_fifo"
|
||||
stop_gateway "$gw_pid"
|
||||
echo "Wizard exited with status $wizard_status"
|
||||
if [ -f "$log_path" ]; then
|
||||
tail -n 160 "$log_path" || true
|
||||
fi
|
||||
exit "$wizard_status"
|
||||
fi
|
||||
exec 3>&-
|
||||
rm -f "$input_fifo"
|
||||
stop_gateway "$gw_pid"
|
||||
if [ -n "$validate_fn" ]; then
|
||||
"$validate_fn" "$log_path"
|
||||
fi
|
||||
}
|
||||
|
||||
run_wizard() {
|
||||
local case_name="$1"
|
||||
local home_dir="$2"
|
||||
local send_fn="$3"
|
||||
local validate_fn="${4:-}"
|
||||
|
||||
# Default onboarding command wrapper.
|
||||
run_wizard_cmd "$case_name" "$home_dir" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
|
||||
}
|
||||
|
||||
make_home() {
|
||||
mktemp -d "/tmp/openclaw-e2e-$1.XXXXXX"
|
||||
}
|
||||
|
||||
set_isolated_openclaw_env() {
|
||||
local home_dir="$1"
|
||||
export HOME="$home_dir"
|
||||
export OPENCLAW_HOME="$home_dir"
|
||||
export OPENCLAW_STATE_DIR="$home_dir/.openclaw"
|
||||
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
}
|
||||
|
||||
assert_file() {
|
||||
local file_path="$1"
|
||||
if [ ! -f "$file_path" ]; then
|
||||
echo "Missing file: $file_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_dir() {
|
||||
local dir_path="$1"
|
||||
if [ ! -d "$dir_path" ]; then
|
||||
echo "Missing dir: $dir_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_case_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
local log_path="/tmp/openclaw-onboard-${label}.log"
|
||||
if ! "$@" >"$log_path" 2>&1; then
|
||||
cat "$log_path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
select_skip_hooks() {
|
||||
# Hooks multiselect: pick "Skip for now".
|
||||
wait_for_log "Enable hooks?" 60
|
||||
send $'"'"' \r'"'"' 0.6
|
||||
}
|
||||
|
||||
send_local_basic() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 60
|
||||
send $'"'"'y\r'"'"' 0.6
|
||||
# Non-interactive flow; no gateway-location prompt.
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_reset_config_only() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
wait_for_log "Continue?" 40
|
||||
send $'"'"'y\r'"'"' 0.8
|
||||
# Select reset flow for existing config.
|
||||
wait_for_log "Config handling" 40
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
# Reset scope -> Config only (default).
|
||||
wait_for_log "Reset scope" 40
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_channels_flow() {
|
||||
# Configure channels via configure wizard. Use the remove-config branch for
|
||||
# a stable no-op smoke path when the config starts empty.
|
||||
wait_for_log "Where will the Gateway run?" 120
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
wait_for_log "Configure/link" 120
|
||||
send $'"'"'\e[B\r'"'"' 0.8
|
||||
# Keep stdin open until wizard exits.
|
||||
send "" 2.0
|
||||
}
|
||||
|
||||
send_skills_flow() {
|
||||
# configure --section skills still runs the configure wizard.
|
||||
wait_for_log "Where will the Gateway run?" 120
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
wait_for_log "Configure skills now?" 120
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
send "" 2.0
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
local home_dir
|
||||
home_dir="$(make_home local-basic)"
|
||||
set_isolated_openclaw_env "$home_dir"
|
||||
run_case_logged local-basic node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
|
||||
# Assert config + workspace scaffolding.
|
||||
workspace_dir="$OPENCLAW_STATE_DIR/workspace"
|
||||
config_path="$OPENCLAW_CONFIG_PATH"
|
||||
sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions"
|
||||
|
||||
assert_file "$config_path"
|
||||
assert_dir "$sessions_dir"
|
||||
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
|
||||
assert_file "$workspace_dir/$file"
|
||||
done
|
||||
|
||||
CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"'
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
|
||||
const expectedWorkspace = process.env.WORKSPACE_DIR;
|
||||
const errors = [];
|
||||
|
||||
if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) {
|
||||
errors.push(
|
||||
`agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
if (cfg?.gateway?.mode !== "local") {
|
||||
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.gateway?.bind !== "loopback") {
|
||||
errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`);
|
||||
}
|
||||
if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") {
|
||||
errors.push(
|
||||
`gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
if (!cfg?.wizard?.lastRunAt) {
|
||||
errors.push("wizard.lastRunAt missing");
|
||||
}
|
||||
if (!cfg?.wizard?.lastRunVersion) {
|
||||
errors.push("wizard.lastRunVersion missing");
|
||||
}
|
||||
if (cfg?.wizard?.lastRunCommand !== "onboard") {
|
||||
errors.push(
|
||||
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
if (cfg?.wizard?.lastRunMode !== "local") {
|
||||
errors.push(
|
||||
`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
}
|
||||
|
||||
run_case_remote_non_interactive() {
|
||||
local home_dir
|
||||
home_dir="$(make_home remote-non-interactive)"
|
||||
set_isolated_openclaw_env "$home_dir"
|
||||
# Smoke test non-interactive remote config write.
|
||||
run_case_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
|
||||
--mode remote \
|
||||
--remote-url ws://gateway.local:18789 \
|
||||
--remote-token remote-token \
|
||||
--skip-skills \
|
||||
--skip-health
|
||||
|
||||
config_path="$OPENCLAW_CONFIG_PATH"
|
||||
assert_file "$config_path"
|
||||
|
||||
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
|
||||
const errors = [];
|
||||
|
||||
if (cfg?.gateway?.mode !== "remote") {
|
||||
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.gateway?.remote?.url !== "ws://gateway.local:18789") {
|
||||
errors.push(`gateway.remote.url mismatch (got ${cfg?.gateway?.remote?.url ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.gateway?.remote?.token !== "remote-token") {
|
||||
errors.push(`gateway.remote.token mismatch (got ${cfg?.gateway?.remote?.token ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.wizard?.lastRunMode !== "remote") {
|
||||
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
run_case_reset() {
|
||||
local home_dir
|
||||
home_dir="$(make_home reset-config)"
|
||||
set_isolated_openclaw_env "$home_dir"
|
||||
# Seed a remote config to exercise reset path.
|
||||
cat > "$OPENCLAW_CONFIG_PATH" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"meta": {},
|
||||
"agents": { "defaults": { "workspace": "/root/old" } },
|
||||
"gateway": {
|
||||
"mode": "remote",
|
||||
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
run_case_logged reset-config node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--reset \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-health
|
||||
|
||||
config_path="$OPENCLAW_CONFIG_PATH"
|
||||
assert_file "$config_path"
|
||||
|
||||
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
|
||||
const errors = [];
|
||||
|
||||
if (cfg?.gateway?.mode !== "local") {
|
||||
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.gateway?.remote?.url) {
|
||||
errors.push(`gateway.remote.url should be cleared (got ${cfg?.gateway?.remote?.url})`);
|
||||
}
|
||||
if (cfg?.wizard?.lastRunMode !== "local") {
|
||||
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
run_case_channels() {
|
||||
local home_dir
|
||||
home_dir="$(make_home channels)"
|
||||
# Channels-only configure flow.
|
||||
run_wizard_cmd channels "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
|
||||
|
||||
config_path="$OPENCLAW_CONFIG_PATH"
|
||||
assert_file "$config_path"
|
||||
|
||||
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
|
||||
const errors = [];
|
||||
|
||||
if (cfg?.telegram?.botToken) {
|
||||
errors.push(`telegram.botToken should be unset (got ${cfg?.telegram?.botToken})`);
|
||||
}
|
||||
if (cfg?.discord?.token) {
|
||||
errors.push(`discord.token should be unset (got ${cfg?.discord?.token})`);
|
||||
}
|
||||
if (cfg?.slack?.botToken || cfg?.slack?.appToken) {
|
||||
errors.push(
|
||||
`slack tokens should be unset (got bot=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
if (cfg?.wizard?.lastRunCommand !== "configure") {
|
||||
errors.push(
|
||||
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
run_case_skills() {
|
||||
local home_dir
|
||||
home_dir="$(make_home skills)"
|
||||
set_isolated_openclaw_env "$home_dir"
|
||||
# Seed skills config to ensure it survives the wizard.
|
||||
cat > "$OPENCLAW_CONFIG_PATH" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"meta": {},
|
||||
"skills": {
|
||||
"allowBundled": ["__none__"],
|
||||
"install": { "nodeManager": "bun" }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
|
||||
|
||||
config_path="$OPENCLAW_CONFIG_PATH"
|
||||
assert_file "$config_path"
|
||||
|
||||
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
|
||||
const errors = [];
|
||||
|
||||
if (cfg?.skills?.install?.nodeManager !== "bun") {
|
||||
errors.push(`skills.install.nodeManager mismatch (got ${cfg?.skills?.install?.nodeManager ?? "unset"})`);
|
||||
}
|
||||
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
|
||||
errors.push("skills.allowBundled missing");
|
||||
}
|
||||
if (cfg?.wizard?.lastRunMode !== "local") {
|
||||
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
assert_log_not_contains() {
|
||||
local file_path="$1"
|
||||
local needle="$2"
|
||||
if grep -q "$needle" "$file_path"; then
|
||||
echo "Unexpected log output: $needle"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_local_basic_log() {
|
||||
local log_path="$1"
|
||||
assert_log_not_contains "$log_path" "systemctl --user unavailable"
|
||||
}
|
||||
|
||||
run_case_local_basic
|
||||
run_case_remote_non_interactive
|
||||
run_case_reset
|
||||
run_case_channels
|
||||
run_case_skills
|
||||
'
|
||||
|
||||
echo "E2E complete."
|
||||
199
openclaw/scripts/e2e/openwebui-docker.sh
Normal file
199
openclaw/scripts/e2e/openwebui-docker.sh
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
|
||||
IMAGE_NAME="openclaw-openwebui-e2e"
|
||||
OPENWEBUI_IMAGE="${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.8.10}"
|
||||
# Keep the default on a broadly available non-reasoning OpenAI model for
|
||||
# Open WebUI compatibility smoke. Callers can still override this explicitly.
|
||||
MODEL="${OPENCLAW_OPENWEBUI_MODEL:-openai/gpt-4.1-mini}"
|
||||
PROMPT_NONCE="OPENWEBUI_DOCKER_E2E_$(date +%s)_$$"
|
||||
PROMPT="${OPENCLAW_OPENWEBUI_PROMPT:-Reply with exactly this token and nothing else: ${PROMPT_NONCE}}"
|
||||
PORT="${OPENCLAW_OPENWEBUI_GATEWAY_PORT:-18789}"
|
||||
WEBUI_PORT="${OPENCLAW_OPENWEBUI_PORT:-8080}"
|
||||
TOKEN="openwebui-e2e-$(date +%s)-$$"
|
||||
ADMIN_EMAIL="${OPENCLAW_OPENWEBUI_ADMIN_EMAIL:-openwebui-e2e@example.com}"
|
||||
ADMIN_PASSWORD="${OPENCLAW_OPENWEBUI_ADMIN_PASSWORD:-OpenWebUI-E2E-Password-$(date +%s)-$$}"
|
||||
NET_NAME="openclaw-openwebui-e2e-$$"
|
||||
GW_NAME="openclaw-openwebui-gateway-$$"
|
||||
OW_NAME="openclaw-openwebui-$$"
|
||||
|
||||
OPENAI_API_KEY_VALUE="${OPENAI_API_KEY:-}"
|
||||
if [[ "$OPENAI_API_KEY_VALUE" == "undefined" || "$OPENAI_API_KEY_VALUE" == "null" ]]; then
|
||||
OPENAI_API_KEY_VALUE=""
|
||||
fi
|
||||
OPENAI_BASE_URL_VALUE="${OPENAI_BASE_URL:-}"
|
||||
if [[ "$OPENAI_BASE_URL_VALUE" == "undefined" || "$OPENAI_BASE_URL_VALUE" == "null" ]]; then
|
||||
OPENAI_BASE_URL_VALUE=""
|
||||
fi
|
||||
if [[ -z "$OPENAI_API_KEY_VALUE" ]]; then
|
||||
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$OW_NAME" >/dev/null 2>&1 || true
|
||||
docker rm -f "$GW_NAME" >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged openwebui-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE"
|
||||
docker pull "$OPENWEBUI_IMAGE" >/dev/null
|
||||
|
||||
echo "Creating Docker network..."
|
||||
docker network create "$NET_NAME" >/dev/null
|
||||
|
||||
echo "Starting gateway container..."
|
||||
docker run -d \
|
||||
--name "$GW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_OPENWEBUI_MODEL=$MODEL" \
|
||||
-e OPENAI_API_KEY \
|
||||
${OPENAI_BASE_URL_VALUE:+-e OPENAI_BASE_URL} \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
entry=dist/index.mjs
|
||||
[ -f "$entry" ] || entry=dist/index.js
|
||||
|
||||
openai_api_key="${OPENAI_API_KEY:?OPENAI_API_KEY required}"
|
||||
batch_file="$(mktemp /tmp/openclaw-openwebui-config.XXXXXX.json)"
|
||||
OPENCLAW_CONFIG_BATCH_PATH="$batch_file" node - <<'"'"'NODE'"'"' "$openai_api_key"
|
||||
const fs = require("node:fs");
|
||||
|
||||
const openaiApiKey = process.argv[2];
|
||||
const batchPath = process.env.OPENCLAW_CONFIG_BATCH_PATH;
|
||||
const entries = [
|
||||
{ path: "models.providers.openai.apiKey", value: openaiApiKey },
|
||||
{
|
||||
path: "models.providers.openai.baseUrl",
|
||||
value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
|
||||
},
|
||||
{ path: "models.providers.openai.models", value: [] },
|
||||
{ path: "gateway.controlUi.enabled", value: false },
|
||||
{ path: "gateway.mode", value: "local" },
|
||||
{ path: "gateway.bind", value: "lan" },
|
||||
{ path: "gateway.auth.mode", value: "token" },
|
||||
{ path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN },
|
||||
{ path: "gateway.http.endpoints.chatCompletions.enabled", value: true },
|
||||
{ path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL },
|
||||
];
|
||||
fs.writeFileSync(batchPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
|
||||
NODE
|
||||
node "$entry" config set --batch-file "$batch_file" >/dev/null
|
||||
rm -f "$batch_file"
|
||||
|
||||
workspace="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
|
||||
mkdir -p "$workspace/.openclaw"
|
||||
cat > "$workspace/IDENTITY.md" <<'"'"'EOF'"'"'
|
||||
# Identity
|
||||
|
||||
- Name: OpenClaw
|
||||
- Purpose: Open WebUI Docker compatibility smoke test assistant.
|
||||
EOF
|
||||
cat > "$workspace/.openclaw/workspace-state.json" <<'"'"'EOF'"'"'
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
EOF
|
||||
rm -f "$workspace/BOOTSTRAP.md"
|
||||
|
||||
exec node "$entry" gateway --port '"$PORT"' --bind lan --allow-unconfigured > /tmp/openwebui-gateway.log 2>&1
|
||||
' >/dev/null
|
||||
|
||||
echo "Waiting for gateway HTTP surface..."
|
||||
gateway_ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
|
||||
const res = await fetch(\"http://127.0.0.1:$PORT/v1/models\", {
|
||||
headers: { authorization: \"Bearer $TOKEN\" },
|
||||
}).catch(() => null);
|
||||
process.exit(res?.status === 200 ? 0 : 1);
|
||||
' >/dev/null 2>&1"; then
|
||||
gateway_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$gateway_ready" -ne 1 ]; then
|
||||
echo "Gateway failed to start"
|
||||
docker logs "$GW_NAME" 2>&1 | tail -n 200 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Open WebUI container..."
|
||||
docker run -d \
|
||||
--name "$OW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e ENV=prod \
|
||||
-e WEBUI_NAME="OpenClaw E2E" \
|
||||
-e WEBUI_SECRET_KEY="openclaw-openwebui-e2e-secret" \
|
||||
-e OFFLINE_MODE=True \
|
||||
-e ENABLE_VERSION_UPDATE_CHECK=False \
|
||||
-e ENABLE_PERSISTENT_CONFIG=False \
|
||||
-e ENABLE_OLLAMA_API=False \
|
||||
-e ENABLE_OPENAI_API=True \
|
||||
-e OPENAI_API_BASE_URLS="http://$GW_NAME:$PORT/v1" \
|
||||
-e OPENAI_API_KEY="$TOKEN" \
|
||||
-e OPENAI_API_KEYS="$TOKEN" \
|
||||
-e RAG_EMBEDDING_MODEL_AUTO_UPDATE=False \
|
||||
-e RAG_RERANKING_MODEL_AUTO_UPDATE=False \
|
||||
-e WEBUI_ADMIN_EMAIL="$ADMIN_EMAIL" \
|
||||
-e WEBUI_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
|
||||
-e WEBUI_ADMIN_NAME="OpenClaw E2E" \
|
||||
-e ENABLE_SIGNUP=False \
|
||||
-e DEFAULT_MODELS="openclaw/default" \
|
||||
"$OPENWEBUI_IMAGE" >/dev/null
|
||||
|
||||
echo "Waiting for Open WebUI..."
|
||||
ow_ready=0
|
||||
for _ in $(seq 1 90); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$OW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
|
||||
const res = await fetch(\"http://$OW_NAME:$WEBUI_PORT/\").catch(() => null);
|
||||
process.exit(res && res.status < 500 ? 0 : 1);
|
||||
' >/dev/null 2>&1"; then
|
||||
ow_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ow_ready" -ne 1 ]; then
|
||||
echo "Open WebUI failed to start"
|
||||
docker logs "$OW_NAME" 2>&1 | tail -n 200 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Open WebUI -> OpenClaw smoke..."
|
||||
if ! docker exec \
|
||||
-e "OPENWEBUI_BASE_URL=http://$OW_NAME:$WEBUI_PORT" \
|
||||
-e "OPENWEBUI_ADMIN_EMAIL=$ADMIN_EMAIL" \
|
||||
-e "OPENWEBUI_ADMIN_PASSWORD=$ADMIN_PASSWORD" \
|
||||
-e "OPENWEBUI_EXPECTED_NONCE=$PROMPT_NONCE" \
|
||||
-e "OPENWEBUI_PROMPT=$PROMPT" \
|
||||
"$GW_NAME" \
|
||||
node /app/scripts/e2e/openwebui-probe.mjs >/tmp/openwebui-probe.log 2>&1; then
|
||||
cat /tmp/openwebui-probe.log 2>/dev/null || true
|
||||
echo "Open WebUI probe failed; gateway log tail:"
|
||||
docker exec "$GW_NAME" bash -lc 'tail -n 200 /tmp/openwebui-gateway.log' || true
|
||||
echo "Open WebUI container logs:"
|
||||
docker logs "$OW_NAME" 2>&1 | tail -n 200 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK"
|
||||
95
openclaw/scripts/e2e/openwebui-probe.mjs
Normal file
95
openclaw/scripts/e2e/openwebui-probe.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const baseUrl = process.env.OPENWEBUI_BASE_URL ?? "";
|
||||
const email = process.env.OPENWEBUI_ADMIN_EMAIL ?? "";
|
||||
const password = process.env.OPENWEBUI_ADMIN_PASSWORD ?? "";
|
||||
const expectedNonce = process.env.OPENWEBUI_EXPECTED_NONCE ?? "";
|
||||
const prompt = process.env.OPENWEBUI_PROMPT ?? "";
|
||||
|
||||
if (!baseUrl || !email || !password || !expectedNonce || !prompt) {
|
||||
throw new Error("Missing required OPENWEBUI_* environment variables");
|
||||
}
|
||||
|
||||
function getCookieHeader(res) {
|
||||
const raw = res.headers.get("set-cookie");
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.split(/,(?=[^;]+=[^;]+)/g)
|
||||
.map((part) => part.split(";", 1)[0]?.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function buildAuthHeaders(token, cookie) {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (cookie) {
|
||||
headers.cookie = cookie;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
const signinRes = await fetch(`${baseUrl}/api/v1/auths/signin`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!signinRes.ok) {
|
||||
const body = await signinRes.text();
|
||||
throw new Error(`signin failed: HTTP ${signinRes.status} ${body}`);
|
||||
}
|
||||
|
||||
const signinJson = await signinRes.json();
|
||||
const token =
|
||||
signinJson?.token ?? signinJson?.access_token ?? signinJson?.jwt ?? signinJson?.data?.token ?? "";
|
||||
const cookie = getCookieHeader(signinRes);
|
||||
const authHeaders = {
|
||||
...buildAuthHeaders(token, cookie),
|
||||
accept: "application/json",
|
||||
};
|
||||
|
||||
const modelsRes = await fetch(`${baseUrl}/api/models`, { headers: authHeaders });
|
||||
if (!modelsRes.ok) {
|
||||
throw new Error(`/api/models failed: HTTP ${modelsRes.status} ${await modelsRes.text()}`);
|
||||
}
|
||||
const modelsJson = await modelsRes.json();
|
||||
const models = Array.isArray(modelsJson)
|
||||
? modelsJson
|
||||
: Array.isArray(modelsJson?.data)
|
||||
? modelsJson.data
|
||||
: Array.isArray(modelsJson?.models)
|
||||
? modelsJson.models
|
||||
: [];
|
||||
const modelIds = models
|
||||
.map((entry) => entry?.id ?? entry?.model ?? entry?.name)
|
||||
.filter((value) => typeof value === "string");
|
||||
const targetModel =
|
||||
modelIds.find((id) => id === "openclaw/default") ?? modelIds.find((id) => id === "openclaw");
|
||||
if (!targetModel) {
|
||||
throw new Error(`openclaw model missing from Open WebUI model list: ${JSON.stringify(modelIds)}`);
|
||||
}
|
||||
|
||||
const chatRes = await fetch(`${baseUrl}/api/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: targetModel,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!chatRes.ok) {
|
||||
throw new Error(`/api/chat/completions failed: HTTP ${chatRes.status} ${await chatRes.text()}`);
|
||||
}
|
||||
const chatJson = await chatRes.json();
|
||||
const reply =
|
||||
chatJson?.choices?.[0]?.message?.content ?? chatJson?.message?.content ?? chatJson?.content ?? "";
|
||||
if (typeof reply !== "string" || !reply.includes(expectedNonce)) {
|
||||
throw new Error(`chat reply missing nonce: ${JSON.stringify(reply)}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, model: targetModel, reply }, null, 2));
|
||||
989
openclaw/scripts/e2e/parallels-linux-smoke.sh
Normal file
989
openclaw/scripts/e2e/parallels-linux-smoke.sh
Normal file
|
|
@ -0,0 +1,989 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VM_NAME="Ubuntu 24.04.3 ARM64"
|
||||
VM_NAME_EXPLICIT=0
|
||||
SNAPSHOT_HINT="fresh"
|
||||
MODE="both"
|
||||
PROVIDER="openai"
|
||||
API_KEY_ENV=""
|
||||
AUTH_CHOICE=""
|
||||
AUTH_KEY_FLAG=""
|
||||
MODEL_ID=""
|
||||
INSTALL_URL="https://openclaw.ai/install.sh"
|
||||
HOST_PORT="18427"
|
||||
HOST_PORT_EXPLICIT=0
|
||||
HOST_IP=""
|
||||
LATEST_VERSION=""
|
||||
INSTALL_VERSION=""
|
||||
TARGET_PACKAGE_SPEC=""
|
||||
JSON_OUTPUT=0
|
||||
KEEP_SERVER=0
|
||||
SNAPSHOT_ID=""
|
||||
SNAPSHOT_STATE=""
|
||||
SNAPSHOT_NAME=""
|
||||
PACKED_MAIN_COMMIT_SHORT=""
|
||||
|
||||
MAIN_TGZ_DIR="$(mktemp -d)"
|
||||
MAIN_TGZ_PATH=""
|
||||
SERVER_PID=""
|
||||
RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-linux.XXXXXX)"
|
||||
BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
|
||||
|
||||
TIMEOUT_SNAPSHOT_S=180
|
||||
TIMEOUT_BOOTSTRAP_S=600
|
||||
TIMEOUT_INSTALL_S=420
|
||||
TIMEOUT_VERIFY_S=90
|
||||
TIMEOUT_ONBOARD_S=180
|
||||
TIMEOUT_AGENT_S=180
|
||||
TIMEOUT_GATEWAY_S=90
|
||||
|
||||
FRESH_MAIN_STATUS="skip"
|
||||
FRESH_MAIN_VERSION="skip"
|
||||
FRESH_GATEWAY_STATUS="skip"
|
||||
FRESH_AGENT_STATUS="skip"
|
||||
UPGRADE_STATUS="skip"
|
||||
LATEST_INSTALLED_VERSION="skip"
|
||||
UPGRADE_MAIN_VERSION="skip"
|
||||
UPGRADE_GATEWAY_STATUS="skip"
|
||||
UPGRADE_AGENT_STATUS="skip"
|
||||
DAEMON_STATUS="systemd-user-unavailable"
|
||||
|
||||
say() {
|
||||
printf '==> %s\n' "$*"
|
||||
}
|
||||
|
||||
artifact_label() {
|
||||
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
|
||||
printf 'target package tgz'
|
||||
return
|
||||
fi
|
||||
printf 'current main tgz'
|
||||
}
|
||||
|
||||
extract_package_build_commit_from_tgz() {
|
||||
tar -xOf "$1" package/dist/build-info.json | python3 -c 'import json, sys; print(json.load(sys.stdin).get("commit", ""))'
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warn: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$MAIN_TGZ_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
shell_quote() {
|
||||
local value="$1"
|
||||
printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\"'\"'/g")"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash scripts/e2e/parallels-linux-smoke.sh [options]
|
||||
|
||||
Options:
|
||||
--vm <name> Parallels VM name. Default: "Ubuntu 24.04.3 ARM64"
|
||||
Falls back to the closest Ubuntu VM when omitted and unavailable.
|
||||
--snapshot-hint <name> Snapshot name substring/fuzzy match. Default: "fresh"
|
||||
--mode <fresh|upgrade|both>
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.sh
|
||||
--host-port <port> Host HTTP port for current-main tgz. Default: 18427
|
||||
--host-ip <ip> Override Parallels host IP.
|
||||
--latest-version <ver> Override npm latest version lookup.
|
||||
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
|
||||
--target-package-spec <npm-spec>
|
||||
Install this npm package tarball instead of packing current main.
|
||||
Example: openclaw@2026.3.13-beta.1
|
||||
--keep-server Leave temp host HTTP server running.
|
||||
--json Print machine-readable JSON summary.
|
||||
-h, --help Show help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--)
|
||||
shift
|
||||
;;
|
||||
--vm)
|
||||
VM_NAME="$2"
|
||||
VM_NAME_EXPLICIT=1
|
||||
shift 2
|
||||
;;
|
||||
--snapshot-hint)
|
||||
SNAPSHOT_HINT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--provider)
|
||||
PROVIDER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--api-key-env|--openai-api-key-env)
|
||||
API_KEY_ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-url)
|
||||
INSTALL_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host-port)
|
||||
HOST_PORT="$2"
|
||||
HOST_PORT_EXPLICIT=1
|
||||
shift 2
|
||||
;;
|
||||
--host-ip)
|
||||
HOST_IP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--latest-version)
|
||||
LATEST_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-version)
|
||||
INSTALL_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target-package-spec)
|
||||
TARGET_PACKAGE_SPEC="$2"
|
||||
shift 2
|
||||
;;
|
||||
--keep-server)
|
||||
KEEP_SERVER=1
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown arg: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$MODE" in
|
||||
fresh|upgrade|both) ;;
|
||||
*)
|
||||
die "invalid --mode: $MODE"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$PROVIDER" in
|
||||
openai)
|
||||
AUTH_CHOICE="openai-api-key"
|
||||
AUTH_KEY_FLAG="openai-api-key"
|
||||
MODEL_ID="openai/gpt-5.4"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
|
||||
;;
|
||||
anthropic)
|
||||
AUTH_CHOICE="apiKey"
|
||||
AUTH_KEY_FLAG="anthropic-api-key"
|
||||
MODEL_ID="anthropic/claude-sonnet-4-6"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY"
|
||||
;;
|
||||
minimax)
|
||||
AUTH_CHOICE="minimax-global-api"
|
||||
AUTH_KEY_FLAG="minimax-api-key"
|
||||
MODEL_ID="minimax/MiniMax-M2.7"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY"
|
||||
;;
|
||||
*)
|
||||
die "invalid --provider: $PROVIDER"
|
||||
;;
|
||||
esac
|
||||
|
||||
API_KEY_VALUE="${!API_KEY_ENV:-}"
|
||||
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
|
||||
|
||||
resolve_vm_name() {
|
||||
local json requested explicit
|
||||
json="$(prlctl list --all --json)"
|
||||
requested="$VM_NAME"
|
||||
explicit="$VM_NAME_EXPLICIT"
|
||||
PRL_VM_JSON="$json" REQUESTED_VM_NAME="$requested" VM_NAME_EXPLICIT="$explicit" python3 - <<'PY'
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
payload = json.loads(os.environ["PRL_VM_JSON"])
|
||||
requested = os.environ["REQUESTED_VM_NAME"].strip()
|
||||
requested_lower = requested.lower()
|
||||
explicit = os.environ["VM_NAME_EXPLICIT"] == "1"
|
||||
names = [str(item.get("name", "")).strip() for item in payload if str(item.get("name", "")).strip()]
|
||||
|
||||
def parse_ubuntu_version(name: str) -> tuple[int, ...] | None:
|
||||
match = re.search(r"ubuntu\s+(\d+(?:\.\d+)*)", name, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
|
||||
def version_distance(version: tuple[int, ...], target: tuple[int, ...]) -> tuple[int, ...]:
|
||||
width = max(len(version), len(target))
|
||||
padded_version = version + (0,) * (width - len(version))
|
||||
padded_target = target + (0,) * (width - len(target))
|
||||
return tuple(abs(a - b) for a, b in zip(padded_version, padded_target))
|
||||
|
||||
if requested in names:
|
||||
print(requested)
|
||||
raise SystemExit(0)
|
||||
|
||||
if explicit:
|
||||
sys.exit(f"vm not found: {requested}")
|
||||
|
||||
ubuntu_names = [name for name in names if "ubuntu" in name.lower()]
|
||||
if not ubuntu_names:
|
||||
sys.exit(f"default vm not found and no Ubuntu fallback available: {requested}")
|
||||
|
||||
requested_version = parse_ubuntu_version(requested) or (24,)
|
||||
ubuntu_with_versions = [
|
||||
(name, parse_ubuntu_version(name)) for name in ubuntu_names
|
||||
]
|
||||
ubuntu_ge_24 = [
|
||||
(name, version)
|
||||
for name, version in ubuntu_with_versions
|
||||
if version and version[0] >= 24
|
||||
]
|
||||
if ubuntu_ge_24:
|
||||
best_name = min(
|
||||
ubuntu_ge_24,
|
||||
key=lambda item: (
|
||||
version_distance(item[1], requested_version),
|
||||
-len(item[1]),
|
||||
item[0].lower(),
|
||||
),
|
||||
)[0]
|
||||
print(best_name)
|
||||
raise SystemExit(0)
|
||||
|
||||
best_name = max(
|
||||
ubuntu_names,
|
||||
key=lambda name: difflib.SequenceMatcher(None, requested_lower, name.lower()).ratio(),
|
||||
)
|
||||
print(best_name)
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_snapshot_info() {
|
||||
local json hint
|
||||
json="$(prlctl snapshot-list "$VM_NAME" --json)"
|
||||
hint="$SNAPSHOT_HINT"
|
||||
SNAPSHOT_JSON="$json" SNAPSHOT_HINT="$hint" python3 - <<'PY'
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
payload = json.loads(os.environ["SNAPSHOT_JSON"])
|
||||
hint = os.environ["SNAPSHOT_HINT"].strip().lower()
|
||||
best_id = None
|
||||
best_meta = None
|
||||
best_score = -1.0
|
||||
|
||||
def aliases(name: str) -> list[str]:
|
||||
values = [name]
|
||||
for pattern in (
|
||||
r"^(.*)-poweroff$",
|
||||
r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$",
|
||||
):
|
||||
match = re.match(pattern, name)
|
||||
if match:
|
||||
values.append(match.group(1))
|
||||
return values
|
||||
|
||||
for snapshot_id, meta in payload.items():
|
||||
name = str(meta.get("name", "")).strip()
|
||||
lowered = name.lower()
|
||||
score = 0.0
|
||||
for alias in aliases(lowered):
|
||||
if alias == hint:
|
||||
score = max(score, 10.0)
|
||||
elif hint and hint in alias:
|
||||
score = max(score, 5.0 + len(hint) / max(len(alias), 1))
|
||||
else:
|
||||
score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio())
|
||||
if str(meta.get("state", "")).lower() == "poweroff":
|
||||
score += 0.5
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_id = snapshot_id
|
||||
best_meta = meta
|
||||
if not best_id:
|
||||
sys.exit("no snapshot matched")
|
||||
print(
|
||||
"\t".join(
|
||||
[
|
||||
best_id,
|
||||
str(best_meta.get("state", "")).strip(),
|
||||
str(best_meta.get("name", "")).strip(),
|
||||
]
|
||||
)
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_host_ip() {
|
||||
if [[ -n "$HOST_IP" ]]; then
|
||||
printf '%s\n' "$HOST_IP"
|
||||
return
|
||||
fi
|
||||
local detected
|
||||
detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')"
|
||||
[[ -n "$detected" ]] || die "failed to detect Parallels host IP; pass --host-ip"
|
||||
printf '%s\n' "$detected"
|
||||
}
|
||||
|
||||
is_host_port_free() {
|
||||
local port="$1"
|
||||
python3 - "$port" <<'PY'
|
||||
import socket
|
||||
import sys
|
||||
|
||||
sock = socket.socket()
|
||||
try:
|
||||
sock.bind(("0.0.0.0", int(sys.argv[1])))
|
||||
except OSError:
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
PY
|
||||
}
|
||||
|
||||
allocate_host_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("0.0.0.0", 0))
|
||||
print(sock.getsockname()[1])
|
||||
sock.close()
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_host_port() {
|
||||
if is_host_port_free "$HOST_PORT"; then
|
||||
printf '%s\n' "$HOST_PORT"
|
||||
return
|
||||
fi
|
||||
if [[ "$HOST_PORT_EXPLICIT" -eq 1 ]]; then
|
||||
die "host port $HOST_PORT already in use"
|
||||
fi
|
||||
HOST_PORT="$(allocate_host_port)"
|
||||
warn "host port 18427 busy; using $HOST_PORT"
|
||||
printf '%s\n' "$HOST_PORT"
|
||||
}
|
||||
|
||||
guest_exec() {
|
||||
prlctl exec "$VM_NAME" /usr/bin/env HOME=/root "$@"
|
||||
}
|
||||
|
||||
guest_bash_script() {
|
||||
local encoded
|
||||
encoded="$(base64 | tr -d '\n')"
|
||||
guest_exec bash -lc "printf '%s' '$encoded' | base64 -d | bash"
|
||||
}
|
||||
|
||||
wait_for_vm_status() {
|
||||
local expected="$1"
|
||||
local deadline status
|
||||
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
|
||||
while (( SECONDS < deadline )); do
|
||||
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
|
||||
if [[ "$status" == *" $expected" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_guest_ready() {
|
||||
local deadline
|
||||
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
|
||||
while (( SECONDS < deadline )); do
|
||||
if guest_exec /bin/true >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
restore_snapshot() {
|
||||
local snapshot_id="$1"
|
||||
say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)"
|
||||
prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null
|
||||
if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then
|
||||
wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME"
|
||||
say "Start restored poweroff snapshot $SNAPSHOT_NAME"
|
||||
prlctl start "$VM_NAME" >/dev/null
|
||||
fi
|
||||
wait_for_guest_ready || die "guest did not become ready in $VM_NAME"
|
||||
}
|
||||
|
||||
bootstrap_guest() {
|
||||
guest_exec apt-get -o Acquire::Check-Date=false update
|
||||
guest_exec apt-get install -y curl ca-certificates
|
||||
}
|
||||
|
||||
resolve_latest_version() {
|
||||
if [[ -n "$LATEST_VERSION" ]]; then
|
||||
printf '%s\n' "$LATEST_VERSION"
|
||||
return
|
||||
fi
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
}
|
||||
|
||||
current_build_commit() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
path = pathlib.Path("dist/build-info.json")
|
||||
if not path.exists():
|
||||
print("")
|
||||
else:
|
||||
print(json.loads(path.read_text()).get("commit", ""))
|
||||
PY
|
||||
}
|
||||
|
||||
acquire_build_lock() {
|
||||
local owner_pid=""
|
||||
while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
|
||||
if [[ -f "$BUILD_LOCK_DIR/pid" ]]; then
|
||||
owner_pid="$(cat "$BUILD_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then
|
||||
warn "Removing stale Parallels build lock"
|
||||
rm -rf "$BUILD_LOCK_DIR"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
printf '%s\n' "$$" >"$BUILD_LOCK_DIR/pid"
|
||||
}
|
||||
|
||||
release_build_lock() {
|
||||
if [[ -d "$BUILD_LOCK_DIR" ]]; then
|
||||
rm -rf "$BUILD_LOCK_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_current_build() {
|
||||
local head build_commit
|
||||
acquire_build_lock
|
||||
head="$(git rev-parse HEAD)"
|
||||
build_commit="$(current_build_commit)"
|
||||
if [[ "$build_commit" == "$head" ]]; then
|
||||
release_build_lock
|
||||
return
|
||||
fi
|
||||
say "Build dist for current head"
|
||||
pnpm build
|
||||
build_commit="$(current_build_commit)"
|
||||
release_build_lock
|
||||
[[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build"
|
||||
}
|
||||
|
||||
extract_package_version_from_tgz() {
|
||||
tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
|
||||
}
|
||||
|
||||
pack_main_tgz() {
|
||||
local short_head pkg packed_commit
|
||||
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
|
||||
say "Pack target package tgz: $TARGET_PACKAGE_SPEC"
|
||||
pkg="$(
|
||||
npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
|
||||
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
|
||||
)"
|
||||
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")"
|
||||
TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")"
|
||||
say "Packed $MAIN_TGZ_PATH"
|
||||
say "Target package version: $TARGET_EXPECT_VERSION"
|
||||
return
|
||||
fi
|
||||
say "Pack current main tgz"
|
||||
ensure_current_build
|
||||
short_head="$(git rev-parse --short HEAD)"
|
||||
pkg="$(
|
||||
npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
|
||||
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
|
||||
)"
|
||||
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz"
|
||||
cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH"
|
||||
packed_commit="$(extract_package_build_commit_from_tgz "$MAIN_TGZ_PATH")"
|
||||
[[ -n "$packed_commit" ]] || die "failed to read packed build commit from $MAIN_TGZ_PATH"
|
||||
PACKED_MAIN_COMMIT_SHORT="${packed_commit:0:7}"
|
||||
say "Packed $MAIN_TGZ_PATH"
|
||||
tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json
|
||||
}
|
||||
|
||||
verify_target_version() {
|
||||
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
|
||||
verify_version_contains "$TARGET_EXPECT_VERSION"
|
||||
return
|
||||
fi
|
||||
[[ -n "$PACKED_MAIN_COMMIT_SHORT" ]] || die "packed main commit not captured"
|
||||
verify_version_contains "$PACKED_MAIN_COMMIT_SHORT"
|
||||
}
|
||||
|
||||
start_server() {
|
||||
local host_ip="$1"
|
||||
local artifact probe_url attempt
|
||||
artifact="$(basename "$MAIN_TGZ_PATH")"
|
||||
attempt=0
|
||||
while :; do
|
||||
attempt=$((attempt + 1))
|
||||
say "Serve $(artifact_label) on $host_ip:$HOST_PORT"
|
||||
(
|
||||
cd "$MAIN_TGZ_DIR"
|
||||
exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0
|
||||
) >/tmp/openclaw-parallels-linux-http.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
probe_url="http://127.0.0.1:$HOST_PORT/$artifact"
|
||||
if kill -0 "$SERVER_PID" >/dev/null 2>&1 && curl -fsSI "$probe_url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
kill "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
wait "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
SERVER_PID=""
|
||||
if [[ "$HOST_PORT_EXPLICIT" -eq 1 || $attempt -ge 3 ]]; then
|
||||
die "failed to start reachable host HTTP server on port $HOST_PORT"
|
||||
fi
|
||||
HOST_PORT="$(allocate_host_port)"
|
||||
warn "retrying host HTTP server on port $HOST_PORT"
|
||||
done
|
||||
}
|
||||
|
||||
install_latest_release() {
|
||||
local version_args=()
|
||||
if [[ -n "$INSTALL_VERSION" ]]; then
|
||||
version_args=(--version "$INSTALL_VERSION")
|
||||
fi
|
||||
guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh
|
||||
guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh "${version_args[@]}" --no-onboard
|
||||
guest_exec openclaw --version
|
||||
}
|
||||
|
||||
install_main_tgz() {
|
||||
local host_ip="$1"
|
||||
local temp_name="$2"
|
||||
local tgz_url="http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
|
||||
guest_exec curl -fsSL "$tgz_url" -o "/tmp/$temp_name"
|
||||
guest_exec npm install -g "/tmp/$temp_name" --no-fund --no-audit
|
||||
guest_exec openclaw --version
|
||||
}
|
||||
|
||||
verify_version_contains() {
|
||||
local needle="$1"
|
||||
local version
|
||||
version="$(guest_exec openclaw --version)"
|
||||
printf '%s\n' "$version"
|
||||
case "$version" in
|
||||
*"$needle"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $needle" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_ref_onboard() {
|
||||
guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw onboard \
|
||||
--non-interactive \
|
||||
--mode local \
|
||||
--auth-choice "$AUTH_CHOICE" \
|
||||
--secret-input-mode ref \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback \
|
||||
--skip-skills \
|
||||
--skip-health \
|
||||
--accept-risk \
|
||||
--json
|
||||
}
|
||||
|
||||
inject_bad_plugin_fixture() {
|
||||
guest_bash_script <<'EOF'
|
||||
set -euo pipefail
|
||||
plugin_dir=/root/.openclaw/test-bad-plugin
|
||||
mkdir -p "$plugin_dir"
|
||||
cat >"$plugin_dir/package.json" <<'JSON'
|
||||
{
|
||||
"name": "@openclaw/test-bad-plugin",
|
||||
"version": "1.0.0",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.cjs"],
|
||||
"setupEntry": "./setup-entry.cjs"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
cat >"$plugin_dir/openclaw.plugin.json" <<'JSON'
|
||||
{
|
||||
"id": "test-bad-plugin",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"channels": ["test-bad-plugin"]
|
||||
}
|
||||
JSON
|
||||
cat >"$plugin_dir/index.cjs" <<'JS'
|
||||
module.exports = { id: "test-bad-plugin", register() {} };
|
||||
JS
|
||||
cat >"$plugin_dir/setup-entry.cjs" <<'JS'
|
||||
module.exports = {
|
||||
kind: "bundled-channel-setup-entry",
|
||||
loadSetupPlugin() {
|
||||
throw new Error("boom: bad plugin smoke fixture");
|
||||
},
|
||||
};
|
||||
JS
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path("/root/.openclaw/openclaw.json")
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
config = json.loads(config_path.read_text())
|
||||
|
||||
plugins = config.setdefault("plugins", {})
|
||||
load = plugins.setdefault("load", {})
|
||||
paths = load.setdefault("paths", [])
|
||||
plugin_dir = "/root/.openclaw/test-bad-plugin"
|
||||
if plugin_dir not in paths:
|
||||
paths.append(plugin_dir)
|
||||
|
||||
allow = plugins.get("allow")
|
||||
if isinstance(allow, list) and "test-bad-plugin" not in allow:
|
||||
allow.append("test-bad-plugin")
|
||||
|
||||
config_path.write_text(json.dumps(config, indent=2) + "\n")
|
||||
PY
|
||||
EOF
|
||||
}
|
||||
|
||||
verify_bad_plugin_diagnostic() {
|
||||
guest_bash_script <<'EOF'
|
||||
grep -F "failed to load setup entry" /tmp/openclaw-parallels-linux-gateway.log
|
||||
EOF
|
||||
}
|
||||
|
||||
start_gateway_background() {
|
||||
local cmd api_key_value_q
|
||||
api_key_value_q="$(shell_quote "$API_KEY_VALUE")"
|
||||
cmd="$(cat <<EOF
|
||||
pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
|
||||
rm -f /tmp/openclaw-parallels-linux-gateway.log
|
||||
setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null &
|
||||
EOF
|
||||
)"
|
||||
guest_exec bash -lc "$cmd"
|
||||
|
||||
# On the Ubuntu guest the backgrounded process can bind a few seconds after
|
||||
# the launch command returns. Keep the race inside gateway-start instead of
|
||||
# failing the next phase with a false-negative RPC probe.
|
||||
local deadline
|
||||
deadline=$((SECONDS + TIMEOUT_GATEWAY_S))
|
||||
while (( SECONDS < deadline )); do
|
||||
if show_gateway_status_compat >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
show_gateway_status_compat() {
|
||||
if guest_exec openclaw gateway status --help | grep -Fq -- "--require-rpc"; then
|
||||
guest_exec openclaw gateway status --deep --require-rpc
|
||||
return
|
||||
fi
|
||||
guest_exec openclaw gateway status --deep
|
||||
}
|
||||
|
||||
verify_local_turn() {
|
||||
guest_exec openclaw models set "$MODEL_ID"
|
||||
guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw agent \
|
||||
--local \
|
||||
--agent main \
|
||||
--message ping \
|
||||
--json
|
||||
}
|
||||
|
||||
phase_log_path() {
|
||||
printf '%s/%s.log\n' "$RUN_DIR" "$1"
|
||||
}
|
||||
|
||||
extract_last_version() {
|
||||
local log_path="$1"
|
||||
python3 - "$log_path" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = pathlib.Path(sys.argv[1]).read_text(errors="replace")
|
||||
matches = re.findall(r"OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)", text)
|
||||
print(matches[-1] if matches else "")
|
||||
PY
|
||||
}
|
||||
|
||||
show_log_excerpt() {
|
||||
local log_path="$1"
|
||||
warn "log tail: $log_path"
|
||||
tail -n 80 "$log_path" >&2 || true
|
||||
}
|
||||
|
||||
phase_run() {
|
||||
local phase_id="$1"
|
||||
local timeout_s="$2"
|
||||
shift 2
|
||||
|
||||
local log_path pid start rc timed_out
|
||||
log_path="$(phase_log_path "$phase_id")"
|
||||
say "$phase_id"
|
||||
start=$SECONDS
|
||||
timed_out=0
|
||||
|
||||
(
|
||||
"$@"
|
||||
) >"$log_path" 2>&1 &
|
||||
pid=$!
|
||||
|
||||
while kill -0 "$pid" >/dev/null 2>&1; do
|
||||
if (( SECONDS - start >= timeout_s )); then
|
||||
timed_out=1
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
set +e
|
||||
wait "$pid"
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if (( timed_out )); then
|
||||
warn "$phase_id timed out after ${timeout_s}s"
|
||||
printf 'timeout after %ss\n' "$timeout_s" >>"$log_path"
|
||||
show_log_excerpt "$log_path"
|
||||
return 124
|
||||
fi
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
warn "$phase_id failed (rc=$rc)"
|
||||
show_log_excerpt "$log_path"
|
||||
return "$rc"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
write_summary_json() {
|
||||
local summary_path="$RUN_DIR/summary.json"
|
||||
python3 - "$summary_path" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
summary = {
|
||||
"vm": os.environ["SUMMARY_VM"],
|
||||
"snapshotHint": os.environ["SUMMARY_SNAPSHOT_HINT"],
|
||||
"snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"],
|
||||
"mode": os.environ["SUMMARY_MODE"],
|
||||
"provider": os.environ["SUMMARY_PROVIDER"],
|
||||
"latestVersion": os.environ["SUMMARY_LATEST_VERSION"],
|
||||
"installVersion": os.environ["SUMMARY_INSTALL_VERSION"],
|
||||
"targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"],
|
||||
"currentHead": os.environ["SUMMARY_CURRENT_HEAD"],
|
||||
"runDir": os.environ["SUMMARY_RUN_DIR"],
|
||||
"daemon": os.environ["SUMMARY_DAEMON_STATUS"],
|
||||
"freshMain": {
|
||||
"status": os.environ["SUMMARY_FRESH_MAIN_STATUS"],
|
||||
"version": os.environ["SUMMARY_FRESH_MAIN_VERSION"],
|
||||
"gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"],
|
||||
"agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"],
|
||||
},
|
||||
"upgrade": {
|
||||
"status": os.environ["SUMMARY_UPGRADE_STATUS"],
|
||||
"latestVersionInstalled": os.environ["SUMMARY_LATEST_INSTALLED_VERSION"],
|
||||
"mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"],
|
||||
"gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"],
|
||||
"agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"],
|
||||
},
|
||||
}
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, indent=2, sort_keys=True)
|
||||
print(sys.argv[1])
|
||||
PY
|
||||
}
|
||||
|
||||
run_fresh_main_lane() {
|
||||
local snapshot_id="$1"
|
||||
local host_ip="$2"
|
||||
phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
|
||||
phase_run "fresh.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest
|
||||
phase_run "fresh.install-latest-bootstrap" "$TIMEOUT_INSTALL_S" install_latest_release
|
||||
phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz"
|
||||
FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")"
|
||||
phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
|
||||
phase_run "fresh.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture
|
||||
phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
|
||||
phase_run "fresh.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background
|
||||
phase_run "fresh.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic
|
||||
phase_run "fresh.gateway-status" "$TIMEOUT_VERIFY_S" show_gateway_status_compat
|
||||
FRESH_GATEWAY_STATUS="pass"
|
||||
phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn
|
||||
FRESH_AGENT_STATUS="pass"
|
||||
}
|
||||
|
||||
run_upgrade_lane() {
|
||||
local snapshot_id="$1"
|
||||
local host_ip="$2"
|
||||
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
|
||||
phase_run "upgrade.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest
|
||||
phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_S" install_latest_release
|
||||
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")"
|
||||
phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION"
|
||||
phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz"
|
||||
UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")"
|
||||
phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
|
||||
phase_run "upgrade.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture
|
||||
phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
|
||||
phase_run "upgrade.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background
|
||||
phase_run "upgrade.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic
|
||||
phase_run "upgrade.gateway-status" "$TIMEOUT_VERIFY_S" show_gateway_status_compat
|
||||
UPGRADE_GATEWAY_STATUS="pass"
|
||||
phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn
|
||||
UPGRADE_AGENT_STATUS="pass"
|
||||
}
|
||||
|
||||
RESOLVED_VM_NAME="$(resolve_vm_name)"
|
||||
if [[ "$RESOLVED_VM_NAME" != "$VM_NAME" ]]; then
|
||||
warn "requested VM $VM_NAME not found; using $RESOLVED_VM_NAME"
|
||||
VM_NAME="$RESOLVED_VM_NAME"
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)"
|
||||
[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id"
|
||||
[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT"
|
||||
LATEST_VERSION="$(resolve_latest_version)"
|
||||
HOST_IP="$(resolve_host_ip)"
|
||||
HOST_PORT="$(resolve_host_port)"
|
||||
|
||||
say "VM: $VM_NAME"
|
||||
say "Snapshot hint: $SNAPSHOT_HINT"
|
||||
say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]"
|
||||
say "Latest npm version: $LATEST_VERSION"
|
||||
say "Current head: $(git rev-parse --short HEAD)"
|
||||
say "Run logs: $RUN_DIR"
|
||||
|
||||
pack_main_tgz
|
||||
start_server "$HOST_IP"
|
||||
|
||||
if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then
|
||||
set +e
|
||||
run_fresh_main_lane "$SNAPSHOT_ID" "$HOST_IP"
|
||||
fresh_rc=$?
|
||||
set -e
|
||||
if [[ $fresh_rc -eq 0 ]]; then
|
||||
FRESH_MAIN_STATUS="pass"
|
||||
else
|
||||
FRESH_MAIN_STATUS="fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then
|
||||
set +e
|
||||
run_upgrade_lane "$SNAPSHOT_ID" "$HOST_IP"
|
||||
upgrade_rc=$?
|
||||
set -e
|
||||
if [[ $upgrade_rc -eq 0 ]]; then
|
||||
UPGRADE_STATUS="pass"
|
||||
else
|
||||
UPGRADE_STATUS="fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$KEEP_SERVER" -eq 0 && -n "${SERVER_PID:-}" ]]; then
|
||||
kill "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
SERVER_PID=""
|
||||
fi
|
||||
|
||||
SUMMARY_JSON_PATH="$(
|
||||
SUMMARY_VM="$VM_NAME" \
|
||||
SUMMARY_SNAPSHOT_HINT="$SNAPSHOT_HINT" \
|
||||
SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \
|
||||
SUMMARY_MODE="$MODE" \
|
||||
SUMMARY_PROVIDER="$PROVIDER" \
|
||||
SUMMARY_LATEST_VERSION="$LATEST_VERSION" \
|
||||
SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \
|
||||
SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \
|
||||
SUMMARY_CURRENT_HEAD="${PACKED_MAIN_COMMIT_SHORT:-$(git rev-parse --short HEAD)}" \
|
||||
SUMMARY_RUN_DIR="$RUN_DIR" \
|
||||
SUMMARY_DAEMON_STATUS="$DAEMON_STATUS" \
|
||||
SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \
|
||||
SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \
|
||||
SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \
|
||||
SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \
|
||||
SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \
|
||||
SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \
|
||||
SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \
|
||||
SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \
|
||||
SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \
|
||||
write_summary_json
|
||||
)"
|
||||
|
||||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||||
cat "$SUMMARY_JSON_PATH"
|
||||
else
|
||||
printf '\nSummary:\n'
|
||||
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
|
||||
printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC"
|
||||
fi
|
||||
if [[ -n "$INSTALL_VERSION" ]]; then
|
||||
printf ' baseline-install-version: %s\n' "$INSTALL_VERSION"
|
||||
fi
|
||||
printf ' daemon: %s\n' "$DAEMON_STATUS"
|
||||
printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION"
|
||||
printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION"
|
||||
printf ' logs: %s\n' "$RUN_DIR"
|
||||
printf ' summary: %s\n' "$SUMMARY_JSON_PATH"
|
||||
fi
|
||||
|
||||
if [[ "$FRESH_MAIN_STATUS" == "fail" || "$UPGRADE_STATUS" == "fail" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
1999
openclaw/scripts/e2e/parallels-macos-smoke.sh
Normal file
1999
openclaw/scripts/e2e/parallels-macos-smoke.sh
Normal file
File diff suppressed because it is too large
Load diff
1516
openclaw/scripts/e2e/parallels-npm-update-smoke.sh
Normal file
1516
openclaw/scripts/e2e/parallels-npm-update-smoke.sh
Normal file
File diff suppressed because it is too large
Load diff
2499
openclaw/scripts/e2e/parallels-windows-smoke.sh
Normal file
2499
openclaw/scripts/e2e/parallels-windows-smoke.sh
Normal file
File diff suppressed because it is too large
Load diff
1016
openclaw/scripts/e2e/plugins-docker.sh
Normal file
1016
openclaw/scripts/e2e/plugins-docker.sh
Normal file
File diff suppressed because it is too large
Load diff
12
openclaw/scripts/e2e/qr-import-docker.sh
Normal file
12
openclaw/scripts/e2e/qr-import-docker.sh
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
IMAGE_NAME="${OPENCLAW_QR_SMOKE_IMAGE:-openclaw-qr-smoke}"
|
||||
|
||||
echo "Building Docker image..."
|
||||
run_logged qr-import-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" "$ROOT_DIR"
|
||||
|
||||
echo "Running qrcode-terminal import smoke..."
|
||||
run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))"
|
||||
Loading…
Add table
Add a link
Reference in a new issue