diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 86535b3fbf2..a6a6c1cda28 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -624,6 +624,73 @@ run_ref_onboard() { --json } +inject_bad_plugin_fixture() { + guest_exec bash -lc "$(cat <<'EOF' +set -euo pipefail +plugin_dir=/root/.openclaw/test-bad-plugin +mkdir -p "$plugin_dir" +cat >"$plugin_dir/package.json" <<'JSON' +{ + "name": "@openclaw/test-bad-plugin", + "version": "1.0.0", + "openclaw": { + "extensions": ["./index.cjs"], + "setupEntry": "./setup-entry.cjs" + } +} +JSON +cat >"$plugin_dir/openclaw.plugin.json" <<'JSON' +{ + "id": "test-bad-plugin", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "channels": ["test-bad-plugin"] +} +JSON +cat >"$plugin_dir/index.cjs" <<'JS' +module.exports = { id: "test-bad-plugin", register() {} }; +JS +cat >"$plugin_dir/setup-entry.cjs" <<'JS' +module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin() { + throw new Error("boom: bad plugin smoke fixture"); + }, +}; +JS +python3 - <<'PY' +import json +from pathlib import Path + +config_path = Path("/root/.openclaw/openclaw.json") +config = {} +if config_path.exists(): + config = json.loads(config_path.read_text()) + +plugins = config.setdefault("plugins", {}) +load = plugins.setdefault("load", {}) +paths = load.setdefault("paths", []) +plugin_dir = "/root/.openclaw/test-bad-plugin" +if plugin_dir not in paths: + paths.append(plugin_dir) + +allow = plugins.get("allow") +if isinstance(allow, list) and "test-bad-plugin" not in allow: + allow.append("test-bad-plugin") + +config_path.write_text(json.dumps(config, indent=2) + "\n") +PY +EOF +)" +} + +verify_bad_plugin_diagnostic() { + guest_exec grep -F "failed to load setup entry" /tmp/openclaw-parallels-linux-gateway.log +} + start_gateway_background() { local cmd api_key_value_q api_key_value_q="$(shell_quote "$API_KEY_VALUE")" @@ -786,8 +853,10 @@ run_fresh_main_lane() { phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version + phase_run "fresh.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard phase_run "fresh.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background + phase_run "fresh.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic phase_run "fresh.gateway-status" "$TIMEOUT_VERIFY_S" show_gateway_status_compat FRESH_GATEWAY_STATUS="pass" phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn @@ -805,8 +874,10 @@ run_upgrade_lane() { phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version + phase_run "upgrade.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard phase_run "upgrade.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background + phase_run "upgrade.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic phase_run "upgrade.gateway-status" "$TIMEOUT_VERIFY_S" show_gateway_status_compat UPGRADE_GATEWAY_STATUS="pass" phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 7e36dc9f4a6..d330fd3c8aa 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -859,6 +859,60 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("keeps the channel picker usable when the active registry contains broken sibling diagnostics", async () => { + const registry = createTestRegistry([ + { + pluginId: "healthy-channel", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "external-chat", + label: "Healthy Chat", + docsPath: "/channels/external-chat", + }), + }, + }, + ]); + registry.diagnostics.push({ + level: "error", + pluginId: "broken-channel", + source: "/tmp/broken-channel/setup-entry.cjs", + message: "failed to load setup entry: boom: setup plugin missing", + }); + setActivePluginRegistry(registry); + + const note = vi.fn(async (_message?: string, _title?: string) => {}); + const { multiselect, text } = createUnexpectedPromptGuards(); + const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + if (message === "Select a channel") { + const entries = options as Array<{ value: string; label?: string }>; + expect(entries.find((entry) => entry.value === "external-chat")?.label).toBe( + "Healthy Chat", + ); + expect(entries.some((entry) => entry.value === "broken-channel")).toBe(false); + return "__done__"; + } + return "__done__"; + }); + + const prompter = createPrompter({ + note, + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); + expect( + note.mock.calls.some((call) => + (call[0] ?? "").includes("broken-channel plugin not available"), + ), + ).toBe(false); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("keeps configured external plugin channels visible when the active registry starts empty", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d9a1696ecb5..8361bd4dc59 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3437,6 +3437,105 @@ module.exports = { expect(diagnostic!.message).toContain("failed to load setup entry"); }); + it("keeps healthy sibling channel plugins loadable when a setup entry throws", () => { + useNoBundledPlugins(); + const brokenDir = makeTempDir(); + + fs.writeFileSync( + path.join(brokenDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-throws-sibling-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(brokenDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-throws-sibling-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["broken-chat"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(brokenDir, "index.cjs"), + `module.exports = { id: "setup-entry-throws-sibling-test", register() {} };`, + "utf-8", + ); + fs.writeFileSync( + path.join(brokenDir, "setup-entry.cjs"), + `module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin: () => { throw new Error("boom: setup plugin missing"); }, +};`, + "utf-8", + ); + + const healthy = writePlugin({ + id: "healthy-channel", + filename: "healthy-channel.cjs", + body: `module.exports = { id: "healthy-channel", register(api) { + api.registerChannel({ + plugin: { + id: "healthy-chat", + meta: { + id: "healthy-chat", + label: "Healthy Chat", + selectionLabel: "Healthy Chat", + docsPath: "/channels/healthy-chat", + blurb: "healthy sibling channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + } + }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + load: { paths: [brokenDir, healthy.file] }, + allow: ["setup-entry-throws-sibling-test", "healthy-channel"], + }, + }, + }); + + expect( + registry.channels.find((entry) => entry.plugin.id === "healthy-chat")?.plugin.meta, + ).toMatchObject({ + label: "Healthy Chat", + docsPath: "/channels/healthy-chat", + }); + expect(registry.plugins.find((entry) => entry.id === "healthy-channel")?.status).toBe("loaded"); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "setup-entry-throws-sibling-test" && + diag.level === "error" && + diag.message.includes("failed to load setup entry"), + ), + ).toBe(true); + }); + it("prefers setupEntry for configured channel loads during startup when opted in", () => { expect( __testing.shouldLoadChannelPluginInSetupRuntime({