diff --git a/scripts/e2e/lib/bundled-channel/channel.sh b/scripts/e2e/lib/bundled-channel/channel.sh index 325d3affc85..dc9acc33eba 100644 --- a/scripts/e2e/lib/bundled-channel/channel.sh +++ b/scripts/e2e/lib/bundled-channel/channel.sh @@ -68,9 +68,7 @@ cleanup() { } 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 +bundled_channel_install_package /tmp/openclaw-install.log command -v openclaw >/dev/null package_root="$(npm root -g)/openclaw" diff --git a/scripts/e2e/lib/bundled-channel/common.sh b/scripts/e2e/lib/bundled-channel/common.sh index d9b294aea20..ca82941ba09 100644 --- a/scripts/e2e/lib/bundled-channel/common.sh +++ b/scripts/e2e/lib/bundled-channel/common.sh @@ -12,11 +12,36 @@ bundled_channel_stage_root() { printf "%s/.openclaw/plugin-runtime-deps" "$HOME" } +bundled_channel_stage_dir() { + printf "%s" "${OPENCLAW_PLUGIN_STAGE_DIR:-$(bundled_channel_stage_root)}" +} + +bundled_channel_install_package() { + local log_file="$1" + local label="${2:-mounted OpenClaw package}" + local package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" + echo "Installing $label..." + if ! npm install -g "$package_tgz" --no-fund --no-audit >"$log_file" 2>&1; then + echo "npm install -g failed for $label" >&2 + cat "$log_file" >&2 || true + exit 1 + fi +} + bundled_channel_find_external_dep_package() { local dep_path="$1" find "$(bundled_channel_stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true } +bundled_channel_find_staged_dep_package() { + local dep_path="$1" + find "$(bundled_channel_stage_dir)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true +} + +bundled_channel_dump_stage_dir() { + find "$(bundled_channel_stage_dir)" -maxdepth 12 -type f | sort | head -160 >&2 || true +} + bundled_channel_assert_no_package_dep_available() { local channel="$1" local dep_path="$2" @@ -62,6 +87,91 @@ bundled_channel_assert_no_dep_available() { fi } +bundled_channel_assert_no_staged_dep() { + local channel="$1" + local dep_path="$2" + local message="${3:-$channel unexpectedly staged $dep_path}" + if [ -n "$(bundled_channel_find_staged_dep_package "$dep_path")" ]; then + echo "$message" >&2 + bundled_channel_dump_stage_dir + exit 1 + fi +} + +bundled_channel_assert_staged_dep() { + local channel="$1" + local dep_path="$2" + local log_file="${3:-}" + if [ -n "$(bundled_channel_find_staged_dep_package "$dep_path")" ]; then + return 0 + fi + echo "missing external staged dependency sentinel for $channel: $dep_path" >&2 + if [ -n "$log_file" ]; then + cat "$log_file" >&2 || true + fi + bundled_channel_dump_stage_dir + exit 1 +} + +bundled_channel_assert_no_staged_manifest_spec() { + local channel="$1" + local dep_path="$2" + local log_file="${3:-}" + if ! node - <<'NODE' "$(bundled_channel_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 "$channel unexpectedly selected $dep_path for external runtime deps" >&2 + if [ -n "$log_file" ]; then + cat "$log_file" >&2 || true + fi + exit 1 + fi +} + bundled_channel_remove_runtime_dep() { local channel="$1" local dep_path="$2" @@ -74,18 +184,63 @@ bundled_channel_remove_runtime_dep() { bundled_channel_write_config() { local mode="$1" - node - <<'NODE' "$mode" "${TOKEN:?missing TOKEN}" "${PORT:?missing PORT}" + node - <<'NODE' "$mode" "${TOKEN:-bundled-channel-config-token}" "${PORT:-18789}" 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 configPath = + process.env.OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH || + path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +if (mode === "disabled-config") { + const stateDir = path.dirname(configPath); + const disabledConfig = { + gateway: { + mode: "local", + auth: { + mode: "token", + token: "disabled-config-runtime-deps-token", + }, + }, + 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.join(stateDir, "agents", "main", "sessions"), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(disabledConfig, null, 2)}\n`, "utf8"); + fs.chmodSync(stateDir, 0o700); + fs.chmodSync(configPath, 0o600); + process.exit(0); +} + config.gateway = { ...(config.gateway || {}), port, @@ -120,7 +275,9 @@ config.channels = { telegram: { ...(config.channels?.telegram || {}), enabled: mode === "telegram", - botToken: "123456:bundled-channel-update-token", + botToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_TELEGRAM_TOKEN || + "123456:bundled-channel-update-token", dmPolicy: "disabled", groupPolicy: "disabled", }, @@ -133,8 +290,12 @@ config.channels = { slack: { ...(config.channels?.slack || {}), enabled: mode === "slack", - botToken: "xoxb-bundled-channel-update-token", - appToken: "xapp-bundled-channel-update-token", + botToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN || + "xoxb-bundled-channel-update-token", + appToken: + process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN || + "xapp-bundled-channel-update-token", }, feishu: { ...(config.channels?.feishu || {}), @@ -187,6 +348,23 @@ if (mode === "acpx") { }, }; } +if (mode === "setup-entry-channels") { + config.plugins = { + ...(config.plugins || {}), + enabled: true, + }; + config.channels = { + ...(config.channels || {}), + feishu: { + ...(config.channels?.feishu || {}), + enabled: true, + }, + whatsapp: { + ...(config.channels?.whatsapp || {}), + enabled: true, + }, + }; +} fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); diff --git a/scripts/e2e/lib/bundled-channel/disabled-config.sh b/scripts/e2e/lib/bundled-channel/disabled-config.sh index 62d4b757a6b..d8587768562 100644 --- a/scripts/e2e/lib/bundled-channel/disabled-config.sh +++ b/scripts/e2e/lib/bundled-channel/disabled-config.sh @@ -29,72 +29,11 @@ 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}`); - } - } - } + bundled_channel_assert_no_package_dep_available "$channel" "$dep_path" "$root" + bundled_channel_assert_no_staged_manifest_spec "$channel" "$dep_path" /tmp/openclaw-disabled-config-doctor.log } -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 +bundled_channel_install_package /tmp/openclaw-disabled-config-install.log root="$(bundled_channel_package_root)" test -d "$root/dist/extensions/telegram" @@ -104,51 +43,7 @@ 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 stateDir = path.dirname(configPath); -const config = { - gateway: { - mode: "local", - auth: { - mode: "token", - token: "disabled-config-runtime-deps-token", - }, - }, - 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.join(stateDir, "agents", "main", "sessions"), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -fs.chmodSync(stateDir, 0o700); -fs.chmodSync(configPath, 0o600); -NODE +bundled_channel_write_config disabled-config if ! openclaw doctor --non-interactive >/tmp/openclaw-disabled-config-doctor.log 2>&1; then echo "doctor failed for disabled-config runtime deps smoke" >&2 diff --git a/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs b/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs new file mode 100644 index 00000000000..4dac06b6905 --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = process.argv[2] || process.env.OPENCLAW_PACKAGE_ROOT; +if (!root) { + throw new Error("missing package root"); +} + +const distDir = path.join(root, "dist"); +const onboardChannelFiles = (await readdir(distDir)) + .filter((entry) => /^onboard-channels-.*\.js$/.test(entry)) + .toSorted(); +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"); diff --git a/scripts/e2e/lib/bundled-channel/load-failure.sh b/scripts/e2e/lib/bundled-channel/load-failure.sh index 7abecb89484..6949dab0658 100644 --- a/scripts/e2e/lib/bundled-channel/load-failure.sh +++ b/scripts/e2e/lib/bundled-channel/load-failure.sh @@ -23,9 +23,7 @@ export NPM_CONFIG_PREFIX="$HOME/.npm-global" export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" export OPENCLAW_NO_ONBOARD=1 -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 +bundled_channel_install_package /tmp/openclaw-load-failure-install.log root="$(bundled_channel_package_root)" plugin_dir="$root/dist/extensions/load-failure-alpha" @@ -85,85 +83,7 @@ export default { 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 loaderNames = [ - "getBundledChannelPlugin", - "getBundledChannelSetupPlugin", - "getBundledChannelSecrets", - "getBundledChannelSetupSecrets", -]; -const exportedLoaders = new Map( - Object.values(bundled) - .filter((value) => typeof value === "function") - .map((fn) => [fn.name, fn]), -); -const loaders = loaderNames.map((name) => { - const fn = exportedLoaders.get(name); - if (typeof fn !== "function") { - throw new Error(`missing packaged bundled loader export ${name}; exports=${Object.keys(bundled).join(",")}`); - } - return [name, fn]; -}); - -const id = "load-failure-alpha"; -function exerciseLoaders() { - for (const [name, fn] of loaders) { - 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}`); - } - } - } -} - -function loadCounts() { - return { - plugin: globalThis.__loadFailurePlugin, - setup: globalThis.__loadFailureSetup, - secrets: globalThis.__loadFailureSecrets, - setupSecrets: globalThis.__loadFailureSetupSecrets, - }; -} - -exerciseLoaders(); -const firstCounts = loadCounts(); -exerciseLoaders(); -const secondCounts = loadCounts(); -for (const key of ["plugin", "setup", "setupSecrets"]) { - const first = firstCounts[key]; - if (!Number.isInteger(first) || first < 1) { - throw new Error(`expected ${key} failure to be exercised at least once, got ${first}`); - } - if (secondCounts[key] !== first) { - throw new Error(`expected ${key} failure to be cached after first pass, got ${first} then ${secondCounts[key]}`); - } -} -if (firstCounts.secrets !== undefined && secondCounts.secrets !== firstCounts.secrets) { - throw new Error(`expected secrets failure to be cached after first pass, got ${firstCounts.secrets} then ${secondCounts.secrets}`); -} -console.log("synthetic bundled channel load failures were isolated and cached"); -NODE -) +node scripts/e2e/lib/bundled-channel/loader-probe.mjs load-failure "$root" load-failure-alpha echo "bundled channel load-failure isolation Docker E2E passed" EOF diff --git a/scripts/e2e/lib/bundled-channel/loader-probe.mjs b/scripts/e2e/lib/bundled-channel/loader-probe.mjs new file mode 100644 index 00000000000..3add66ba35d --- /dev/null +++ b/scripts/e2e/lib/bundled-channel/loader-probe.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function usage() { + console.error("Usage: loader-probe.mjs [channel...]"); + process.exit(2); +} + +function findBundledLoader(root) { + 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"); + } + return bundledPath; +} + +function namedExport(module, name) { + const fn = Object.values(module).find( + (value) => typeof value === "function" && value.name === name, + ); + if (typeof fn !== "function") { + throw new Error( + `missing packaged bundled loader export ${name}; exports=${Object.keys(module).join(",")}`, + ); + } + return fn; +} + +async function importBundled(root) { + return import(pathToFileURL(findBundledLoader(root))); +} + +function loadCounts() { + return { + plugin: globalThis.__loadFailurePlugin, + setup: globalThis.__loadFailureSetup, + secrets: globalThis.__loadFailureSecrets, + setupSecrets: globalThis.__loadFailureSetupSecrets, + }; +} + +function exerciseLoaders(loaders, id) { + for (const [name, fn] of loaders) { + 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}`, { + cause: error, + }); + } + } + } +} + +const [command, root, ...args] = process.argv.slice(2); +if (!command || !root) { + usage(); +} + +if (command === "load-failure") { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist/extensions"); +} + +const bundled = await importBundled(root); + +if (command === "setup-entries") { + const channels = args.length > 0 ? args : ["feishu", "whatsapp"]; + const setupPluginLoader = namedExport(bundled, "getBundledChannelSetupPlugin"); + for (const channel of channels) { + 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`); + } +} else if (command === "load-failure") { + const id = args[0] || "load-failure-alpha"; + const loaderNames = [ + "getBundledChannelPlugin", + "getBundledChannelSetupPlugin", + "getBundledChannelSecrets", + "getBundledChannelSetupSecrets", + ]; + const loaders = loaderNames.map((name) => [name, namedExport(bundled, name)]); + + exerciseLoaders(loaders, id); + const firstCounts = loadCounts(); + exerciseLoaders(loaders, id); + const secondCounts = loadCounts(); + for (const key of ["plugin", "setup", "setupSecrets"]) { + const first = firstCounts[key]; + if (!Number.isInteger(first) || first < 1) { + throw new Error(`expected ${key} failure to be exercised at least once, got ${first}`); + } + if (secondCounts[key] !== first) { + throw new Error( + `expected ${key} failure to be cached after first pass, got ${first} then ${secondCounts[key]}`, + ); + } + } + if (firstCounts.secrets !== undefined && secondCounts.secrets !== firstCounts.secrets) { + throw new Error( + `expected secrets failure to be cached after first pass, got ${firstCounts.secrets} then ${secondCounts.secrets}`, + ); + } + console.log("synthetic bundled channel load failures were isolated and cached"); +} else { + usage(); +} diff --git a/scripts/e2e/lib/bundled-channel/root-owned.sh b/scripts/e2e/lib/bundled-channel/root-owned.sh index e481c6479a3..d1c1ae3e8fd 100644 --- a/scripts/e2e/lib/bundled-channel/root-owned.sh +++ b/scripts/e2e/lib/bundled-channel/root-owned.sh @@ -33,13 +33,7 @@ cleanup() { } trap cleanup EXIT -echo "Installing mounted OpenClaw package into root-owned global npm..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -if ! npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-root-owned-install.log 2>&1; then - echo "root-owned global npm install failed" >&2 - cat /tmp/openclaw-root-owned-install.log >&2 - exit 1 -fi +bundled_channel_install_package /tmp/openclaw-root-owned-install.log "mounted OpenClaw package into root-owned global npm" root="$(bundled_channel_package_root)" test -d "$root/dist/extensions/$CHANNEL" @@ -53,44 +47,10 @@ if runuser -u appuser -- test -w "$root"; then 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 +OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH=/home/appuser/.openclaw/openclaw.json \ + OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN=xoxb-bundled-channel-root-owned-token \ + OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN=xapp-bundled-channel-root-owned-token \ + bundled_channel_write_config slack chown appuser:appuser /home/appuser/.openclaw/openclaw.json start_gateway() { @@ -140,25 +100,16 @@ wait_for_slack_provider_start() { 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 +bundled_channel_assert_no_package_dep_available "$CHANNEL" "$DEP_SENTINEL" "$root" +bundled_channel_assert_staged_dep "$CHANNEL" "$DEP_SENTINEL" /tmp/openclaw-root-owned-gateway.log 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 +if ! find "$(bundled_channel_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 + bundled_channel_dump_stage_dir cat /tmp/openclaw-root-owned-gateway.log >&2 exit 1 fi diff --git a/scripts/e2e/lib/bundled-channel/setup-entry.sh b/scripts/e2e/lib/bundled-channel/setup-entry.sh index baa2c35108a..438cea3ce95 100644 --- a/scripts/e2e/lib/bundled-channel/setup-entry.sh +++ b/scripts/e2e/lib/bundled-channel/setup-entry.sh @@ -30,225 +30,39 @@ declare -A SETUP_ENTRY_DEP_SENTINELS=( [whatsapp]="@whiskeysockets/baileys" ) -echo "Installing mounted OpenClaw package..." -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -if ! npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-setup-entry-install.log 2>&1; then - cat /tmp/openclaw-setup-entry-install.log >&2 || true - exit 1 -fi +bundled_channel_install_package /tmp/openclaw-setup-entry-install.log root="$(bundled_channel_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 + bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" 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 -) +node scripts/e2e/lib/bundled-channel/loader-probe.mjs setup-entries "$root" feishu whatsapp 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 + bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" + bundled_channel_assert_no_staged_dep "$channel" "$dep_sentinel" "setup-entry discovery installed $channel external staged deps before channel configuration" 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"; +node scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs "$root" -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 +bundled_channel_assert_no_package_dep_available whatsapp @whiskeysockets/baileys "$root" +bundled_channel_assert_staged_dep whatsapp @whiskeysockets/baileys 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 +bundled_channel_write_config setup-entry-channels 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 + bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" + bundled_channel_assert_staged_dep "$channel" "$dep_sentinel" /tmp/openclaw-setup-entry-doctor.log done echo "bundled channel setup-entry runtime deps Docker E2E passed" diff --git a/scripts/e2e/lib/bundled-channel/update.sh b/scripts/e2e/lib/bundled-channel/update.sh index adcb7904b73..434684c42e0 100644 --- a/scripts/e2e/lib/bundled-channel/update.sh +++ b/scripts/e2e/lib/bundled-channel/update.sh @@ -113,9 +113,8 @@ should_run_update_target() { 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 +bundled_channel_install_package /tmp/openclaw-update-baseline-install.log "current candidate as update baseline" command -v openclaw >/dev/null poison_home_npm_project baseline_root="$(bundled_channel_package_root)"