diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index c9a08951dbf..fbe269239a6 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -8,6 +8,12 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" source "$ROOT_DIR/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/channel.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/root-owned.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/setup-entry.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/disabled-config.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/update.sh" +source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/load-failure.sh" IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-channel-deps-e2e" OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE)" UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-2026.4.20}" @@ -40,1608 +46,5 @@ prepare_package_tgz() { prepare_package_tgz docker_e2e_package_mount_args "$PACKAGE_TGZ" -run_channel_scenario() { - local channel="$1" - local dep_sentinel="$2" - local run_log - run_log="$(docker_e2e_run_log "bundled-channel-deps-$channel")" - - echo "Running bundled $channel runtime deps Docker E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ - -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-deps.XXXXXX")" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-deps-e2e" -export OPENCLAW_NO_ONBOARD=1 - -TOKEN="bundled-channel-deps-token" -PORT="18789" -CHANNEL="${OPENCLAW_CHANNEL_UNDER_TEST:?missing OPENCLAW_CHANNEL_UNDER_TEST}" -DEP_SENTINEL="${OPENCLAW_DEP_SENTINEL:?missing OPENCLAW_DEP_SENTINEL}" -gateway_pid="" - -terminate_gateways() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - fi - if command -v pkill >/dev/null 2>&1; then - pkill -TERM -f "[o]penclaw-gateway" 2>/dev/null || true - fi - for _ in $(seq 1 100); do - local alive=0 - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - alive=1 - fi - if command -v pgrep >/dev/null 2>&1 && pgrep -f "[o]penclaw-gateway" >/dev/null 2>&1; then - alive=1 - fi - [ "$alive" = "0" ] && break - sleep 0.1 - done - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill -KILL "$gateway_pid" 2>/dev/null || true - fi - if command -v pkill >/dev/null 2>&1; then - pkill -KILL -f "[o]penclaw-gateway" 2>/dev/null || true - fi - if [ -n "${gateway_pid:-}" ]; then - wait "$gateway_pid" 2>/dev/null || true - fi -} - -cleanup() { - terminate_gateways -} -trap cleanup EXIT - -echo "Installing mounted OpenClaw package..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-install.log 2>&1 - -command -v openclaw >/dev/null -package_root="$(npm root -g)/openclaw" -test -d "$package_root/dist/extensions/telegram" -test -d "$package_root/dist/extensions/discord" -test -d "$package_root/dist/extensions/slack" -test -d "$package_root/dist/extensions/feishu" -test -d "$package_root/dist/extensions/memory-lancedb" - -stage_root() { - printf "%s/.openclaw/plugin-runtime-deps" "$HOME" -} - -find_external_dep_package() { - local dep_path="$1" - find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true -} - -assert_package_dep_absent() { - local channel="$1" - local dep_path="$2" - for candidate in \ - "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$package_root/dist/extensions/node_modules/$dep_path/package.json" \ - "$package_root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - echo "packaged install should not mutate package tree for $channel: $candidate" >&2 - exit 1 - fi - done -} - -if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then - echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 - find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true - exit 1 -fi - -write_config() { - local mode="$1" - node - <<'NODE' "$mode" "$TOKEN" "$PORT" -const fs = require("node:fs"); -const path = require("node:path"); - -const mode = process.argv[2]; -const token = process.argv[3]; -const port = Number(process.argv[4]); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; - -config.gateway = { - ...(config.gateway || {}), - port, - auth: { mode: "token", token }, - controlUi: { enabled: false }, -}; -config.agents = { - ...(config.agents || {}), - defaults: { - ...(config.agents?.defaults || {}), - model: { primary: "openai/gpt-4.1-mini" }, - }, -}; -config.models = { - ...(config.models || {}), - providers: { - ...(config.models?.providers || {}), - openai: { - ...(config.models?.providers?.openai || {}), - apiKey: process.env.OPENAI_API_KEY, - baseUrl: "https://api.openai.com/v1", - models: [], - }, - }, -}; -config.plugins = { - ...(config.plugins || {}), - enabled: true, -}; - -if (mode === "telegram") { - config.channels = { - ...(config.channels || {}), - telegram: { - ...(config.channels?.telegram || {}), - enabled: true, - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - }; -} -if (mode === "discord") { - config.channels = { - ...(config.channels || {}), - discord: { - ...(config.channels?.discord || {}), - enabled: true, - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - }; -} -if (mode === "slack") { - config.channels = { - ...(config.channels || {}), - slack: { - ...(config.channels?.slack || {}), - enabled: true, - }, - }; -} -if (mode === "feishu") { - config.channels = { - ...(config.channels || {}), - feishu: { - ...(config.channels?.feishu || {}), - enabled: true, - }, - }; -} -if (mode === "memory-lancedb") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], - slots: { - ...(config.plugins?.slots || {}), - memory: "memory-lancedb", - }, - entries: { - ...(config.plugins?.entries || {}), - "memory-lancedb": { - ...(config.plugins?.entries?.["memory-lancedb"] || {}), - enabled: true, - config: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config || {}), - embedding: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config?.embedding || {}), - apiKey: process.env.OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath: "~/.openclaw/memory/lancedb-e2e", - autoCapture: false, - autoRecall: false, - }, - }, - }, - }; -} - -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE -} - -start_gateway() { - local log_file="$1" - local skip_sidecars="${2:-0}" - : >"$log_file" - if [ "$skip_sidecars" = "1" ]; then - OPENCLAW_SKIP_CHANNELS=1 OPENCLAW_SKIP_PROVIDERS=1 \ - openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & - else - openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & - fi - gateway_pid="$!" - - # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. - for _ in $(seq 1 1200); do - if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "gateway exited unexpectedly" >&2 - cat "$log_file" >&2 - exit 1 - fi - sleep 0.25 - done - - echo "timed out waiting for gateway" >&2 - cat "$log_file" >&2 - exit 1 -} - -stop_gateway() { - terminate_gateways - gateway_pid="" -} - -wait_for_gateway_health() { - local log_file="${1:-}" - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - return 0 - fi - echo "gateway process exited after ready marker" >&2 - if [ -n "$log_file" ]; then - cat "$log_file" >&2 - fi - return 1 -} - -assert_channel_status() { - local channel="$1" - if [ "$channel" = "memory-lancedb" ]; then - echo "memory-lancedb plugin activation verified by dependency sentinel" - return 0 - fi - local out="/tmp/openclaw-channel-status-$channel.json" - local err="/tmp/openclaw-channel-status-$channel.err" - for _ in $(seq 1 12); do - if openclaw gateway call channels.status \ - --url "ws://127.0.0.1:$PORT" \ - --token "$TOKEN" \ - --timeout 10000 \ - --json \ - --params '{"probe":false}' >"$out" 2>"$err"; then - break - fi - sleep 2 - done - if [ ! -s "$out" ]; then - if grep -Eq "\\[gateway\\] ready \\(.*\\b$channel\\b" /tmp/openclaw-"$channel"-*.log 2>/dev/null; then - echo "$channel channel plugin visible in gateway ready log" - return 0 - fi - cat "$err" >&2 || true - return 1 - fi - node - <<'NODE' "$out" "$channel" -const fs = require("node:fs"); -const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const payload = raw.result ?? raw.data ?? raw; -const channel = process.argv[3]; -const dump = () => JSON.stringify(raw, null, 2).slice(0, 4000); -const hasChannelMeta = Array.isArray(payload.channelMeta) - ? payload.channelMeta.some((entry) => entry?.id === channel) - : Boolean(payload.channelMeta?.[channel]); -if (!hasChannelMeta) { - throw new Error(`missing channelMeta.${channel}\n${dump()}`); -} -if (!payload.channels || !payload.channels[channel]) { - throw new Error(`missing channels.${channel}\n${dump()}`); -} -const accounts = payload.channelAccounts?.[channel]; -if (!Array.isArray(accounts) || accounts.length === 0) { - throw new Error(`missing channelAccounts.${channel}\n${dump()}`); -} -console.log(`${channel} channel plugin visible`); -NODE -} - -assert_installed_once() { - local log_file="$1" - local channel="$2" - local dep_path="$3" - local count - count="$(grep -Ec "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file" || true)" - if [ "$count" -eq 1 ]; then - return 0 - fi - if [ "$count" -eq 0 ] && [ -n "$(find_external_dep_package "$dep_path")" ]; then - return 0 - fi - echo "expected one runtime deps install log or staged dependency sentinel for $channel, got $count log lines" >&2 - cat "$log_file" >&2 - find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 -} - -assert_not_installed() { - local log_file="$1" - local channel="$2" - if grep -Eq "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file"; then - echo "expected no runtime deps reinstall for $channel" >&2 - cat "$log_file" >&2 - exit 1 - fi -} - -assert_dep_sentinel() { - local channel="$1" - local dep_path="$2" - local sentinel - sentinel="$(find_external_dep_package "$dep_path")" - if [ -z "$sentinel" ]; then - echo "missing external dependency sentinel for $channel: $dep_path" >&2 - find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 - fi - assert_package_dep_absent "$channel" "$dep_path" -} - -assert_no_dep_sentinel() { - local channel="$1" - local dep_path="$2" - assert_package_dep_absent "$channel" "$dep_path" - if [ -n "$(find_external_dep_package "$dep_path")" ]; then - echo "external dependency sentinel should be absent before activation for $channel: $dep_path" >&2 - exit 1 - fi -} - -assert_no_install_stage() { - local channel="$1" - local stage="$package_root/dist/extensions/$channel/.openclaw-install-stage" - if [ -e "$stage" ]; then - echo "install stage should be cleaned after activation for $channel" >&2 - find "$stage" -maxdepth 4 -type f | sort | head -80 >&2 || true - exit 1 - fi -} - -echo "Starting baseline gateway with OpenAI configured..." -write_config baseline -start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" 1 -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-baseline.log" -stop_gateway -assert_no_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" - -echo "Enabling $CHANNEL by config edit, then restarting gateway..." -write_config "$CHANNEL" -start_gateway "/tmp/openclaw-$CHANNEL-first.log" -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-first.log" -assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" "$DEP_SENTINEL" -assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" -assert_no_install_stage "$CHANNEL" -assert_channel_status "$CHANNEL" -stop_gateway - -echo "Restarting gateway again; $CHANNEL deps must stay installed..." -start_gateway "/tmp/openclaw-$CHANNEL-second.log" -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-second.log" -assert_not_installed "/tmp/openclaw-$CHANNEL-second.log" "$CHANNEL" -assert_no_install_stage "$CHANNEL" -assert_channel_status "$CHANNEL" -stop_gateway - -echo "bundled $CHANNEL runtime deps Docker E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} - -run_root_owned_global_scenario() { - local run_log - run_log="$(docker_e2e_run_log bundled-channel-root-owned)" - - echo "Running bundled channel root-owned global install Docker E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm --user root \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="/root" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-root-owned-e2e" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="/var/lib/openclaw/plugin-runtime-deps" - -TOKEN="bundled-channel-root-owned-token" -PORT="18791" -CHANNEL="slack" -DEP_SENTINEL="@slack/web-api" -gateway_pid="" - -package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -cleanup() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - wait "$gateway_pid" 2>/dev/null || true - fi -} -trap cleanup EXIT - -echo "Installing mounted OpenClaw package into root-owned global npm..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-root-owned-install.log 2>&1 - -root="$(package_root)" -test -d "$root/dist/extensions/$CHANNEL" -rm -rf "$root/dist/extensions/$CHANNEL/node_modules" -chmod -R a-w "$root" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" /home/appuser/.openclaw -chown -R appuser:appuser /home/appuser/.openclaw /var/lib/openclaw - -if runuser -u appuser -- test -w "$root"; then - echo "expected package root to be unwritable for appuser" >&2 - exit 1 -fi - -node - <<'NODE' "$TOKEN" "$PORT" -const fs = require("node:fs"); -const path = require("node:path"); -const token = process.argv[2]; -const port = Number(process.argv[3]); -const configPath = "/home/appuser/.openclaw/openclaw.json"; -const config = { - gateway: { - port, - auth: { mode: "token", token }, - controlUi: { enabled: false }, - }, - agents: { - defaults: { - model: { primary: "openai/gpt-4.1-mini" }, - }, - }, - models: { - providers: { - openai: { - apiKey: process.env.OPENAI_API_KEY, - baseUrl: "https://api.openai.com/v1", - models: [], - }, - }, - }, - plugins: { enabled: true }, - channels: { - slack: { - enabled: true, - botToken: "xoxb-bundled-channel-root-owned-token", - appToken: "xapp-bundled-channel-root-owned-token", - }, - }, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE -chown appuser:appuser /home/appuser/.openclaw/openclaw.json - -start_gateway() { - local log_file="$1" - : >"$log_file" - chown appuser:appuser "$log_file" - runuser -u appuser -- env \ - HOME=/home/appuser \ - OPENAI_API_KEY="$OPENAI_API_KEY" \ - OPENCLAW_NO_ONBOARD=1 \ - OPENCLAW_PLUGIN_STAGE_DIR="$OPENCLAW_PLUGIN_STAGE_DIR" \ - npm_config_cache=/tmp/openclaw-root-owned-npm-cache \ - bash -c 'openclaw gateway --port "$1" --bind loopback --allow-unconfigured >"$2" 2>&1' \ - bash "$PORT" "$log_file" & - gateway_pid="$!" - - # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. - for _ in $(seq 1 1200); do - if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "gateway exited unexpectedly" >&2 - cat "$log_file" >&2 - exit 1 - fi - sleep 0.25 - done - - echo "timed out waiting for gateway" >&2 - cat "$log_file" >&2 - exit 1 -} - -wait_for_slack_provider_start() { - for _ in $(seq 1 180); do - if grep -Eq "\\[slack\\] \\[default\\] starting provider|An API error occurred: invalid_auth|\\[plugins\\] slack installed bundled runtime deps|\\[gateway\\] ready \\(.*\\bslack\\b" /tmp/openclaw-root-owned-gateway.log; then - return 0 - fi - sleep 1 - done - echo "timed out waiting for slack provider startup" >&2 - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -} - -start_gateway /tmp/openclaw-root-owned-gateway.log -wait_for_slack_provider_start - -if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then - echo "root-owned package tree was mutated" >&2 - find "$root/dist/extensions/$CHANNEL/node_modules" -maxdepth 4 -type f | sort | head -80 >&2 || true - exit 1 -fi -if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_SENTINEL/package.json" -type f | grep -q .; then - echo "missing external staged dependency sentinel for $DEP_SENTINEL" >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -120 >&2 || true - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -fi -if [ -e "$root/dist/extensions/node_modules/openclaw/package.json" ]; then - echo "root-owned package tree was mutated with SDK alias" >&2 - find "$root/dist/extensions/node_modules/openclaw" -maxdepth 4 -type f | sort | head -80 >&2 || true - exit 1 -fi -if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/dist/extensions/node_modules/openclaw/package.json" -type f | grep -q .; then - echo "missing external staged openclaw/plugin-sdk alias" >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -120 >&2 || true - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -fi -if grep -Eq "failed to install bundled runtime deps|Cannot find package 'openclaw'|Cannot find module 'openclaw/plugin-sdk'" /tmp/openclaw-root-owned-gateway.log; then - echo "root-owned gateway hit bundled runtime dependency errors" >&2 - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -fi - -echo "root-owned global install Docker E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} - -run_setup_entry_scenario() { - local run_log - run_log="$(docker_e2e_run_log bundled-channel-setup-entry)" - - echo "Running bundled channel setup-entry runtime deps Docker E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-setup-entry.XXXXXX")" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" - -declare -A SETUP_ENTRY_DEP_SENTINELS=( - [feishu]="@larksuiteoapi/node-sdk" - [whatsapp]="@whiskeysockets/baileys" -) - -package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -echo "Installing mounted OpenClaw package..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-setup-entry-install.log 2>&1 - -root="$(package_root)" -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - test -d "$root/dist/extensions/$channel" - if [ -d "$root/dist/extensions/$channel/node_modules" ]; then - echo "$channel runtime deps should not be preinstalled in package" >&2 - find "$root/dist/extensions/$channel/node_modules" -maxdepth 3 -type f | head -40 >&2 || true - exit 1 - fi - if [ -f "$root/node_modules/$dep_sentinel/package.json" ]; then - echo "$dep_sentinel should not be installed at package root before setup-entry load" >&2 - exit 1 - fi -done - -echo "Probing real bundled setup entries before channel configuration..." -( - cd "$root" - node --input-type=module - <<'NODE' -import fs from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const root = process.cwd(); -const distDir = path.join(root, "dist"); -const bundledPath = fs - .readdirSync(distDir) - .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) - .map((entry) => path.join(distDir, entry)) - .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); -if (!bundledPath) { - throw new Error("missing packaged bundled channel loader artifact"); -} -const bundled = await import(pathToFileURL(bundledPath)); -const setupPluginLoader = Object.values(bundled).find( - (value) => typeof value === "function" && value.name === "getBundledChannelSetupPlugin", -); -if (!setupPluginLoader) { - throw new Error("missing packaged getBundledChannelSetupPlugin export"); -} -for (const channel of ["feishu", "whatsapp"]) { - const plugin = setupPluginLoader(channel); - if (!plugin) { - throw new Error(`${channel} setup plugin did not load pre-config`); - } - if (plugin.id !== channel) { - throw new Error(`${channel} setup plugin id mismatch: ${plugin.id}`); - } - console.log(`${channel} setup plugin loaded pre-config`); -} -NODE -) - -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then - echo "setup-entry discovery installed $channel deps into bundled plugin tree before channel configuration" >&2 - exit 1 - fi - if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then - echo "setup-entry discovery installed $channel external staged deps before channel configuration" >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true - exit 1 - fi -done - -echo "Running packaged guided WhatsApp setup; runtime deps should be staged before finalize..." -OPENCLAW_PACKAGE_ROOT="$root" node --input-type=module - <<'NODE' -import path from "node:path"; -import { readdir } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; - -const root = process.env.OPENCLAW_PACKAGE_ROOT; -if (!root) { - throw new Error("missing OPENCLAW_PACKAGE_ROOT"); -} -const distDir = path.join(root, "dist"); -const onboardChannelFiles = (await readdir(distDir)) - .filter((entry) => /^onboard-channels-.*\.js$/.test(entry)) - .sort(); -let setupChannels; -for (const entry of onboardChannelFiles) { - const module = await import(pathToFileURL(path.join(distDir, entry))); - if (typeof module.setupChannels === "function") { - setupChannels = module.setupChannels; - break; - } -} -if (!setupChannels) { - throw new Error( - `could not find packaged setupChannels export in ${JSON.stringify(onboardChannelFiles)}`, - ); -} - -let channelSelectCount = 0; -const notes = []; -const prompter = { - intro: async () => {}, - outro: async () => {}, - note: async (body, title) => { - notes.push({ title, body }); - }, - confirm: async ({ message, initialValue }) => { - if (message === "Link WhatsApp now (QR)?") { - return false; - } - return initialValue ?? true; - }, - select: async ({ message, options }) => { - if (message === "Select a channel") { - channelSelectCount += 1; - return channelSelectCount === 1 ? "whatsapp" : "__done__"; - } - if (message === "Install WhatsApp plugin?") { - if (!options?.some((option) => option.value === "local")) { - throw new Error(`missing bundled local install option: ${JSON.stringify(options)}`); - } - return "local"; - } - if (message === "WhatsApp phone setup") { - return "separate"; - } - if (message === "WhatsApp DM policy") { - return "disabled"; - } - throw new Error(`unexpected select prompt: ${message}`); - }, - multiselect: async ({ message }) => { - throw new Error(`unexpected multiselect prompt: ${message}`); - }, - text: async ({ message }) => { - throw new Error(`unexpected text prompt: ${message}`); - }, -}; -const runtime = { - log: (message) => console.log(message), - error: (message) => console.error(message), -}; - -const result = await setupChannels( - { plugins: { enabled: true } }, - runtime, - prompter, - { - deferStatusUntilSelection: true, - skipConfirm: true, - skipStatusNote: true, - skipDmPolicyPrompt: true, - initialSelection: ["whatsapp"], - }, -); - -if (!result.channels?.whatsapp) { - throw new Error(`WhatsApp setup did not write channel config: ${JSON.stringify(result)}`); -} -console.log("packaged guided WhatsApp setup completed"); -NODE - -if [ -e "$root/dist/extensions/whatsapp/node_modules/@whiskeysockets/baileys/package.json" ]; then - echo "expected guided WhatsApp setup deps to be installed externally, not into bundled plugin tree" >&2 - exit 1 -fi -if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/@whiskeysockets/baileys/package.json" -type f | grep -q .; then - echo "guided WhatsApp setup did not stage @whiskeysockets/baileys before finalize" >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true - exit 1 -fi - -echo "Configuring setup-entry channels; doctor should now install bundled runtime deps externally..." -node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; - -config.plugins = { - ...(config.plugins || {}), - enabled: true, -}; -config.channels = { - ...(config.channels || {}), - feishu: { - ...(config.channels?.feishu || {}), - enabled: true, - }, - whatsapp: { - ...(config.channels?.whatsapp || {}), - enabled: true, - }, -}; - -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE - -openclaw doctor --non-interactive >/tmp/openclaw-setup-entry-doctor.log 2>&1 - -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then - echo "expected configured $channel deps to be installed externally, not into bundled plugin tree" >&2 - exit 1 - fi - if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then - echo "missing external staged dependency sentinel for configured $channel: $dep_sentinel" >&2 - cat /tmp/openclaw-setup-entry-doctor.log >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true - exit 1 - fi -done - -echo "bundled channel setup-entry runtime deps Docker E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} - -run_disabled_config_scenario() { - local run_log - run_log="$(docker_e2e_run_log bundled-channel-disabled-config)" - - echo "Running bundled channel disabled-config runtime deps Docker E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-disabled-config.XXXXXX")" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" - -package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -assert_dep_absent_everywhere() { - local channel="$1" - local dep_path="$2" - local root="$3" - for candidate in \ - "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$root/dist/extensions/node_modules/$dep_path/package.json" \ - "$root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - echo "disabled $channel unexpectedly installed $dep_path at $candidate" >&2 - exit 1 - fi - done - - if ! node - <<'NODE' "$OPENCLAW_PLUGIN_STAGE_DIR" "$dep_path" -const fs = require("node:fs"); -const path = require("node:path"); - -const stageDir = process.argv[2]; -const depName = process.argv[3]; -const manifestName = ".openclaw-runtime-deps.json"; -const matches = []; - -function visit(dir) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - if (entry.name !== manifestName) { - continue; - } - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); - } catch { - continue; - } - const specs = Array.isArray(parsed.specs) ? parsed.specs : []; - for (const spec of specs) { - if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { - matches.push(`${fullPath}: ${spec}`); - } - } - } -} - -visit(stageDir); -if (matches.length > 0) { - process.stderr.write(`${matches.join("\n")}\n`); - process.exit(1); -} -NODE - then - echo "disabled $channel unexpectedly selected $dep_path for external runtime deps" >&2 - cat /tmp/openclaw-disabled-config-doctor.log >&2 - exit 1 - fi -} - -echo "Installing mounted OpenClaw package..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-disabled-config-install.log 2>&1 - -root="$(package_root)" -test -d "$root/dist/extensions/telegram" -test -d "$root/dist/extensions/discord" -test -d "$root/dist/extensions/slack" -rm -rf "$root/dist/extensions/telegram/node_modules" -rm -rf "$root/dist/extensions/discord/node_modules" -rm -rf "$root/dist/extensions/slack/node_modules" - -node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = { - plugins: { - enabled: true, - entries: { - discord: { enabled: false }, - }, - }, - channels: { - telegram: { - enabled: false, - botToken: "123456:disabled-config-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - enabled: false, - botToken: "xoxb-disabled-config-token", - appToken: "xapp-disabled-config-token", - }, - discord: { - enabled: true, - token: "disabled-plugin-entry-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - }, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE - -if ! openclaw doctor --non-interactive >/tmp/openclaw-disabled-config-doctor.log 2>&1; then - echo "doctor failed for disabled-config runtime deps smoke" >&2 - cat /tmp/openclaw-disabled-config-doctor.log >&2 - exit 1 -fi - -assert_dep_absent_everywhere telegram grammy "$root" -assert_dep_absent_everywhere slack @slack/web-api "$root" -assert_dep_absent_everywhere discord discord-api-types "$root" - -if grep -Eq "(used by .*\\b(telegram|slack|discord)\\b|\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps( in [0-9]+ms)?:)" /tmp/openclaw-disabled-config-doctor.log; then - echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2 - cat /tmp/openclaw-disabled-config-doctor.log >&2 - exit 1 -fi - -echo "bundled channel disabled-config runtime deps Docker E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} - -run_update_scenario() { - local run_log - run_log="$(docker_e2e_run_log bundled-channel-update)" - - echo "Running bundled channel runtime deps Docker update E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ - -e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-update.XXXXXX")" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-update-e2e" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_UPDATE_PACKAGE_SPEC="" - -TOKEN="bundled-channel-update-token" -PORT="18790" -UPDATE_TARGETS="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" - -package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -stage_root() { - printf "%s/.openclaw/plugin-runtime-deps" "$HOME" -} - -poison_home_npm_project() { - printf '{"name":"openclaw-home-prefix-poison","private":true}\n' >"$HOME/package.json" - rm -rf "$HOME/node_modules" - mkdir -p "$HOME/node_modules" - chmod 500 "$HOME/node_modules" -} - -find_external_dep_package() { - local dep_path="$1" - find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true -} - -assert_no_unknown_stage_roots() { - if find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-unknown-*' -print -quit 2>/dev/null | grep -q .; then - echo "runtime deps created second-generation unknown stage roots" >&2 - find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-*' -print | sort >&2 || true - exit 1 - fi -} - -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -update_target="file:$package_tgz" -candidate_version="$(node - <<'NODE' "$package_tgz" -const { execFileSync } = require("node:child_process"); -const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { - encoding: "utf8", -}); -process.stdout.write(String(JSON.parse(raw).version)); -NODE -)" - -write_config() { - local mode="$1" - node - <<'NODE' "$mode" "$TOKEN" "$PORT" -const fs = require("node:fs"); -const path = require("node:path"); - -const mode = process.argv[2]; -const token = process.argv[3]; -const port = Number(process.argv[4]); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; - -config.gateway = { - ...(config.gateway || {}), - port, - auth: { mode: "token", token }, - controlUi: { enabled: false }, -}; -config.agents = { - ...(config.agents || {}), - defaults: { - ...(config.agents?.defaults || {}), - model: { primary: "openai/gpt-4.1-mini" }, - }, -}; -config.models = { - ...(config.models || {}), - providers: { - ...(config.models?.providers || {}), - openai: { - ...(config.models?.providers?.openai || {}), - apiKey: process.env.OPENAI_API_KEY, - baseUrl: "https://api.openai.com/v1", - models: [], - }, - }, -}; -config.plugins = { - ...(config.plugins || {}), - enabled: true, -}; -config.channels = { - ...(config.channels || {}), - telegram: { - ...(config.channels?.telegram || {}), - enabled: mode === "telegram", - botToken: "123456:bundled-channel-update-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - discord: { - ...(config.channels?.discord || {}), - enabled: mode === "discord", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - ...(config.channels?.slack || {}), - enabled: mode === "slack", - botToken: "xoxb-bundled-channel-update-token", - appToken: "xapp-bundled-channel-update-token", - }, - feishu: { - ...(config.channels?.feishu || {}), - enabled: mode === "feishu", - }, -}; -if (mode === "memory-lancedb") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], - slots: { - ...(config.plugins?.slots || {}), - memory: "memory-lancedb", - }, - entries: { - ...(config.plugins?.entries || {}), - "memory-lancedb": { - ...(config.plugins?.entries?.["memory-lancedb"] || {}), - enabled: true, - config: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config || {}), - embedding: { - ...(config.plugins?.entries?.["memory-lancedb"]?.config?.embedding || {}), - apiKey: process.env.OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath: "~/.openclaw/memory/lancedb-update-e2e", - autoCapture: false, - autoRecall: false, - }, - }, - }, - }; -} -if (mode === "acpx") { - config.plugins = { - ...(config.plugins || {}), - enabled: true, - allow: - Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0 - ? [...new Set([...config.plugins.allow, "acpx"])] - : config.plugins?.allow, - entries: { - ...(config.plugins?.entries || {}), - acpx: { - ...(config.plugins?.entries?.acpx || {}), - enabled: true, - }, - }, - }; -} - -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE -} - -assert_dep_sentinel() { - local channel="$1" - local dep_path="$2" - local root - local sentinel - root="$(package_root)" - sentinel="$(find_external_dep_package "$dep_path")" - if [ -z "$sentinel" ]; then - echo "missing external dependency sentinel for $channel: $dep_path" >&2 - find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 - fi - assert_no_package_dep_available "$channel" "$dep_path" "$root" -} - -assert_no_dep_sentinel() { - local channel="$1" - local dep_path="$2" - local root - root="$(package_root)" - assert_no_package_dep_available "$channel" "$dep_path" "$root" - if [ -n "$(find_external_dep_package "$dep_path")" ]; then - echo "external dependency sentinel should be absent before repair for $channel: $dep_path" >&2 - exit 1 - fi -} - -assert_no_package_dep_available() { - local channel="$1" - local dep_path="$2" - local root="$3" - for candidate in \ - "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$root/dist/extensions/node_modules/$dep_path/package.json" \ - "$root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - echo "packaged install should not mutate package tree for $channel: $candidate" >&2 - exit 1 - fi - done - if [ -f "$HOME/node_modules/$dep_path/package.json" ]; then - echo "bundled runtime deps should not use HOME npm project for $channel: $HOME/node_modules/$dep_path/package.json" >&2 - exit 1 - fi -} - -assert_dep_available() { - local channel="$1" - local dep_path="$2" - local root - local sentinel - root="$(package_root)" - sentinel="$(find_external_dep_package "$dep_path")" - if [ -n "$sentinel" ]; then - assert_no_package_dep_available "$channel" "$dep_path" "$root" - return 0 - fi - echo "missing dependency sentinel for $channel: $dep_path" >&2 - find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true - find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true - find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 -} - -assert_no_dep_available() { - local channel="$1" - local dep_path="$2" - local root - root="$(package_root)" - assert_no_package_dep_available "$channel" "$dep_path" "$root" - if [ -n "$(find_external_dep_package "$dep_path")" ]; then - echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 - exit 1 - fi -} - -remove_runtime_dep() { - local channel="$1" - local dep_path="$2" - local root - root="$(package_root)" - rm -rf "$root/dist/extensions/$channel/node_modules" - rm -rf "$root/dist/extensions/node_modules/$dep_path" - rm -rf "$root/node_modules/$dep_path" - rm -rf "$(stage_root)" -} - -assert_update_ok() { - local json_file="$1" - local expected_before="$2" - node - <<'NODE' "$json_file" "$expected_before" "$candidate_version" -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const expectedBefore = process.argv[3]; -const expectedAfter = process.argv[4]; -if (payload.status !== "ok") { - throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); -} -if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { - throw new Error( - `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, - ); -} -if ((payload.after?.version ?? null) !== expectedAfter) { - throw new Error( - `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, - ); -} -const steps = Array.isArray(payload.steps) ? payload.steps : []; -const doctor = steps.find((step) => step?.name === "openclaw doctor"); -if (!doctor) { - throw new Error("missing openclaw doctor step"); -} -if (Number(doctor.exitCode ?? 1) !== 0) { - throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); -} -NODE -} - -run_update_and_capture() { - local label="$1" - local out_file="$2" - set +e - openclaw update --tag "$update_target" --yes --json >"$out_file" 2>"/tmp/openclaw-$label-update.stderr" - local status=$? - set -e - if [ "$status" -ne 0 ]; then - echo "openclaw update failed for $label with exit code $status" >&2 - cat "$out_file" >&2 || true - cat "/tmp/openclaw-$label-update.stderr" >&2 || true - exit "$status" - fi -} - -should_run_update_target() { - local target="$1" - case ",$UPDATE_TARGETS," in - *",all,"* | *",$target,"*) return 0 ;; - *) return 1 ;; - esac -} - -echo "Installing current candidate as update baseline..." -echo "Update targets: $UPDATE_TARGETS" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-update-baseline-install.log 2>&1 -command -v openclaw >/dev/null -poison_home_npm_project -baseline_root="$(package_root)" -test -d "$baseline_root/dist/extensions/telegram" -test -d "$baseline_root/dist/extensions/feishu" -test -d "$baseline_root/dist/extensions/acpx" - -if should_run_update_target telegram; then - echo "Replicating configured Telegram missing-runtime state..." - write_config telegram - assert_no_dep_available telegram grammy - set +e - openclaw doctor --non-interactive >/tmp/openclaw-baseline-doctor.log 2>&1 - baseline_doctor_status=$? - set -e - echo "baseline doctor exited with $baseline_doctor_status" - remove_runtime_dep telegram grammy - assert_no_dep_available telegram grammy - - echo "Updating from baseline to current candidate; candidate doctor must repair Telegram deps..." - run_update_and_capture telegram /tmp/openclaw-update-telegram.json - cat /tmp/openclaw-update-telegram.json - assert_update_ok /tmp/openclaw-update-telegram.json "$candidate_version" - assert_dep_available telegram grammy - assert_no_unknown_stage_roots - - echo "Mutating installed package: remove Telegram deps, then update-mode doctor repairs them..." - remove_runtime_dep telegram grammy - assert_no_dep_available telegram grammy - if ! OPENCLAW_UPDATE_IN_PROGRESS=1 openclaw doctor --non-interactive >/tmp/openclaw-update-mode-doctor.log 2>&1; then - echo "update-mode doctor failed while repairing Telegram deps" >&2 - cat /tmp/openclaw-update-mode-doctor.log >&2 - exit 1 - fi - assert_dep_available telegram grammy - assert_no_unknown_stage_roots -fi - -if should_run_update_target discord; then - echo "Mutating config to Discord and rerunning same-version update path..." - write_config discord - remove_runtime_dep discord discord-api-types - assert_no_dep_available discord discord-api-types - run_update_and_capture discord /tmp/openclaw-update-discord.json - cat /tmp/openclaw-update-discord.json - assert_update_ok /tmp/openclaw-update-discord.json "$candidate_version" - assert_dep_available discord discord-api-types -fi - -if should_run_update_target slack; then - echo "Mutating config to Slack and rerunning same-version update path..." - write_config slack - remove_runtime_dep slack @slack/web-api - assert_no_dep_available slack @slack/web-api - run_update_and_capture slack /tmp/openclaw-update-slack.json - cat /tmp/openclaw-update-slack.json - assert_update_ok /tmp/openclaw-update-slack.json "$candidate_version" - assert_dep_available slack @slack/web-api -fi - -if should_run_update_target feishu; then - echo "Mutating config to Feishu and rerunning same-version update path..." - write_config feishu - remove_runtime_dep feishu @larksuiteoapi/node-sdk - assert_no_dep_available feishu @larksuiteoapi/node-sdk - run_update_and_capture feishu /tmp/openclaw-update-feishu.json - cat /tmp/openclaw-update-feishu.json - assert_update_ok /tmp/openclaw-update-feishu.json "$candidate_version" - assert_dep_available feishu @larksuiteoapi/node-sdk -fi - -if should_run_update_target memory-lancedb; then - echo "Mutating config to memory-lancedb and rerunning same-version update path..." - write_config memory-lancedb - remove_runtime_dep memory-lancedb @lancedb/lancedb - assert_no_dep_available memory-lancedb @lancedb/lancedb - run_update_and_capture memory-lancedb /tmp/openclaw-update-memory-lancedb.json - cat /tmp/openclaw-update-memory-lancedb.json - assert_update_ok /tmp/openclaw-update-memory-lancedb.json "$candidate_version" - assert_dep_available memory-lancedb @lancedb/lancedb -fi - -if should_run_update_target acpx; then - echo "Removing ACPX runtime package and rerunning same-version update path..." - write_config acpx - remove_runtime_dep acpx acpx - assert_no_dep_available acpx acpx - run_update_and_capture acpx /tmp/openclaw-update-acpx.json - cat /tmp/openclaw-update-acpx.json - assert_update_ok /tmp/openclaw-update-acpx.json "$candidate_version" - assert_dep_available acpx acpx -fi - -echo "bundled channel runtime deps Docker update E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} - -run_load_failure_scenario() { - local run_log - run_log="$(docker_e2e_run_log bundled-channel-load-failure)" - - echo "Running bundled channel load-failure isolation Docker E2E..." - if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' -set -euo pipefail - -export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-load-failure.XXXXXX")" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 - -package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -echo "Installing mounted OpenClaw package..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-load-failure-install.log 2>&1 - -root="$(package_root)" -plugin_dir="$root/dist/extensions/load-failure-alpha" -mkdir -p "$plugin_dir" -cat >"$plugin_dir/package.json" <<'JSON' -{ - "name": "@openclaw/load-failure-alpha", - "version": "2026.4.21", - "private": true, - "type": "module", - "openclaw": { - "extensions": ["./index.js"], - "setupEntry": "./setup-entry.js" - } -} -JSON -cat >"$plugin_dir/openclaw.plugin.json" <<'JSON' -{ - "id": "load-failure-alpha", - "channels": ["load-failure-alpha"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} -JSON -cat >"$plugin_dir/index.js" <<'JS' -export default { - kind: "bundled-channel-entry", - id: "load-failure-alpha", - name: "Load Failure Alpha", - description: "Load Failure Alpha", - register() {}, - loadChannelSecrets() { - globalThis.__loadFailureSecrets = (globalThis.__loadFailureSecrets ?? 0) + 1; - throw new Error("synthetic channel secrets failure"); - }, - loadChannelPlugin() { - globalThis.__loadFailurePlugin = (globalThis.__loadFailurePlugin ?? 0) + 1; - throw new Error("synthetic channel plugin failure"); - } -}; -JS -cat >"$plugin_dir/setup-entry.js" <<'JS' -export default { - kind: "bundled-channel-setup-entry", - loadSetupSecrets() { - globalThis.__loadFailureSetupSecrets = (globalThis.__loadFailureSetupSecrets ?? 0) + 1; - throw new Error("synthetic setup secrets failure"); - }, - loadSetupPlugin() { - globalThis.__loadFailureSetup = (globalThis.__loadFailureSetup ?? 0) + 1; - throw new Error("synthetic setup plugin failure"); - } -}; -JS - -echo "Loading synthetic failing bundled channel through packaged loader..." -( - cd "$root" - OPENCLAW_BUNDLED_PLUGINS_DIR="$root/dist/extensions" node --input-type=module - <<'NODE' -import fs from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const root = process.cwd(); -const distDir = path.join(root, "dist"); -const bundledPath = fs - .readdirSync(distDir) - .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) - .map((entry) => path.join(distDir, entry)) - .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); -if (!bundledPath) { - throw new Error("missing packaged bundled channel loader artifact"); -} -const bundled = await import(pathToFileURL(bundledPath)); -const oneArgExports = Object.entries(bundled).filter( - ([, value]) => typeof value === "function" && value.length === 1, -); -if (oneArgExports.length === 0) { - throw new Error(`missing one-argument bundled loader exports; exports=${Object.keys(bundled).join(",")}`); -} - -const id = "load-failure-alpha"; -for (let i = 0; i < 2; i += 1) { - for (const [name, fn] of oneArgExports) { - try { - fn(id); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("synthetic")) { - throw new Error(`bundled export ${name} leaked synthetic load failure: ${message}`); - } - } - } -} - -const counts = { - plugin: globalThis.__loadFailurePlugin, - setup: globalThis.__loadFailureSetup, - secrets: globalThis.__loadFailureSecrets, - setupSecrets: globalThis.__loadFailureSetupSecrets, -}; -for (const [key, value] of Object.entries({ - plugin: counts.plugin, - setup: counts.setup, - setupSecrets: counts.setupSecrets, -})) { - if (value !== 1) { - throw new Error(`expected ${key} failure to be cached after one load, got ${value}`); - } -} -if (counts.secrets !== undefined && counts.secrets !== 1) { - throw new Error(`expected secrets failure to be cached after one load when exercised, got ${counts.secrets}`); -} -console.log("synthetic bundled channel load failures were isolated and cached"); -NODE -) - -echo "bundled channel load-failure isolation Docker E2E passed" -EOF - then - docker_e2e_print_log "$run_log" - rm -f "$run_log" - exit 1 - fi - - docker_e2e_print_log "$run_log" - rm -f "$run_log" -} run_bundled_channel_runtime_dep_scenarios diff --git a/scripts/e2e/lib/bundled-channel/channel.sh b/scripts/e2e/lib/bundled-channel/channel.sh new file mode 100644 index 00000000000..a9c1e7a3a53 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/channel.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# +# Runs one bundled plugin channel runtime-dependency scenario. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_channel_scenario() { + local channel="$1" + local dep_sentinel="$2" + local run_log + run_log="$(docker_e2e_run_log "bundled-channel-deps-$channel")" + + echo "Running bundled $channel runtime deps Docker E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ + -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-deps.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENAI_API_KEY="sk-openclaw-bundled-channel-deps-e2e" +export OPENCLAW_NO_ONBOARD=1 + +TOKEN="bundled-channel-deps-token" +PORT="18789" +CHANNEL="${OPENCLAW_CHANNEL_UNDER_TEST:?missing OPENCLAW_CHANNEL_UNDER_TEST}" +DEP_SENTINEL="${OPENCLAW_DEP_SENTINEL:?missing OPENCLAW_DEP_SENTINEL}" +gateway_pid="" + +terminate_gateways() { + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill "$gateway_pid" 2>/dev/null || true + fi + if command -v pkill >/dev/null 2>&1; then + pkill -TERM -f "[o]penclaw-gateway" 2>/dev/null || true + fi + for _ in $(seq 1 100); do + local alive=0 + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + alive=1 + fi + if command -v pgrep >/dev/null 2>&1 && pgrep -f "[o]penclaw-gateway" >/dev/null 2>&1; then + alive=1 + fi + [ "$alive" = "0" ] && break + sleep 0.1 + done + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill -KILL "$gateway_pid" 2>/dev/null || true + fi + if command -v pkill >/dev/null 2>&1; then + pkill -KILL -f "[o]penclaw-gateway" 2>/dev/null || true + fi + if [ -n "${gateway_pid:-}" ]; then + wait "$gateway_pid" 2>/dev/null || true + fi +} + +cleanup() { + terminate_gateways +} +trap cleanup EXIT + +echo "Installing mounted OpenClaw package..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-install.log 2>&1 + +command -v openclaw >/dev/null +package_root="$(npm root -g)/openclaw" +test -d "$package_root/dist/extensions/telegram" +test -d "$package_root/dist/extensions/discord" +test -d "$package_root/dist/extensions/slack" +test -d "$package_root/dist/extensions/feishu" +test -d "$package_root/dist/extensions/memory-lancedb" + +stage_root() { + printf "%s/.openclaw/plugin-runtime-deps" "$HOME" +} + +find_external_dep_package() { + local dep_path="$1" + find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true +} + +assert_package_dep_absent() { + local channel="$1" + local dep_path="$2" + for candidate in \ + "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$package_root/dist/extensions/node_modules/$dep_path/package.json" \ + "$package_root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "packaged install should not mutate package tree for $channel: $candidate" >&2 + exit 1 + fi + done +} + +if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then + echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 + find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true + exit 1 +fi + +write_config() { + local mode="$1" + node - <<'NODE' "$mode" "$TOKEN" "$PORT" +const fs = require("node:fs"); +const path = require("node:path"); + +const mode = process.argv[2]; +const token = process.argv[3]; +const port = Number(process.argv[4]); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; + +config.gateway = { + ...(config.gateway || {}), + port, + auth: { mode: "token", token }, + controlUi: { enabled: false }, +}; +config.agents = { + ...(config.agents || {}), + defaults: { + ...(config.agents?.defaults || {}), + model: { primary: "openai/gpt-4.1-mini" }, + }, +}; +config.models = { + ...(config.models || {}), + providers: { + ...(config.models?.providers || {}), + openai: { + ...(config.models?.providers?.openai || {}), + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, +}; +config.plugins = { + ...(config.plugins || {}), + enabled: true, +}; + +if (mode === "telegram") { + config.channels = { + ...(config.channels || {}), + telegram: { + ...(config.channels?.telegram || {}), + enabled: true, + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }; +} +if (mode === "discord") { + config.channels = { + ...(config.channels || {}), + discord: { + ...(config.channels?.discord || {}), + enabled: true, + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }; +} +if (mode === "slack") { + config.channels = { + ...(config.channels || {}), + slack: { + ...(config.channels?.slack || {}), + enabled: true, + }, + }; +} +if (mode === "feishu") { + config.channels = { + ...(config.channels || {}), + feishu: { + ...(config.channels?.feishu || {}), + enabled: true, + }, + }; +} +if (mode === "memory-lancedb") { + config.plugins = { + ...(config.plugins || {}), + enabled: true, + allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], + slots: { + ...(config.plugins?.slots || {}), + memory: "memory-lancedb", + }, + entries: { + ...(config.plugins?.entries || {}), + "memory-lancedb": { + ...(config.plugins?.entries?.["memory-lancedb"] || {}), + enabled: true, + config: { + ...(config.plugins?.entries?.["memory-lancedb"]?.config || {}), + embedding: { + ...(config.plugins?.entries?.["memory-lancedb"]?.config?.embedding || {}), + apiKey: process.env.OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: "~/.openclaw/memory/lancedb-e2e", + autoCapture: false, + autoRecall: false, + }, + }, + }, + }; +} + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE +} + +start_gateway() { + local log_file="$1" + local skip_sidecars="${2:-0}" + : >"$log_file" + if [ "$skip_sidecars" = "1" ]; then + OPENCLAW_SKIP_CHANNELS=1 OPENCLAW_SKIP_PROVIDERS=1 \ + openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & + else + openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & + fi + gateway_pid="$!" + + # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. + for _ in $(seq 1 1200); do + if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then + return 0 + fi + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited unexpectedly" >&2 + cat "$log_file" >&2 + exit 1 + fi + sleep 0.25 + done + + echo "timed out waiting for gateway" >&2 + cat "$log_file" >&2 + exit 1 +} + +stop_gateway() { + terminate_gateways + gateway_pid="" +} + +wait_for_gateway_health() { + local log_file="${1:-}" + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + return 0 + fi + echo "gateway process exited after ready marker" >&2 + if [ -n "$log_file" ]; then + cat "$log_file" >&2 + fi + return 1 +} + +assert_channel_status() { + local channel="$1" + if [ "$channel" = "memory-lancedb" ]; then + echo "memory-lancedb plugin activation verified by dependency sentinel" + return 0 + fi + local out="/tmp/openclaw-channel-status-$channel.json" + local err="/tmp/openclaw-channel-status-$channel.err" + for _ in $(seq 1 12); do + if openclaw gateway call channels.status \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 10000 \ + --json \ + --params '{"probe":false}' >"$out" 2>"$err"; then + break + fi + sleep 2 + done + if [ ! -s "$out" ]; then + if grep -Eq "\\[gateway\\] ready \\(.*\\b$channel\\b" /tmp/openclaw-"$channel"-*.log 2>/dev/null; then + echo "$channel channel plugin visible in gateway ready log" + return 0 + fi + cat "$err" >&2 || true + return 1 + fi + node - <<'NODE' "$out" "$channel" +const fs = require("node:fs"); +const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const payload = raw.result ?? raw.data ?? raw; +const channel = process.argv[3]; +const dump = () => JSON.stringify(raw, null, 2).slice(0, 4000); +const hasChannelMeta = Array.isArray(payload.channelMeta) + ? payload.channelMeta.some((entry) => entry?.id === channel) + : Boolean(payload.channelMeta?.[channel]); +if (!hasChannelMeta) { + throw new Error(`missing channelMeta.${channel}\n${dump()}`); +} +if (!payload.channels || !payload.channels[channel]) { + throw new Error(`missing channels.${channel}\n${dump()}`); +} +const accounts = payload.channelAccounts?.[channel]; +if (!Array.isArray(accounts) || accounts.length === 0) { + throw new Error(`missing channelAccounts.${channel}\n${dump()}`); +} +console.log(`${channel} channel plugin visible`); +NODE +} + +assert_installed_once() { + local log_file="$1" + local channel="$2" + local dep_path="$3" + local count + count="$(grep -Ec "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file" || true)" + if [ "$count" -eq 1 ]; then + return 0 + fi + if [ "$count" -eq 0 ] && [ -n "$(find_external_dep_package "$dep_path")" ]; then + return 0 + fi + echo "expected one runtime deps install log or staged dependency sentinel for $channel, got $count log lines" >&2 + cat "$log_file" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true + exit 1 +} + +assert_not_installed() { + local log_file="$1" + local channel="$2" + if grep -Eq "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file"; then + echo "expected no runtime deps reinstall for $channel" >&2 + cat "$log_file" >&2 + exit 1 + fi +} + +assert_dep_sentinel() { + local channel="$1" + local dep_path="$2" + local sentinel + sentinel="$(find_external_dep_package "$dep_path")" + if [ -z "$sentinel" ]; then + echo "missing external dependency sentinel for $channel: $dep_path" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true + exit 1 + fi + assert_package_dep_absent "$channel" "$dep_path" +} + +assert_no_dep_sentinel() { + local channel="$1" + local dep_path="$2" + assert_package_dep_absent "$channel" "$dep_path" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "external dependency sentinel should be absent before activation for $channel: $dep_path" >&2 + exit 1 + fi +} + +assert_no_install_stage() { + local channel="$1" + local stage="$package_root/dist/extensions/$channel/.openclaw-install-stage" + if [ -e "$stage" ]; then + echo "install stage should be cleaned after activation for $channel" >&2 + find "$stage" -maxdepth 4 -type f | sort | head -80 >&2 || true + exit 1 + fi +} + +echo "Starting baseline gateway with OpenAI configured..." +write_config baseline +start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" 1 +wait_for_gateway_health "/tmp/openclaw-$CHANNEL-baseline.log" +stop_gateway +assert_no_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" + +echo "Enabling $CHANNEL by config edit, then restarting gateway..." +write_config "$CHANNEL" +start_gateway "/tmp/openclaw-$CHANNEL-first.log" +wait_for_gateway_health "/tmp/openclaw-$CHANNEL-first.log" +assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" "$DEP_SENTINEL" +assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" +assert_no_install_stage "$CHANNEL" +assert_channel_status "$CHANNEL" +stop_gateway + +echo "Restarting gateway again; $CHANNEL deps must stay installed..." +start_gateway "/tmp/openclaw-$CHANNEL-second.log" +wait_for_gateway_health "/tmp/openclaw-$CHANNEL-second.log" +assert_not_installed "/tmp/openclaw-$CHANNEL-second.log" "$CHANNEL" +assert_no_install_stage "$CHANNEL" +assert_channel_status "$CHANNEL" +stop_gateway + +echo "bundled $CHANNEL runtime deps Docker E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +} diff --git a/scripts/e2e/lib/bundled-channel/disabled-config.sh b/scripts/e2e/lib/bundled-channel/disabled-config.sh new file mode 100644 index 00000000000..b1c30b56308 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/disabled-config.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# +# Runs disabled-config runtime-dependency isolation scenarios. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_disabled_config_scenario() { + local run_log + run_log="$(docker_e2e_run_log bundled-channel-disabled-config)" + + echo "Running bundled channel disabled-config runtime deps Docker E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-disabled-config.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" +mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +assert_dep_absent_everywhere() { + local channel="$1" + local dep_path="$2" + local root="$3" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "disabled $channel unexpectedly installed $dep_path at $candidate" >&2 + exit 1 + fi + done + + if ! node - <<'NODE' "$OPENCLAW_PLUGIN_STAGE_DIR" "$dep_path" +const fs = require("node:fs"); +const path = require("node:path"); + +const stageDir = process.argv[2]; +const depName = process.argv[3]; +const manifestName = ".openclaw-runtime-deps.json"; +const matches = []; + +function visit(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (entry.name !== manifestName) { + continue; + } + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); + } catch { + continue; + } + const specs = Array.isArray(parsed.specs) ? parsed.specs : []; + for (const spec of specs) { + if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { + matches.push(`${fullPath}: ${spec}`); + } + } + } +} + +visit(stageDir); +if (matches.length > 0) { + process.stderr.write(`${matches.join("\n")}\n`); + process.exit(1); +} +NODE + then + echo "disabled $channel unexpectedly selected $dep_path for external runtime deps" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 + exit 1 + fi +} + +echo "Installing mounted OpenClaw package..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-disabled-config-install.log 2>&1 + +root="$(package_root)" +test -d "$root/dist/extensions/telegram" +test -d "$root/dist/extensions/discord" +test -d "$root/dist/extensions/slack" +rm -rf "$root/dist/extensions/telegram/node_modules" +rm -rf "$root/dist/extensions/discord/node_modules" +rm -rf "$root/dist/extensions/slack/node_modules" + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = { + plugins: { + enabled: true, + entries: { + discord: { enabled: false }, + }, + }, + channels: { + telegram: { + enabled: false, + botToken: "123456:disabled-config-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + slack: { + enabled: false, + botToken: "xoxb-disabled-config-token", + appToken: "xapp-disabled-config-token", + }, + discord: { + enabled: true, + token: "disabled-plugin-entry-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }, +}; +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE + +if ! openclaw doctor --non-interactive >/tmp/openclaw-disabled-config-doctor.log 2>&1; then + echo "doctor failed for disabled-config runtime deps smoke" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 + exit 1 +fi + +assert_dep_absent_everywhere telegram grammy "$root" +assert_dep_absent_everywhere slack @slack/web-api "$root" +assert_dep_absent_everywhere discord discord-api-types "$root" + +if grep -Eq "(used by .*\\b(telegram|slack|discord)\\b|\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps( in [0-9]+ms)?:)" /tmp/openclaw-disabled-config-doctor.log; then + echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 + exit 1 +fi + +echo "bundled channel disabled-config runtime deps Docker E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +} diff --git a/scripts/e2e/lib/bundled-channel/load-failure.sh b/scripts/e2e/lib/bundled-channel/load-failure.sh new file mode 100644 index 00000000000..fad53419a3a --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/load-failure.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Runs load-failure isolation scenarios. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_load_failure_scenario() { + local run_log + run_log="$(docker_e2e_run_log bundled-channel-load-failure)" + + echo "Running bundled channel load-failure isolation Docker E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-load-failure.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_NO_ONBOARD=1 + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +echo "Installing mounted OpenClaw package..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-load-failure-install.log 2>&1 + +root="$(package_root)" +plugin_dir="$root/dist/extensions/load-failure-alpha" +mkdir -p "$plugin_dir" +cat >"$plugin_dir/package.json" <<'JSON' +{ + "name": "@openclaw/load-failure-alpha", + "version": "2026.4.21", + "private": true, + "type": "module", + "openclaw": { + "extensions": ["./index.js"], + "setupEntry": "./setup-entry.js" + } +} +JSON +cat >"$plugin_dir/openclaw.plugin.json" <<'JSON' +{ + "id": "load-failure-alpha", + "channels": ["load-failure-alpha"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} +JSON +cat >"$plugin_dir/index.js" <<'JS' +export default { + kind: "bundled-channel-entry", + id: "load-failure-alpha", + name: "Load Failure Alpha", + description: "Load Failure Alpha", + register() {}, + loadChannelSecrets() { + globalThis.__loadFailureSecrets = (globalThis.__loadFailureSecrets ?? 0) + 1; + throw new Error("synthetic channel secrets failure"); + }, + loadChannelPlugin() { + globalThis.__loadFailurePlugin = (globalThis.__loadFailurePlugin ?? 0) + 1; + throw new Error("synthetic channel plugin failure"); + } +}; +JS +cat >"$plugin_dir/setup-entry.js" <<'JS' +export default { + kind: "bundled-channel-setup-entry", + loadSetupSecrets() { + globalThis.__loadFailureSetupSecrets = (globalThis.__loadFailureSetupSecrets ?? 0) + 1; + throw new Error("synthetic setup secrets failure"); + }, + loadSetupPlugin() { + globalThis.__loadFailureSetup = (globalThis.__loadFailureSetup ?? 0) + 1; + throw new Error("synthetic setup plugin failure"); + } +}; +JS + +echo "Loading synthetic failing bundled channel through packaged loader..." +( + cd "$root" + OPENCLAW_BUNDLED_PLUGINS_DIR="$root/dist/extensions" node --input-type=module - <<'NODE' +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = process.cwd(); +const distDir = path.join(root, "dist"); +const bundledPath = fs + .readdirSync(distDir) + .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) + .map((entry) => path.join(distDir, entry)) + .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); +if (!bundledPath) { + throw new Error("missing packaged bundled channel loader artifact"); +} +const bundled = await import(pathToFileURL(bundledPath)); +const oneArgExports = Object.entries(bundled).filter( + ([, value]) => typeof value === "function" && value.length === 1, +); +if (oneArgExports.length === 0) { + throw new Error(`missing one-argument bundled loader exports; exports=${Object.keys(bundled).join(",")}`); +} + +const id = "load-failure-alpha"; +for (let i = 0; i < 2; i += 1) { + for (const [name, fn] of oneArgExports) { + try { + fn(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("synthetic")) { + throw new Error(`bundled export ${name} leaked synthetic load failure: ${message}`); + } + } + } +} + +const counts = { + plugin: globalThis.__loadFailurePlugin, + setup: globalThis.__loadFailureSetup, + secrets: globalThis.__loadFailureSecrets, + setupSecrets: globalThis.__loadFailureSetupSecrets, +}; +for (const [key, value] of Object.entries({ + plugin: counts.plugin, + setup: counts.setup, + setupSecrets: counts.setupSecrets, +})) { + if (value !== 1) { + throw new Error(`expected ${key} failure to be cached after one load, got ${value}`); + } +} +if (counts.secrets !== undefined && counts.secrets !== 1) { + throw new Error(`expected secrets failure to be cached after one load when exercised, got ${counts.secrets}`); +} +console.log("synthetic bundled channel load failures were isolated and cached"); +NODE +) + +echo "bundled channel load-failure isolation Docker E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +} diff --git a/scripts/e2e/lib/bundled-channel/root-owned.sh b/scripts/e2e/lib/bundled-channel/root-owned.sh new file mode 100644 index 00000000000..8b2f57bed7a --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/root-owned.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# +# Runs the root-owned global install runtime-dependency scenario. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_root_owned_global_scenario() { + local run_log + run_log="$(docker_e2e_run_log bundled-channel-root-owned)" + + echo "Running bundled channel root-owned global install Docker E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm --user root \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="/root" +export OPENAI_API_KEY="sk-openclaw-bundled-channel-root-owned-e2e" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_PLUGIN_STAGE_DIR="/var/lib/openclaw/plugin-runtime-deps" + +TOKEN="bundled-channel-root-owned-token" +PORT="18791" +CHANNEL="slack" +DEP_SENTINEL="@slack/web-api" +gateway_pid="" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +cleanup() { + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill "$gateway_pid" 2>/dev/null || true + wait "$gateway_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "Installing mounted OpenClaw package into root-owned global npm..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-root-owned-install.log 2>&1 + +root="$(package_root)" +test -d "$root/dist/extensions/$CHANNEL" +rm -rf "$root/dist/extensions/$CHANNEL/node_modules" +chmod -R a-w "$root" +mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" /home/appuser/.openclaw +chown -R appuser:appuser /home/appuser/.openclaw /var/lib/openclaw + +if runuser -u appuser -- test -w "$root"; then + echo "expected package root to be unwritable for appuser" >&2 + exit 1 +fi + +node - <<'NODE' "$TOKEN" "$PORT" +const fs = require("node:fs"); +const path = require("node:path"); +const token = process.argv[2]; +const port = Number(process.argv[3]); +const configPath = "/home/appuser/.openclaw/openclaw.json"; +const config = { + gateway: { + port, + auth: { mode: "token", token }, + controlUi: { enabled: false }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + }, + }, + models: { + providers: { + openai: { + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + plugins: { enabled: true }, + channels: { + slack: { + enabled: true, + botToken: "xoxb-bundled-channel-root-owned-token", + appToken: "xapp-bundled-channel-root-owned-token", + }, + }, +}; +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE +chown appuser:appuser /home/appuser/.openclaw/openclaw.json + +start_gateway() { + local log_file="$1" + : >"$log_file" + chown appuser:appuser "$log_file" + runuser -u appuser -- env \ + HOME=/home/appuser \ + OPENAI_API_KEY="$OPENAI_API_KEY" \ + OPENCLAW_NO_ONBOARD=1 \ + OPENCLAW_PLUGIN_STAGE_DIR="$OPENCLAW_PLUGIN_STAGE_DIR" \ + npm_config_cache=/tmp/openclaw-root-owned-npm-cache \ + bash -c 'openclaw gateway --port "$1" --bind loopback --allow-unconfigured >"$2" 2>&1' \ + bash "$PORT" "$log_file" & + gateway_pid="$!" + + # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. + for _ in $(seq 1 1200); do + if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then + return 0 + fi + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited unexpectedly" >&2 + cat "$log_file" >&2 + exit 1 + fi + sleep 0.25 + done + + echo "timed out waiting for gateway" >&2 + cat "$log_file" >&2 + exit 1 +} + +wait_for_slack_provider_start() { + for _ in $(seq 1 180); do + if grep -Eq "\\[slack\\] \\[default\\] starting provider|An API error occurred: invalid_auth|\\[plugins\\] slack installed bundled runtime deps|\\[gateway\\] ready \\(.*\\bslack\\b" /tmp/openclaw-root-owned-gateway.log; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for slack provider startup" >&2 + cat /tmp/openclaw-root-owned-gateway.log >&2 + exit 1 +} + +start_gateway /tmp/openclaw-root-owned-gateway.log +wait_for_slack_provider_start + +if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then + echo "root-owned package tree was mutated" >&2 + find "$root/dist/extensions/$CHANNEL/node_modules" -maxdepth 4 -type f | sort | head -80 >&2 || true + exit 1 +fi +if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_SENTINEL/package.json" -type f | grep -q .; then + echo "missing external staged dependency sentinel for $DEP_SENTINEL" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -120 >&2 || true + cat /tmp/openclaw-root-owned-gateway.log >&2 + exit 1 +fi +if [ -e "$root/dist/extensions/node_modules/openclaw/package.json" ]; then + echo "root-owned package tree was mutated with SDK alias" >&2 + find "$root/dist/extensions/node_modules/openclaw" -maxdepth 4 -type f | sort | head -80 >&2 || true + exit 1 +fi +if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/dist/extensions/node_modules/openclaw/package.json" -type f | grep -q .; then + echo "missing external staged openclaw/plugin-sdk alias" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -120 >&2 || true + cat /tmp/openclaw-root-owned-gateway.log >&2 + exit 1 +fi +if grep -Eq "failed to install bundled runtime deps|Cannot find package 'openclaw'|Cannot find module 'openclaw/plugin-sdk'" /tmp/openclaw-root-owned-gateway.log; then + echo "root-owned gateway hit bundled runtime dependency errors" >&2 + cat /tmp/openclaw-root-owned-gateway.log >&2 + exit 1 +fi + +echo "root-owned global install Docker E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +} diff --git a/scripts/e2e/lib/bundled-channel/setup-entry.sh b/scripts/e2e/lib/bundled-channel/setup-entry.sh new file mode 100644 index 00000000000..ff35352a7f0 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/setup-entry.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# +# Runs setup-entry runtime-dependency installation scenarios. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_setup_entry_scenario() { + local run_log + run_log="$(docker_e2e_run_log bundled-channel-setup-entry)" + + echo "Running bundled channel setup-entry runtime deps Docker E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-setup-entry.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" +mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" + +declare -A SETUP_ENTRY_DEP_SENTINELS=( + [feishu]="@larksuiteoapi/node-sdk" + [whatsapp]="@whiskeysockets/baileys" +) + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +echo "Installing mounted OpenClaw package..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-setup-entry-install.log 2>&1 + +root="$(package_root)" +for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do + dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" + test -d "$root/dist/extensions/$channel" + if [ -d "$root/dist/extensions/$channel/node_modules" ]; then + echo "$channel runtime deps should not be preinstalled in package" >&2 + find "$root/dist/extensions/$channel/node_modules" -maxdepth 3 -type f | head -40 >&2 || true + exit 1 + fi + if [ -f "$root/node_modules/$dep_sentinel/package.json" ]; then + echo "$dep_sentinel should not be installed at package root before setup-entry load" >&2 + exit 1 + fi +done + +echo "Probing real bundled setup entries before channel configuration..." +( + cd "$root" + node --input-type=module - <<'NODE' +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = process.cwd(); +const distDir = path.join(root, "dist"); +const bundledPath = fs + .readdirSync(distDir) + .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) + .map((entry) => path.join(distDir, entry)) + .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); +if (!bundledPath) { + throw new Error("missing packaged bundled channel loader artifact"); +} +const bundled = await import(pathToFileURL(bundledPath)); +const setupPluginLoader = Object.values(bundled).find( + (value) => typeof value === "function" && value.name === "getBundledChannelSetupPlugin", +); +if (!setupPluginLoader) { + throw new Error("missing packaged getBundledChannelSetupPlugin export"); +} +for (const channel of ["feishu", "whatsapp"]) { + const plugin = setupPluginLoader(channel); + if (!plugin) { + throw new Error(`${channel} setup plugin did not load pre-config`); + } + if (plugin.id !== channel) { + throw new Error(`${channel} setup plugin id mismatch: ${plugin.id}`); + } + console.log(`${channel} setup plugin loaded pre-config`); +} +NODE +) + +for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do + dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" + if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then + echo "setup-entry discovery installed $channel deps into bundled plugin tree before channel configuration" >&2 + exit 1 + fi + if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then + echo "setup-entry discovery installed $channel external staged deps before channel configuration" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + exit 1 + fi +done + +echo "Running packaged guided WhatsApp setup; runtime deps should be staged before finalize..." +OPENCLAW_PACKAGE_ROOT="$root" node --input-type=module - <<'NODE' +import path from "node:path"; +import { readdir } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const root = process.env.OPENCLAW_PACKAGE_ROOT; +if (!root) { + throw new Error("missing OPENCLAW_PACKAGE_ROOT"); +} +const distDir = path.join(root, "dist"); +const onboardChannelFiles = (await readdir(distDir)) + .filter((entry) => /^onboard-channels-.*\.js$/.test(entry)) + .sort(); +let setupChannels; +for (const entry of onboardChannelFiles) { + const module = await import(pathToFileURL(path.join(distDir, entry))); + if (typeof module.setupChannels === "function") { + setupChannels = module.setupChannels; + break; + } +} +if (!setupChannels) { + throw new Error( + `could not find packaged setupChannels export in ${JSON.stringify(onboardChannelFiles)}`, + ); +} + +let channelSelectCount = 0; +const notes = []; +const prompter = { + intro: async () => {}, + outro: async () => {}, + note: async (body, title) => { + notes.push({ title, body }); + }, + confirm: async ({ message, initialValue }) => { + if (message === "Link WhatsApp now (QR)?") { + return false; + } + return initialValue ?? true; + }, + select: async ({ message, options }) => { + if (message === "Select a channel") { + channelSelectCount += 1; + return channelSelectCount === 1 ? "whatsapp" : "__done__"; + } + if (message === "Install WhatsApp plugin?") { + if (!options?.some((option) => option.value === "local")) { + throw new Error(`missing bundled local install option: ${JSON.stringify(options)}`); + } + return "local"; + } + if (message === "WhatsApp phone setup") { + return "separate"; + } + if (message === "WhatsApp DM policy") { + return "disabled"; + } + throw new Error(`unexpected select prompt: ${message}`); + }, + multiselect: async ({ message }) => { + throw new Error(`unexpected multiselect prompt: ${message}`); + }, + text: async ({ message }) => { + throw new Error(`unexpected text prompt: ${message}`); + }, +}; +const runtime = { + log: (message) => console.log(message), + error: (message) => console.error(message), +}; + +const result = await setupChannels( + { plugins: { enabled: true } }, + runtime, + prompter, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipStatusNote: true, + skipDmPolicyPrompt: true, + initialSelection: ["whatsapp"], + }, +); + +if (!result.channels?.whatsapp) { + throw new Error(`WhatsApp setup did not write channel config: ${JSON.stringify(result)}`); +} +console.log("packaged guided WhatsApp setup completed"); +NODE + +if [ -e "$root/dist/extensions/whatsapp/node_modules/@whiskeysockets/baileys/package.json" ]; then + echo "expected guided WhatsApp setup deps to be installed externally, not into bundled plugin tree" >&2 + exit 1 +fi +if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/@whiskeysockets/baileys/package.json" -type f | grep -q .; then + echo "guided WhatsApp setup did not stage @whiskeysockets/baileys before finalize" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + exit 1 +fi + +echo "Configuring setup-entry channels; doctor should now install bundled runtime deps externally..." +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; + +config.plugins = { + ...(config.plugins || {}), + enabled: true, +}; +config.channels = { + ...(config.channels || {}), + feishu: { + ...(config.channels?.feishu || {}), + enabled: true, + }, + whatsapp: { + ...(config.channels?.whatsapp || {}), + enabled: true, + }, +}; + +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE + +openclaw doctor --non-interactive >/tmp/openclaw-setup-entry-doctor.log 2>&1 + +for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do + dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" + if [ -e "$root/dist/extensions/$channel/node_modules/$dep_sentinel/package.json" ]; then + echo "expected configured $channel deps to be installed externally, not into bundled plugin tree" >&2 + exit 1 + fi + if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_sentinel/package.json" -type f | grep -q .; then + echo "missing external staged dependency sentinel for configured $channel: $dep_sentinel" >&2 + cat /tmp/openclaw-setup-entry-doctor.log >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + exit 1 + fi +done + +echo "bundled channel setup-entry runtime deps Docker E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +} diff --git a/scripts/e2e/lib/bundled-channel/update.sh b/scripts/e2e/lib/bundled-channel/update.sh new file mode 100644 index 00000000000..89258903289 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/update.sh @@ -0,0 +1,438 @@ +#!/usr/bin/env bash +# +# Runs baseline-to-current bundled plugin update scenarios. +# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. + +run_update_scenario() { + local run_log + run_log="$(docker_e2e_run_log bundled-channel-update)" + + echo "Running bundled channel runtime deps Docker update E2E..." + if ! timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ + -e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-update.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENAI_API_KEY="sk-openclaw-bundled-channel-update-e2e" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_UPDATE_PACKAGE_SPEC="" + +TOKEN="bundled-channel-update-token" +PORT="18790" +UPDATE_TARGETS="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +stage_root() { + printf "%s/.openclaw/plugin-runtime-deps" "$HOME" +} + +poison_home_npm_project() { + printf '{"name":"openclaw-home-prefix-poison","private":true}\n' >"$HOME/package.json" + rm -rf "$HOME/node_modules" + mkdir -p "$HOME/node_modules" + chmod 500 "$HOME/node_modules" +} + +find_external_dep_package() { + local dep_path="$1" + find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true +} + +assert_no_unknown_stage_roots() { + if find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-unknown-*' -print -quit 2>/dev/null | grep -q .; then + echo "runtime deps created second-generation unknown stage roots" >&2 + find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-*' -print | sort >&2 || true + exit 1 + fi +} + +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +update_target="file:$package_tgz" +candidate_version="$(node - <<'NODE' "$package_tgz" +const { execFileSync } = require("node:child_process"); +const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { + encoding: "utf8", +}); +process.stdout.write(String(JSON.parse(raw).version)); +NODE +)" + +write_config() { + local mode="$1" + node - <<'NODE' "$mode" "$TOKEN" "$PORT" +const fs = require("node:fs"); +const path = require("node:path"); + +const mode = process.argv[2]; +const token = process.argv[3]; +const port = Number(process.argv[4]); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; + +config.gateway = { + ...(config.gateway || {}), + port, + auth: { mode: "token", token }, + controlUi: { enabled: false }, +}; +config.agents = { + ...(config.agents || {}), + defaults: { + ...(config.agents?.defaults || {}), + model: { primary: "openai/gpt-4.1-mini" }, + }, +}; +config.models = { + ...(config.models || {}), + providers: { + ...(config.models?.providers || {}), + openai: { + ...(config.models?.providers?.openai || {}), + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, +}; +config.plugins = { + ...(config.plugins || {}), + enabled: true, +}; +config.channels = { + ...(config.channels || {}), + telegram: { + ...(config.channels?.telegram || {}), + enabled: mode === "telegram", + botToken: "123456:bundled-channel-update-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + discord: { + ...(config.channels?.discord || {}), + enabled: mode === "discord", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + slack: { + ...(config.channels?.slack || {}), + enabled: mode === "slack", + botToken: "xoxb-bundled-channel-update-token", + appToken: "xapp-bundled-channel-update-token", + }, + feishu: { + ...(config.channels?.feishu || {}), + enabled: mode === "feishu", + }, +}; +if (mode === "memory-lancedb") { + config.plugins = { + ...(config.plugins || {}), + enabled: true, + allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], + slots: { + ...(config.plugins?.slots || {}), + memory: "memory-lancedb", + }, + entries: { + ...(config.plugins?.entries || {}), + "memory-lancedb": { + ...(config.plugins?.entries?.["memory-lancedb"] || {}), + enabled: true, + config: { + ...(config.plugins?.entries?.["memory-lancedb"]?.config || {}), + embedding: { + ...(config.plugins?.entries?.["memory-lancedb"]?.config?.embedding || {}), + apiKey: process.env.OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: "~/.openclaw/memory/lancedb-update-e2e", + autoCapture: false, + autoRecall: false, + }, + }, + }, + }; +} +if (mode === "acpx") { + config.plugins = { + ...(config.plugins || {}), + enabled: true, + allow: + Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0 + ? [...new Set([...config.plugins.allow, "acpx"])] + : config.plugins?.allow, + entries: { + ...(config.plugins?.entries || {}), + acpx: { + ...(config.plugins?.entries?.acpx || {}), + enabled: true, + }, + }, + }; +} + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE +} + +assert_dep_sentinel() { + local channel="$1" + local dep_path="$2" + local root + local sentinel + root="$(package_root)" + sentinel="$(find_external_dep_package "$dep_path")" + if [ -z "$sentinel" ]; then + echo "missing external dependency sentinel for $channel: $dep_path" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true + exit 1 + fi + assert_no_package_dep_available "$channel" "$dep_path" "$root" +} + +assert_no_dep_sentinel() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + assert_no_package_dep_available "$channel" "$dep_path" "$root" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "external dependency sentinel should be absent before repair for $channel: $dep_path" >&2 + exit 1 + fi +} + +assert_no_package_dep_available() { + local channel="$1" + local dep_path="$2" + local root="$3" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "packaged install should not mutate package tree for $channel: $candidate" >&2 + exit 1 + fi + done + if [ -f "$HOME/node_modules/$dep_path/package.json" ]; then + echo "bundled runtime deps should not use HOME npm project for $channel: $HOME/node_modules/$dep_path/package.json" >&2 + exit 1 + fi +} + +assert_dep_available() { + local channel="$1" + local dep_path="$2" + local root + local sentinel + root="$(package_root)" + sentinel="$(find_external_dep_package "$dep_path")" + if [ -n "$sentinel" ]; then + assert_no_package_dep_available "$channel" "$dep_path" "$root" + return 0 + fi + echo "missing dependency sentinel for $channel: $dep_path" >&2 + find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true + exit 1 +} + +assert_no_dep_available() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + assert_no_package_dep_available "$channel" "$dep_path" "$root" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 + exit 1 + fi +} + +remove_runtime_dep() { + local channel="$1" + local dep_path="$2" + local root + root="$(package_root)" + rm -rf "$root/dist/extensions/$channel/node_modules" + rm -rf "$root/dist/extensions/node_modules/$dep_path" + rm -rf "$root/node_modules/$dep_path" + rm -rf "$(stage_root)" +} + +assert_update_ok() { + local json_file="$1" + local expected_before="$2" + node - <<'NODE' "$json_file" "$expected_before" "$candidate_version" +const fs = require("node:fs"); +const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expectedBefore = process.argv[3]; +const expectedAfter = process.argv[4]; +if (payload.status !== "ok") { + throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); +} +if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { + throw new Error( + `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, + ); +} +if ((payload.after?.version ?? null) !== expectedAfter) { + throw new Error( + `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, + ); +} +const steps = Array.isArray(payload.steps) ? payload.steps : []; +const doctor = steps.find((step) => step?.name === "openclaw doctor"); +if (!doctor) { + throw new Error("missing openclaw doctor step"); +} +if (Number(doctor.exitCode ?? 1) !== 0) { + throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); +} +NODE +} + +run_update_and_capture() { + local label="$1" + local out_file="$2" + set +e + openclaw update --tag "$update_target" --yes --json >"$out_file" 2>"/tmp/openclaw-$label-update.stderr" + local status=$? + set -e + if [ "$status" -ne 0 ]; then + echo "openclaw update failed for $label with exit code $status" >&2 + cat "$out_file" >&2 || true + cat "/tmp/openclaw-$label-update.stderr" >&2 || true + exit "$status" + fi +} + +should_run_update_target() { + local target="$1" + case ",$UPDATE_TARGETS," in + *",all,"* | *",$target,"*) return 0 ;; + *) return 1 ;; + esac +} + +echo "Installing current candidate as update baseline..." +echo "Update targets: $UPDATE_TARGETS" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-update-baseline-install.log 2>&1 +command -v openclaw >/dev/null +poison_home_npm_project +baseline_root="$(package_root)" +test -d "$baseline_root/dist/extensions/telegram" +test -d "$baseline_root/dist/extensions/feishu" +test -d "$baseline_root/dist/extensions/acpx" + +if should_run_update_target telegram; then + echo "Replicating configured Telegram missing-runtime state..." + write_config telegram + assert_no_dep_available telegram grammy + set +e + openclaw doctor --non-interactive >/tmp/openclaw-baseline-doctor.log 2>&1 + baseline_doctor_status=$? + set -e + echo "baseline doctor exited with $baseline_doctor_status" + remove_runtime_dep telegram grammy + assert_no_dep_available telegram grammy + + echo "Updating from baseline to current candidate; candidate doctor must repair Telegram deps..." + run_update_and_capture telegram /tmp/openclaw-update-telegram.json + cat /tmp/openclaw-update-telegram.json + assert_update_ok /tmp/openclaw-update-telegram.json "$candidate_version" + assert_dep_available telegram grammy + assert_no_unknown_stage_roots + + echo "Mutating installed package: remove Telegram deps, then update-mode doctor repairs them..." + remove_runtime_dep telegram grammy + assert_no_dep_available telegram grammy + if ! OPENCLAW_UPDATE_IN_PROGRESS=1 openclaw doctor --non-interactive >/tmp/openclaw-update-mode-doctor.log 2>&1; then + echo "update-mode doctor failed while repairing Telegram deps" >&2 + cat /tmp/openclaw-update-mode-doctor.log >&2 + exit 1 + fi + assert_dep_available telegram grammy + assert_no_unknown_stage_roots +fi + +if should_run_update_target discord; then + echo "Mutating config to Discord and rerunning same-version update path..." + write_config discord + remove_runtime_dep discord discord-api-types + assert_no_dep_available discord discord-api-types + run_update_and_capture discord /tmp/openclaw-update-discord.json + cat /tmp/openclaw-update-discord.json + assert_update_ok /tmp/openclaw-update-discord.json "$candidate_version" + assert_dep_available discord discord-api-types +fi + +if should_run_update_target slack; then + echo "Mutating config to Slack and rerunning same-version update path..." + write_config slack + remove_runtime_dep slack @slack/web-api + assert_no_dep_available slack @slack/web-api + run_update_and_capture slack /tmp/openclaw-update-slack.json + cat /tmp/openclaw-update-slack.json + assert_update_ok /tmp/openclaw-update-slack.json "$candidate_version" + assert_dep_available slack @slack/web-api +fi + +if should_run_update_target feishu; then + echo "Mutating config to Feishu and rerunning same-version update path..." + write_config feishu + remove_runtime_dep feishu @larksuiteoapi/node-sdk + assert_no_dep_available feishu @larksuiteoapi/node-sdk + run_update_and_capture feishu /tmp/openclaw-update-feishu.json + cat /tmp/openclaw-update-feishu.json + assert_update_ok /tmp/openclaw-update-feishu.json "$candidate_version" + assert_dep_available feishu @larksuiteoapi/node-sdk +fi + +if should_run_update_target memory-lancedb; then + echo "Mutating config to memory-lancedb and rerunning same-version update path..." + write_config memory-lancedb + remove_runtime_dep memory-lancedb @lancedb/lancedb + assert_no_dep_available memory-lancedb @lancedb/lancedb + run_update_and_capture memory-lancedb /tmp/openclaw-update-memory-lancedb.json + cat /tmp/openclaw-update-memory-lancedb.json + assert_update_ok /tmp/openclaw-update-memory-lancedb.json "$candidate_version" + assert_dep_available memory-lancedb @lancedb/lancedb +fi + +if should_run_update_target acpx; then + echo "Removing ACPX runtime package and rerunning same-version update path..." + write_config acpx + remove_runtime_dep acpx acpx + assert_no_dep_available acpx acpx + run_update_and_capture acpx /tmp/openclaw-update-acpx.json + cat /tmp/openclaw-update-acpx.json + assert_update_ok /tmp/openclaw-update-acpx.json "$candidate_version" + assert_dep_available acpx acpx +fi + +echo "bundled channel runtime deps Docker update E2E passed" +EOF + then + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 + fi + + docker_e2e_print_log "$run_log" + rm -f "$run_log" +}