fix(agent-runner): validate clean marketplace onboarding

This commit is contained in:
huanghuoguoguo
2026-07-15 07:52:43 +08:00
parent 99d9c227f9
commit 32216a82f5
10 changed files with 461 additions and 41 deletions
+3 -3
View File
@@ -20,7 +20,7 @@
| Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 | | Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 |
| Steering control path | Done | claim 异常不再逃逸 consumer loopqueue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | | Steering control path | Done | claim 异常不再逃逸 consumer loopqueue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | | 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 与实现已知差距 ## Spec 与实现已知差距
@@ -37,7 +37,7 @@
| Runner | 状态 | 最近证据 | | 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.0Host 注册 `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 登录态。 | | `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 非每轮必跑。 | | 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。 | | 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。 |
## 历史高价值记录 ## 历史高价值记录
@@ -106,14 +106,16 @@ try {
const messageBehavior = page.getByRole("menuitem", { const messageBehavior = page.getByRole("menuitem", {
name: /Reply to messages|回复收到的消息|受信メッセージに返信/, name: /Reply to messages|回复收到的消息|受信メッセージに返信/,
}); });
const noEventRoutes = page.getByText(
/No event routes|暂无事件路由|イベントルートはありません/,
{ exact: true },
);
await noEventRoutes.waitFor();
await messageBehavior.waitFor(); await messageBehavior.waitFor();
await page.waitForTimeout(250); await page.waitForTimeout(250);
await safeScreenshot(page, scenarioMenuScreenshot); await safeScreenshot(page, scenarioMenuScreenshot);
await messageBehavior.click(); await messageBehavior.click();
await page await noEventRoutes.waitFor({ state: "hidden" });
.getByText(/Message received|收到消息|メッセージを受信/)
.first()
.waitFor();
result.visible_signals.push("create-mode-routing", "scenario-route-added"); result.visible_signals.push("create-mode-routing", "scenario-route-added");
const create = await apiJson(backendUrl, "/api/v1/platform/bots", { const create = await apiJson(backendUrl, "/api/v1/platform/bots", {
@@ -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;
}
}
@@ -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()
@@ -13,17 +13,20 @@ import {
scanBrowserDiagnostics, scanBrowserDiagnostics,
writeResult, writeResult,
} from "./lib/langbot-e2e.mjs"; } from "./lib/langbot-e2e.mjs";
import { startIsolatedLangBotInstance } from "./lib/isolated-langbot-instance.mjs";
const caseId = "wizard-runner-marketplace-catalog"; const caseId = "wizard-runner-marketplace-catalog";
await loadEnvFiles(); await loadEnvFiles();
const paths = evidencePaths(caseId); const paths = evidencePaths(caseId);
await ensureEvidence(paths); await ensureEvidence(paths);
const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png");
const installedScreenshot = paths.screenshot.replace(/\.png$/, "-installed.png");
const startedAt = new Date(); const startedAt = new Date();
const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; let frontendUrl = "";
const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; let backendUrl = "";
let browser; let browser;
let isolated;
let token = ""; let token = "";
let botId = ""; let botId = "";
@@ -41,13 +44,17 @@ const result = {
visible_signals: [], visible_signals: [],
api: {}, api: {},
marketplace_request: null, marketplace_request: null,
marketplace_response: null,
diagnostics: null, diagnostics: null,
cleanup: {}, cleanup: {},
evidence: { evidence: {
screenshot: paths.screenshot, screenshot: paths.screenshot,
mobile_screenshot: mobileScreenshot, mobile_screenshot: mobileScreenshot,
installed_screenshot: installedScreenshot,
console_log: paths.consoleLog, console_log: paths.consoleLog,
network_log: paths.networkLog, 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, automation_result_json: paths.automationResultJson,
result_json: paths.resultJson, result_json: paths.resultJson,
}, },
@@ -55,8 +62,15 @@ const result = {
}; };
try { try {
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); isolated = await startIsolatedLangBotInstance({
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); evidenceDir: paths.evidenceDir,
});
frontendUrl = isolated.frontendUrl;
backendUrl = isolated.backendUrl;
result.api.isolated_instance = {
backend_url: backendUrl,
frontend_url: frontendUrl,
};
browser = await createBrowser(paths); browser = await createBrowser(paths);
const { page } = browser; const { page } = browser;
@@ -82,11 +96,35 @@ try {
// The assertion below reports a missing or malformed catalog request. // 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" }); await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
const auth = await ensureAuthenticatedBrowser(page, { const auth = await ensureAuthenticatedBrowser(page, {
frontendUrl, frontendUrl,
backendUrl, backendUrl,
user: isolated.user,
password: isolated.password,
recoveryKey: isolated.recoveryKey,
}); });
if (auth.status !== "pass") { if (auth.status !== "pass") {
result.status = auth.status; result.status = auth.status;
@@ -174,12 +212,17 @@ try {
exact: true, exact: true,
}) })
.waitFor({ timeout: 15_000 }); .waitFor({ timeout: 15_000 });
await page const localAgentIdentity = page.getByText("langbot-team/LocalAgent", {
.getByText( exact: true,
/No AgentRunner extensions are published yet|市场暂未发布 AgentRunner 扩展|AgentRunner 拡張機能はまだ公開されていません/, });
{ exact: true }, await localAgentIdentity.waitFor({ timeout: 30_000 });
) const localAgentCard = localAgentIdentity.locator(
.waitFor({ timeout: 15_000 }); "xpath=ancestor::*[@data-slot='card'][1]",
);
const installButton = localAgentCard.getByRole("button", {
name: /Install & Continue|安装并继续|インストールして続行/,
});
await installButton.waitFor();
const browseLink = page.getByRole("link", { const browseLink = page.getByRole("link", {
name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/, name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/,
}); });
@@ -199,10 +242,19 @@ try {
"Wizard did not request the AgentRunner Marketplace catalog.", "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( result.visible_signals.push(
"first-run-ai-engine-step", "first-run-ai-engine-step",
"empty-runner-catalog-state", "published-local-agent-runner",
"install-and-continue-action",
"runner-marketplace-link", "runner-marketplace-link",
"next-disabled-without-runner", "next-disabled-without-runner",
); );
@@ -220,13 +272,73 @@ try {
await safeScreenshot(page, mobileScreenshot); await safeScreenshot(page, mobileScreenshot);
result.visible_signals.push("mobile-layout"); 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); result.diagnostics = await scanBrowserDiagnostics(paths);
if (result.diagnostics.status !== "pass") { if (result.diagnostics.status !== "pass") {
throw new Error(result.diagnostics.reason); throw new Error(result.diagnostics.reason);
} }
result.status = "pass"; result.status = "pass";
result.reason = 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) { } catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message; result.reason = result.reason || error.message;
@@ -270,6 +382,18 @@ try {
result.reason = "The clean Wizard fixtures were not fully reset."; result.reason = "The clean Wizard fixtures were not fully reset.";
} }
if (browser) await browser.close().catch(() => {}); 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(); const finishedAt = new Date();
result.finished_at = finishedAt.toISOString(); result.finished_at = finishedAt.toISOString();
result.finished_at_local = localIsoWithOffset(finishedAt); result.finished_at_local = localIsoWithOffset(finishedAt);
+1 -1
View File
@@ -1705,7 +1705,7 @@
}, },
{ {
"id": "wizard-runner-marketplace-catalog", "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", "mode": "agent-browser",
"area": "wizard", "area": "wizard",
"type": "feature", "type": "feature",
@@ -1,5 +1,5 @@
id: wizard-runner-marketplace-catalog 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 mode: agent-browser
area: wizard area: wizard
type: feature type: feature
@@ -16,33 +16,31 @@ skills:
- langbot-env-setup - langbot-env-setup
- langbot-testing - langbot-testing
env: env:
- LANGBOT_FRONTEND_URL - LANGBOT_REPO
- LANGBOT_BACKEND_URL
- LANGBOT_E2E_LOGIN_USER
automation: scripts/e2e/wizard-runner-marketplace-catalog.mjs automation: scripts/e2e/wizard-runner-marketplace-catalog.mjs
automation_env: automation_env:
- LANGBOT_FRONTEND_URL - LANGBOT_REPO
- LANGBOT_BACKEND_URL
- LANGBOT_E2E_LOGIN_USER
- LANGBOT_BROWSER_PROFILE - LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE - LANGBOT_CHROMIUM_EXECUTABLE
preconditions: preconditions:
- "The target is a newly initialized local instance with wizard_status=none." - "The local checkout can start an isolated backend and Vite frontend and can reach LangBot Space."
- "No plugins or AgentRunner components are installed."
steps: 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." - "Resume Quick Start at the AI Engine step with a temporary disabled Bot."
- "Confirm the browser requests Marketplace plugins with component_filter=AgentRunner." - "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 langbot-team/LocalAgent is published with an installable version and the Runner Extensions link is correct."
- "Confirm Create & Deploy remains disabled until a Runner is installed and selected." - "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." - "Verify the layout at desktop and mobile widths."
checks: checks:
- "API: The instance has zero installed plugins and zero registered runners." - "API: The instance has zero installed plugins and zero registered runners."
- "API: The instance wizard status is none." - "API: The instance wizard status is none."
- "Network: Marketplace search uses component_filter=AgentRunner and type_filter=plugin." - "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: 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." - "Console: No unexpected frontend errors appear during the flow."
- "Cleanup: Wizard progress and the temporary Bot are removed." - "Cleanup: Wizard progress and the temporary Bot are removed."
evidence_required: evidence_required:
@@ -51,8 +49,8 @@ evidence_required:
- console - console
- api_diagnostic - api_diagnostic
diagnostics: diagnostics:
- "This case validates the clean-instance discovery and empty state. Run the install continuation after Space publishes at least one AgentRunner plugin." - "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; the empty or installable Runner UI must be visible." - "A Marketplace API response alone is not a pass; LocalAgent must install and register through the visible Wizard flow."
troubleshooting: troubleshooting:
- backend-not-listening - backend-not-listening
- plugin-runtime-timeout - plugin-runtime-timeout
+22 -1
View File
@@ -9,6 +9,7 @@ from typing import Any
import typing import typing
import os import os
import sys import sys
from urllib.parse import urlparse
import httpx import httpx
import sqlalchemy import sqlalchemy
import yaml import yaml
@@ -172,9 +173,20 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# cmd: lbp rt -s # cmd: lbp rt -s
python_path = sys.executable python_path = sys.executable
env = os.environ.copy() 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( self.ctrl = stdio_client_controller.StdioClientController(
command=python_path, 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, env=env,
) )
task = self.ctrl.run(new_connection_callback) task = self.ctrl.run(new_connection_callback)
@@ -184,6 +196,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
asyncio.create_task(task) 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): async def initialize_plugins(self):
pass pass
@@ -141,3 +141,22 @@ class TestParsePluginId:
with pytest.raises(ValueError): with pytest.raises(ValueError):
PluginRuntimeConnector._parse_plugin_id('') 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
+15 -4
View File
@@ -110,10 +110,21 @@ export class CloudServiceClient extends BaseHttpClient {
'/api/v1/marketplace/extensions/search', '/api/v1/marketplace/extensions/search',
data, data,
) )
.then((resp) => ({ .then((resp) => {
plugins: resp?.extensions || [], const extensions = resp?.extensions || [];
total: resp?.total || 0, // 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)); .catch(() => this.searchMarketplaceExtensionsLegacy(data));
} }