From 32216a82f5e487fa3f4fb462a028b869e4c5ade1 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:52:43 +0800 Subject: [PATCH] fix(agent-runner): validate clean marketplace onboarding --- docs/agent-runner-pluginization/STATUS.md | 6 +- .../e2e/bot-event-routing-product-flow.mjs | 10 +- .../e2e/lib/isolated-langbot-instance.mjs | 200 ++++++++++++++++++ .../e2e/prepare-isolated-langbot-config.py | 45 ++++ .../e2e/wizard-runner-marketplace-catalog.mjs | 148 +++++++++++-- skills/skills.index.json | 2 +- .../wizard-runner-marketplace-catalog.yaml | 30 ++- src/langbot/pkg/plugin/connector.py | 23 +- .../unit_tests/plugin/test_connector_pure.py | 19 ++ web/src/app/infra/http/CloudServiceClient.ts | 19 +- 10 files changed, 461 insertions(+), 41 deletions(-) create mode 100644 skills/scripts/e2e/lib/isolated-langbot-instance.mjs create mode 100644 skills/scripts/e2e/prepare-isolated-langbot-config.py diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index 04e83071a..1ece8013a 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -20,7 +20,7 @@ | Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 | | Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | | SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | -| EBA processor routing | Done; clean-instance catalog gate pending | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;全新实例 Runner Marketplace 用例仍需独立空白环境。 | +| EBA processor routing | Done; release gate 5/5 pass | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;隔离空白实例已验证从 Space 安装并注册 LocalAgent。 | ## Spec 与实现已知差距 @@ -37,7 +37,7 @@ | Runner | 状态 | 最近证据 | | --- | --- | --- | -| `plugin:langbot-team/LocalAgent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 | +| `plugin:langbot-team/LocalAgent/default` | Unit-pass; Marketplace UI pass | 2026-07-12 隔离 first-run 实例从真实 AgentRunner catalog 安装 `langbot-team/LocalAgent` 0.1.0,Host 注册 `plugin:langbot-team/LocalAgent/default`,Wizard 自动选中并解锁后续操作。此项不替代带真实模型的 Debug Chat 能力验收。 | | `plugin:langbot-team/ACPAgentRunner/default` / `plugin:langbot-team/ClaudeCodeAgent/default` / `plugin:langbot-team/CodexAgent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 | | Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | @@ -45,7 +45,7 @@ | 范围 | 状态 | 最近证据 | | --- | --- | --- | -| LangBot Runtime Control Plane v2 foundation | Unit-pass; EBA product flow pass | 2026-07-12 事件路由与 Agent 协议针对性测试通过;WebUI 已验证 Quick Start 场景筛选、事件路由 dry-run / 合成派发、Runner 健康状态,以及真实 OneBot `group.member_joined` → Agent → `send_group_msg` 链路。clean-instance Runner Marketplace 用例因当前实例已有插件与 runner 未执行。 | +| LangBot Runtime Control Plane v2 foundation | Unit-pass; EBA release gate 5/5 pass | 2026-07-12 `eba-functional-20260712-release-gate-rerun` 通过 Quick Start 场景筛选、隔离实例 Runner Marketplace 安装、Runner 健康状态、事件路由 dry-run / 合成派发,以及真实 OneBot `group.member_joined` → Agent → `send_group_msg` 链路。 | | SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-23 SDK `tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/test_pull_api_handlers.py`、`tests/runtime/io/handlers/test_plugin_handler.py`、EBA event entities 和 message tests 通过,覆盖 typed entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 | ## 历史高价值记录 diff --git a/skills/scripts/e2e/bot-event-routing-product-flow.mjs b/skills/scripts/e2e/bot-event-routing-product-flow.mjs index 566b89f03..62544202a 100644 --- a/skills/scripts/e2e/bot-event-routing-product-flow.mjs +++ b/skills/scripts/e2e/bot-event-routing-product-flow.mjs @@ -106,14 +106,16 @@ try { const messageBehavior = page.getByRole("menuitem", { name: /Reply to messages|回复收到的消息|受信メッセージに返信/, }); + const noEventRoutes = page.getByText( + /No event routes|暂无事件路由|イベントルートはありません/, + { exact: true }, + ); + await noEventRoutes.waitFor(); await messageBehavior.waitFor(); await page.waitForTimeout(250); await safeScreenshot(page, scenarioMenuScreenshot); await messageBehavior.click(); - await page - .getByText(/Message received|收到消息|メッセージを受信/) - .first() - .waitFor(); + await noEventRoutes.waitFor({ state: "hidden" }); result.visible_signals.push("create-mode-routing", "scenario-route-added"); const create = await apiJson(backendUrl, "/api/v1/platform/bots", { diff --git a/skills/scripts/e2e/lib/isolated-langbot-instance.mjs b/skills/scripts/e2e/lib/isolated-langbot-instance.mjs new file mode 100644 index 000000000..4d75784c9 --- /dev/null +++ b/skills/scripts/e2e/lib/isolated-langbot-instance.mjs @@ -0,0 +1,200 @@ +import { execFile } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { spawn } from "node:child_process"; +import { pathExists, resolveLangBotRepo } from "./langbot-e2e.mjs"; + +const execFileAsync = promisify(execFile); +const proxyKeys = [ + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", +]; + +function withoutProxy(source = process.env) { + const next = { ...source }; + for (const key of proxyKeys) delete next[key]; + next.NO_PROXY = "127.0.0.1,localhost"; + next.no_proxy = "127.0.0.1,localhost"; + return next; +} + +async function getFreePort() { + const server = createServer(); + await new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolvePromise); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolvePromise) => server.close(resolvePromise)); + if (!port) throw new Error("Could not allocate an isolated LangBot port."); + return port; +} + +async function waitForHttp(url, child, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`${label} exited before becoming ready (code ${child.exitCode}).`); + } + try { + const response = await fetch(url, { redirect: "manual" }); + if (response.status < 500) return response; + } catch { + // The service is still starting. + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); + } + throw new Error(`${label} did not become ready within ${timeoutMs}ms.`); +} + +function spawnLogged(command, args, { cwd, env, logPath }) { + const logFd = openSync(logPath, "a"); + try { + return spawn(command, args, { + cwd, + env, + detached: true, + stdio: ["ignore", logFd, logFd], + }); + } finally { + closeSync(logFd); + } +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + const closed = new Promise((resolvePromise) => child.once("close", resolvePromise)); + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + const graceful = await Promise.race([ + closed.then(() => true), + new Promise((resolvePromise) => setTimeout(() => resolvePromise(false), 5_000)), + ]); + if (graceful) return; + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + await closed.catch(() => {}); +} + +async function initializeUser(backendUrl, user, password) { + const response = await fetch(`${backendUrl}/api/v1/user/init`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user, password }), + }); + const payload = await response.json().catch(() => ({})); + if (response.status >= 400 || ![0, 1].includes(payload.code)) { + throw new Error(payload.msg || `Could not initialize isolated user (HTTP ${response.status}).`); + } +} + +export async function startIsolatedLangBotInstance({ evidenceDir }) { + const repo = await resolveLangBotRepo(); + const webRepo = process.env.LANGBOT_WEB_REPO || join(repo, "web"); + const python = join(repo, ".venv", "bin", "python"); + const configScript = resolve( + dirname(fileURLToPath(import.meta.url)), + "../prepare-isolated-langbot-config.py", + ); + if (!(await pathExists(python))) throw new Error(`LangBot virtualenv Python is missing: ${python}`); + if (!(await pathExists(join(webRepo, "node_modules")))) { + throw new Error(`LangBot frontend dependencies are missing: ${join(webRepo, "node_modules")}`); + } + + const [backendPort, frontendPort, pluginDebugPort] = await Promise.all([ + getFreePort(), + getFreePort(), + getFreePort(), + ]); + const instanceRoot = await mkdtemp(join(tmpdir(), "langbot-clean-catalog-")); + const backendUrl = `http://127.0.0.1:${backendPort}`; + const frontendUrl = `http://127.0.0.1:${frontendPort}`; + const backendLog = join(evidenceDir, "isolated-backend.log"); + const frontendLog = join(evidenceDir, "isolated-frontend.log"); + const user = "langbot-e2e@example.invalid"; + const password = "LangBotIsolatedE2E!2026"; + let backend; + let frontend; + + const stop = async () => { + await stopProcess(frontend); + await stopProcess(backend); + await rm(instanceRoot, { recursive: true, force: true }); + }; + + try { + await execFileAsync( + python, + [ + configScript, + "--instance-root", + instanceRoot, + "--port", + String(backendPort), + "--plugin-debug-port", + String(pluginDebugPort), + ], + { cwd: repo, env: withoutProxy(), timeout: 30_000 }, + ); + + backend = spawnLogged(python, ["-m", "langbot"], { + cwd: instanceRoot, + env: { + ...withoutProxy(), + PYTHONPATH: join(repo, "src"), + LANGBOT_DATA_ROOT: join(instanceRoot, "data"), + API__PORT: String(backendPort), + API__WEBHOOK_PREFIX: backendUrl, + SPACE__DISABLE_TELEMETRY: "true", + SPACE__DISABLE_MODELS_SERVICE: "true", + }, + logPath: backendLog, + }); + await waitForHttp(`${backendUrl}/api/v1/system/info`, backend, 180_000, "Isolated LangBot backend"); + + const configText = await readFile(join(instanceRoot, "data", "config.yaml"), "utf8"); + const recoveryKey = configText.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m)?.[1] || ""; + if (!recoveryKey) throw new Error("Isolated LangBot did not generate a recovery key."); + await initializeUser(backendUrl, user, password); + + frontend = spawnLogged( + "pnpm", + ["exec", "vite", "--host", "127.0.0.1", "--port", String(frontendPort), "--strictPort"], + { + cwd: webRepo, + env: { ...withoutProxy(), VITE_API_BASE_URL: backendUrl }, + logPath: frontendLog, + }, + ); + await waitForHttp(frontendUrl, frontend, 60_000, "Isolated LangBot frontend"); + + return { + backendUrl, + frontendUrl, + recoveryKey, + user, + password, + logs: { backend: backendLog, frontend: frontendLog }, + stop, + }; + } catch (error) { + await stop(); + throw error; + } +} diff --git a/skills/scripts/e2e/prepare-isolated-langbot-config.py b/skills/scripts/e2e/prepare-isolated-langbot-config.py new file mode 100644 index 000000000..596baa3d0 --- /dev/null +++ b/skills/scripts/e2e/prepare-isolated-langbot-config.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJECT_ROOT)) + +from tests.e2e.utils.config_factory import ( # noqa: E402 + create_minimal_config, + create_test_directories, +) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--instance-root', required=True, type=Path) + parser.add_argument('--port', required=True, type=int) + parser.add_argument('--plugin-debug-port', required=True, type=int) + args = parser.parse_args() + + config_path = create_minimal_config(args.instance_root, port=args.port) + create_test_directories(args.instance_root) + + with config_path.open('r', encoding='utf-8') as file: + config = yaml.safe_load(file) + + config['plugin']['enable'] = True + config['plugin']['enable_marketplace'] = True + config['plugin']['display_plugin_debug_url'] = ( + f'ws://127.0.0.1:{args.plugin_debug_port}/plugin/debug/ws' + ) + + with config_path.open('w', encoding='utf-8') as file: + yaml.safe_dump(config, file, allow_unicode=True, sort_keys=False) + + +if __name__ == '__main__': + main() diff --git a/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs b/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs index d3fad74d3..dc754e2f7 100644 --- a/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs +++ b/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs @@ -13,17 +13,20 @@ import { scanBrowserDiagnostics, writeResult, } from "./lib/langbot-e2e.mjs"; +import { startIsolatedLangBotInstance } from "./lib/isolated-langbot-instance.mjs"; const caseId = "wizard-runner-marketplace-catalog"; await loadEnvFiles(); const paths = evidencePaths(caseId); await ensureEvidence(paths); const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); +const installedScreenshot = paths.screenshot.replace(/\.png$/, "-installed.png"); const startedAt = new Date(); -const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; -const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +let frontendUrl = ""; +let backendUrl = ""; let browser; +let isolated; let token = ""; let botId = ""; @@ -41,13 +44,17 @@ const result = { visible_signals: [], api: {}, marketplace_request: null, + marketplace_response: null, diagnostics: null, cleanup: {}, evidence: { screenshot: paths.screenshot, mobile_screenshot: mobileScreenshot, + installed_screenshot: installedScreenshot, console_log: paths.consoleLog, network_log: paths.networkLog, + isolated_backend_log: `${paths.evidenceDir}/isolated-backend.log`, + isolated_frontend_log: `${paths.evidenceDir}/isolated-frontend.log`, automation_result_json: paths.automationResultJson, result_json: paths.resultJson, }, @@ -55,8 +62,15 @@ const result = { }; try { - if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); - if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + isolated = await startIsolatedLangBotInstance({ + evidenceDir: paths.evidenceDir, + }); + frontendUrl = isolated.frontendUrl; + backendUrl = isolated.backendUrl; + result.api.isolated_instance = { + backend_url: backendUrl, + frontend_url: frontendUrl, + }; browser = await createBrowser(paths); const { page } = browser; @@ -82,11 +96,35 @@ try { // The assertion below reports a missing or malformed catalog request. } }); + page.on("response", async (response) => { + const pathname = new URL(response.url()).pathname; + if (!/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test(pathname)) { + return; + } + try { + const payload = await response.json(); + const entries = payload?.data?.extensions || payload?.data?.plugins || []; + const localAgent = entries.find( + (entry) => `${entry.author}/${entry.name}` === "langbot-team/LocalAgent", + ); + result.marketplace_response = { + endpoint: pathname, + total: payload?.data?.total ?? entries.length, + local_agent_present: Boolean(localAgent), + local_agent_version: localAgent?.latest_version || null, + }; + } catch { + // The UI assertions below surface malformed Marketplace responses. + } + }); await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); const auth = await ensureAuthenticatedBrowser(page, { frontendUrl, backendUrl, + user: isolated.user, + password: isolated.password, + recoveryKey: isolated.recoveryKey, }); if (auth.status !== "pass") { result.status = auth.status; @@ -174,12 +212,17 @@ try { exact: true, }) .waitFor({ timeout: 15_000 }); - await page - .getByText( - /No AgentRunner extensions are published yet|市场暂未发布 AgentRunner 扩展|AgentRunner 拡張機能はまだ公開されていません/, - { exact: true }, - ) - .waitFor({ timeout: 15_000 }); + const localAgentIdentity = page.getByText("langbot-team/LocalAgent", { + exact: true, + }); + await localAgentIdentity.waitFor({ timeout: 30_000 }); + const localAgentCard = localAgentIdentity.locator( + "xpath=ancestor::*[@data-slot='card'][1]", + ); + const installButton = localAgentCard.getByRole("button", { + name: /Install & Continue|安装并继续|インストールして続行/, + }); + await installButton.waitFor(); const browseLink = page.getByRole("link", { name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/, }); @@ -199,10 +242,19 @@ try { "Wizard did not request the AgentRunner Marketplace catalog.", ); } + if ( + !result.marketplace_response?.local_agent_present || + !result.marketplace_response?.local_agent_version + ) { + throw new Error( + "The Marketplace catalog did not return an installable langbot-team/LocalAgent version.", + ); + } result.visible_signals.push( "first-run-ai-engine-step", - "empty-runner-catalog-state", + "published-local-agent-runner", + "install-and-continue-action", "runner-marketplace-link", "next-disabled-without-runner", ); @@ -220,13 +272,73 @@ try { await safeScreenshot(page, mobileScreenshot); result.visible_signals.push("mobile-layout"); + await page.setViewportSize({ width: 1440, height: 1000 }); + await installButton.click(); + const installingButton = localAgentCard.getByRole("button", { + name: /Installing\.\.\.|正在安装\.\.\.|インストール中\.\.\./, + }); + await installingButton.waitFor({ timeout: 15_000 }); + const installOutcome = await Promise.race([ + nextButton + .click({ trial: true, timeout: 180_000 }) + .then(() => "registered"), + installButton + .waitFor({ state: "visible", timeout: 180_000 }) + .then(() => "failed"), + ]); + if (installOutcome === "failed") { + throw new Error("LocalAgent installation failed before Runner registration."); + } + if (await nextButton.isDisabled()) { + throw new Error("Create & Deploy remained disabled after LocalAgent installation."); + } + + const [installedPluginsResponse, installedMetadataResponse] = + await Promise.all([ + apiJson(backendUrl, "/api/v1/plugins", { token }), + apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }), + ]); + const postInstallPlugins = + installedPluginsResponse.json.data?.plugins || []; + const installedRunnerStage = installedMetadataResponse.json.data?.configs + ?.find((config) => config.name === "ai") + ?.stages?.find((stage) => stage.name === "runner"); + const installedRunnerOptions = + installedRunnerStage?.config?.find((item) => item.name === "id")?.options || + []; + const localAgentInstalled = postInstallPlugins.some((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata || {}; + return `${metadata.author}/${metadata.name}` === "langbot-team/LocalAgent"; + }); + const localAgentRegistered = installedRunnerOptions.some( + (option) => option.name === "plugin:langbot-team/LocalAgent/default", + ); + result.api.installed_state = { + plugin_count: postInstallPlugins.length, + runner_count: installedRunnerOptions.length, + local_agent_installed: localAgentInstalled, + local_agent_registered: localAgentRegistered, + }; + if (!localAgentInstalled || !localAgentRegistered) { + throw new Error( + "LocalAgent installation completed in the UI but the plugin or Runner registration is missing.", + ); + } + await safeScreenshot(page, installedScreenshot); + result.visible_signals.push( + "local-agent-installed", + "local-agent-runner-registered", + "local-agent-selected", + "create-and-deploy-enabled", + ); + result.diagnostics = await scanBrowserDiagnostics(paths); if (result.diagnostics.status !== "pass") { throw new Error(result.diagnostics.reason); } result.status = "pass"; result.reason = - "A clean first-run instance requested the AgentRunner catalog, showed a safe empty state, and prevented continuing without a Runner."; + "A clean first-run instance discovered LocalAgent in the AgentRunner catalog, installed and registered it, selected it, and enabled Create & Deploy."; } catch (error) { if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; result.reason = result.reason || error.message; @@ -270,6 +382,18 @@ try { result.reason = "The clean Wizard fixtures were not fully reset."; } if (browser) await browser.close().catch(() => {}); + if (isolated) { + try { + await isolated.stop(); + cleanup.isolated_instance_stopped = true; + } catch (error) { + cleanup.isolated_instance_stopped = false; + if (result.status === "pass") { + result.status = "fail"; + result.reason = `Could not stop the isolated LangBot instance: ${error.message}`; + } + } + } const finishedAt = new Date(); result.finished_at = finishedAt.toISOString(); result.finished_at_local = localIsoWithOffset(finishedAt); diff --git a/skills/skills.index.json b/skills/skills.index.json index 7590d65eb..3e51bd308 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -1705,7 +1705,7 @@ }, { "id": "wizard-runner-marketplace-catalog", - "title": "Quick Start discovers AgentRunner extensions on a clean instance", + "title": "Quick Start installs a published AgentRunner on a clean instance", "mode": "agent-browser", "area": "wizard", "type": "feature", diff --git a/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml b/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml index 4815d0f70..c0d4b4283 100644 --- a/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml +++ b/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml @@ -1,5 +1,5 @@ id: wizard-runner-marketplace-catalog -title: "Quick Start discovers AgentRunner extensions on a clean instance" +title: "Quick Start installs a published AgentRunner on a clean instance" mode: agent-browser area: wizard type: feature @@ -16,33 +16,31 @@ skills: - langbot-env-setup - langbot-testing env: - - LANGBOT_FRONTEND_URL - - LANGBOT_BACKEND_URL - - LANGBOT_E2E_LOGIN_USER + - LANGBOT_REPO automation: scripts/e2e/wizard-runner-marketplace-catalog.mjs automation_env: - - LANGBOT_FRONTEND_URL - - LANGBOT_BACKEND_URL - - LANGBOT_E2E_LOGIN_USER + - LANGBOT_REPO - LANGBOT_BROWSER_PROFILE - LANGBOT_CHROMIUM_EXECUTABLE preconditions: - - "The target is a newly initialized local instance with wizard_status=none." - - "No plugins or AgentRunner components are installed." + - "The local checkout can start an isolated backend and Vite frontend and can reach LangBot Space." steps: - - "Confirm the backend reports zero installed plugins and zero registered runners." + - "Start an isolated first-run instance and confirm zero installed plugins and zero registered runners." - "Resume Quick Start at the AI Engine step with a temporary disabled Bot." - "Confirm the browser requests Marketplace plugins with component_filter=AgentRunner." - - "When Space has not published Runner plugins, confirm the safe empty state and Runner Extensions link." - - "Confirm Create & Deploy remains disabled until a Runner is installed and selected." + - "Confirm langbot-team/LocalAgent is published with an installable version and the Runner Extensions link is correct." + - "Install LocalAgent and wait for plugin initialization and AgentRunner registration." + - "Confirm Create & Deploy is disabled before installation and enabled after LocalAgent is selected." - "Verify the layout at desktop and mobile widths." checks: - "API: The instance has zero installed plugins and zero registered runners." - "API: The instance wizard status is none." - "Network: Marketplace search uses component_filter=AgentRunner and type_filter=plugin." - - "UI: The AI Engine step remains usable when the Runner catalog is empty." + - "UI: The AI Engine step displays the published langbot-team/LocalAgent card." + - "Marketplace: LocalAgent includes latest_version so installation can proceed." + - "Runtime: LocalAgent installs and registers plugin:langbot-team/LocalAgent/default." - "UI: Browse Runner Extensions links to the AgentRunner-filtered market." - - "UI: Create & Deploy remains disabled without a selected Runner." + - "UI: Create & Deploy transitions from disabled to enabled only after Runner selection." - "Console: No unexpected frontend errors appear during the flow." - "Cleanup: Wizard progress and the temporary Bot are removed." evidence_required: @@ -51,8 +49,8 @@ evidence_required: - console - api_diagnostic diagnostics: - - "This case validates the clean-instance discovery and empty state. Run the install continuation after Space publishes at least one AgentRunner plugin." - - "A Marketplace API response alone is not a pass; the empty or installable Runner UI must be visible." + - "The automation owns a temporary data root and random ports; it must not reuse or reset the developer instance." + - "A Marketplace API response alone is not a pass; LocalAgent must install and register through the visible Wizard flow." troubleshooting: - backend-not-listening - plugin-runtime-timeout diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 337d8b352..8890e7da1 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -9,6 +9,7 @@ from typing import Any import typing import os import sys +from urllib.parse import urlparse import httpx import sqlalchemy import yaml @@ -172,9 +173,20 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): # cmd: lbp rt -s python_path = sys.executable env = os.environ.copy() + debug_url = self.ap.instance_config.data.get('plugin', {}).get( + 'display_plugin_debug_url', 'ws://localhost:5401/plugin/debug/ws' + ) + debug_port = self._runtime_debug_port_from_url(debug_url) self.ctrl = stdio_client_controller.StdioClientController( command=python_path, - args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'], + args=[ + '-m', + 'langbot_plugin.cli.__init__', + 'rt', + '-s', + '--ws-debug-port', + str(debug_port), + ], env=env, ) task = self.ctrl.run(new_connection_callback) @@ -184,6 +196,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): asyncio.create_task(task) + @staticmethod + def _runtime_debug_port_from_url(debug_url: str) -> int: + """Extract the local plugin runtime debug port from its display URL.""" + try: + parsed = urlparse(debug_url if '://' in debug_url else f'//{debug_url}') + return parsed.port or 5401 + except (TypeError, ValueError): + return 5401 + async def initialize_plugins(self): pass diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index 13ba29b50..5b77d4086 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -141,3 +141,22 @@ class TestParsePluginId: with pytest.raises(ValueError): PluginRuntimeConnector._parse_plugin_id('') + + +class TestRuntimeDebugPort: + """Tests for local runtime debug port selection.""" + + @pytest.mark.parametrize( + ('debug_url', 'expected'), + [ + ('ws://localhost:5401/plugin/debug/ws', 5401), + ('http://127.0.0.1:15401', 15401), + ('localhost:25401', 25401), + ('', 5401), + ('ws://localhost:not-a-port/plugin/debug/ws', 5401), + ], + ) + def test_runtime_debug_port_from_url(self, debug_url, expected): + from langbot.pkg.plugin.connector import PluginRuntimeConnector + + assert PluginRuntimeConnector._runtime_debug_port_from_url(debug_url) == expected diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index e05689788..f715ab934 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -110,10 +110,21 @@ export class CloudServiceClient extends BaseHttpClient { '/api/v1/marketplace/extensions/search', data, ) - .then((resp) => ({ - plugins: resp?.extensions || [], - total: resp?.total || 0, - })) + .then((resp) => { + const extensions = resp?.extensions || []; + // Runner installation needs a concrete version. Older Space + // deployments omit it from the unified extension response. + if ( + data.component_filter && + extensions.some((extension) => !extension.latest_version) + ) { + return this.searchMarketplaceExtensionsLegacy(data); + } + return { + plugins: extensions, + total: resp?.total || 0, + }; + }) .catch(() => this.searchMarketplaceExtensionsLegacy(data)); }