mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-20 12:34:21 +00:00
e9dd584792
* feat(api): support global API key from config.yaml (api.global_api_key) Accept a config-defined global API key anywhere a web-UI key is accepted (X-API-Key / Bearer), with no login session and no DB record. Useful for automated deployments and AI agents (HTTP API + MCP). Defaults to empty (disabled); does not require the lbk_ prefix. - templates/config.yaml: add api.global_api_key with security notes - service/apikey.py: verify_api_key checks global key first (constant-time) - docs/API_KEY_AUTH.md: document the global key + security guidance - tests: cover global-key match, prefix-free, fallback-to-db, disabled * feat(mcp): expose LangBot management as an MCP server at /mcp Add an MCP (Model Context Protocol) server so external AI agents can manage a LangBot instance. Reuses the same API-key auth as the HTTP API (including the config.yaml global API key). - pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated tools across system/bots/pipelines/models/knowledge/mcp-servers/skills - pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp requests with an API key, runs the streamable-HTTP session manager lifespan - controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task) - web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and client config; i18n for 8 locales - tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools) Tool surface is intentionally curated (not all ~25 route groups) to keep the agent surface small, safe, and maintainable. Extend deliberately. * feat(skills): add in-repo skills/ as the single source of truth Migrate the agent skills + QA/e2e test harness from the (now archived) langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills. Migrated: - langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup, langbot-skills-maintenance, langbot-eba-adapter-dev - the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/) New: - langbot-dev core backend + web development - langbot-deploy Docker/K8s deployment + config.yaml + global API key - langbot-mcp-ops operating the LangBot MCP server (/mcp) - langbot-space-ops operating the Space marketplace MCP server - src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json + bin/lbs) so the CLI works when nested inside the LangBot repo - README.md: unified skill catalog; skills.index.json regenerated Parity with source verified: bin/lbs validate + node test suite match the source repo (only the uncommitted .lbpkg build-artifact fixture differs). * docs(agents): document agent-facing surfaces + API/MCP/skills sync rule * docs(readme): add 'Built for AI Agents' section across all locales Highlight MCP server, in-repo skills (single source of truth), AGENTS.md sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace. * style(mcp): fix ruff format + prettier lint in MCP server and API panel * style(web): prettier format MCP i18n locale entries * docs(skills): note MCP instance control in dev/testing skills All development-guidance skills now point to the LangBot instance MCP server (/mcp) and the Space marketplace MCP server, reusing API keys.
199 lines
7.5 KiB
JavaScript
199 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { env } from "node:process";
|
|
import {
|
|
apiJson,
|
|
ensureEvidence,
|
|
evidencePaths,
|
|
loadEnvFiles,
|
|
resetAndAuthLocalUser,
|
|
writeResult,
|
|
} from "./lib/langbot-e2e.mjs";
|
|
|
|
const caseId = env.LBS_CASE_ID || "install-qa-plugin-smoke";
|
|
const paths = evidencePaths(caseId);
|
|
await loadEnvFiles();
|
|
await ensureEvidence(paths);
|
|
|
|
const backendUrl = env.LANGBOT_BACKEND_URL || "";
|
|
const user = env.LANGBOT_E2E_LOGIN_USER || "";
|
|
const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026";
|
|
const packagePath = resolve(
|
|
env.LANGBOT_E2E_PLUGIN_PACKAGE
|
|
|| env.LANGBOT_QA_PLUGIN_SMOKE_PACKAGE
|
|
|| "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg",
|
|
);
|
|
const expectedPluginId = env.LANGBOT_E2E_EXPECTED_PLUGIN_ID || "qa/plugin-smoke";
|
|
const expectedTool = env.LANGBOT_E2E_EXPECTED_TOOL || (expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : "");
|
|
const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "";
|
|
|
|
const result = {
|
|
source: "automation",
|
|
case_id: caseId,
|
|
run_id: paths.runId,
|
|
status: "fail",
|
|
reason: "",
|
|
backend_url: backendUrl,
|
|
package_path: packagePath,
|
|
package_preview: null,
|
|
task_id: null,
|
|
task: null,
|
|
plugin_present_before: false,
|
|
plugin_present_after: false,
|
|
tool_names: [],
|
|
runner_ids: [],
|
|
evidence: {
|
|
automation_result_json: paths.automationResultJson,
|
|
result_json: paths.resultJson,
|
|
},
|
|
evidence_collected: ["api_diagnostic", "filesystem"],
|
|
};
|
|
|
|
try {
|
|
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
|
|
if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required.");
|
|
const bytes = await readFile(packagePath);
|
|
|
|
const auth = await resetAndAuthLocalUser({ backendUrl, user, password });
|
|
result.package_preview = await previewPackage(backendUrl, auth.token, bytes, packagePath);
|
|
const metadata = result.package_preview.metadata || {};
|
|
if (`${metadata.author}/${metadata.name}` !== expectedPluginId) {
|
|
throw new Error(`Fixture package metadata is ${metadata.author}/${metadata.name}, expected ${expectedPluginId}.`);
|
|
}
|
|
result.plugin_present_before = await hasPlugin(backendUrl, auth.token);
|
|
|
|
if (!result.plugin_present_before) {
|
|
const form = new FormData();
|
|
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
|
|
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
body: form,
|
|
});
|
|
const json = await response.json().catch(() => ({}));
|
|
if (response.status >= 400 || json.code !== 0) {
|
|
throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`);
|
|
}
|
|
result.task_id = json.data?.task_id ?? null;
|
|
if (!result.task_id) throw new Error("Plugin install response did not include task_id.");
|
|
result.task = await waitForTask(backendUrl, auth.token, result.task_id);
|
|
if (!isTaskComplete(result.task)) {
|
|
throw new Error(`Plugin install task did not complete successfully: ${JSON.stringify(result.task)}`);
|
|
}
|
|
}
|
|
|
|
await sleep(1000);
|
|
result.plugin_present_after = await hasPlugin(backendUrl, auth.token);
|
|
if (!result.plugin_present_after) throw new Error(`${expectedPluginId} is not listed by /api/v1/plugins after install.`);
|
|
if (expectedTool) {
|
|
result.tool_names = await listToolNames(backendUrl, auth.token);
|
|
if (!result.tool_names.includes(expectedTool)) {
|
|
throw new Error(`${expectedTool} is not listed by /api/v1/tools after install.`);
|
|
}
|
|
}
|
|
if (expectedRunnerId) {
|
|
result.runner_ids = await listRunnerIds(backendUrl, auth.token);
|
|
if (!result.runner_ids.includes(expectedRunnerId)) {
|
|
throw new Error(`${expectedRunnerId} is not listed by /api/v1/pipelines/_/metadata after install.`);
|
|
}
|
|
}
|
|
|
|
result.status = "pass";
|
|
result.reason = `${expectedPluginId} is installed.`;
|
|
} catch (error) {
|
|
result.status = "fail";
|
|
result.reason = error.message;
|
|
} finally {
|
|
await writeResult(paths, result);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
process.exit(result.status === "pass" ? 0 : 1);
|
|
|
|
async function hasPlugin(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/plugins", { token });
|
|
const plugins = response.json.data?.plugins || [];
|
|
return plugins.some((plugin) => {
|
|
const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {};
|
|
return `${metadata.author}/${metadata.name}` === expectedPluginId;
|
|
});
|
|
}
|
|
|
|
async function previewPackage(backendUrl, token, bytes, packagePath) {
|
|
const form = new FormData();
|
|
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
|
|
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: form,
|
|
});
|
|
const json = await response.json().catch(() => ({}));
|
|
if (response.status >= 400 || json.code !== 0) {
|
|
throw new Error(json.msg || `Plugin package preview failed with HTTP ${response.status}.`);
|
|
}
|
|
return {
|
|
metadata: json.data?.metadata || {},
|
|
component_types: json.data?.component_types || [],
|
|
file_count: json.data?.file_count ?? null,
|
|
};
|
|
}
|
|
|
|
async function listToolNames(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/tools", { token });
|
|
return (response.json.data?.tools || [])
|
|
.map((tool) => tool.name || tool.tool_name || tool.function?.name || "")
|
|
.filter(Boolean)
|
|
.sort();
|
|
}
|
|
|
|
async function listRunnerIds(backendUrl, token) {
|
|
const response = await apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token });
|
|
const configs = response.json.data?.configs || [];
|
|
return configs
|
|
.flatMap((section) => section.stages || [])
|
|
.flatMap((stage) => stage.config || [])
|
|
.filter((item) => item.name === "id")
|
|
.flatMap((item) => item.options || [])
|
|
.map((option) => option.name || option.value || option.id || "")
|
|
.filter(Boolean)
|
|
.sort();
|
|
}
|
|
|
|
async function waitForTask(backendUrl, token, taskId) {
|
|
const deadline = Date.now() + Number(env.LANGBOT_PLUGIN_INSTALL_TIMEOUT_MS || 120000);
|
|
let last = null;
|
|
while (Date.now() < deadline) {
|
|
const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token });
|
|
last = response.json.data || response.json;
|
|
if (isTaskComplete(last) || isTaskFailed(last)) return last;
|
|
await sleep(1000);
|
|
}
|
|
return last;
|
|
}
|
|
|
|
function isTaskComplete(task) {
|
|
const status = String(task?.status || task?.state || "").toLowerCase();
|
|
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
|
|
return ["done", "completed", "success", "succeeded", "finished"].includes(status)
|
|
|| ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus)
|
|
|| task?.done === true
|
|
|| task?.completed === true
|
|
|| (task?.runtime?.done === true && !task?.runtime?.exception);
|
|
}
|
|
|
|
function isTaskFailed(task) {
|
|
const status = String(task?.status || task?.state || "").toLowerCase();
|
|
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
|
|
return ["failed", "error", "cancelled", "canceled"].includes(status)
|
|
|| ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus)
|
|
|| task?.failed === true
|
|
|| Boolean(task?.error)
|
|
|| Boolean(task?.runtime?.exception);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|