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 不是当前承诺。 |
| 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` 注入与取消传播。 |
| 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.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 登录态。 |
| 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。 |
## 历史高价值记录
@@ -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", {
@@ -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,
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);
+1 -1
View File
@@ -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",
@@ -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
+22 -1
View File
@@ -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
@@ -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
+15 -4
View File
@@ -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));
}