mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-25 15:04:19 +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.
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { stderr } from "node:process";
|
|
import type { Skill, StructuredItem } from "../types.ts";
|
|
import { loadFixtureItems } from "../fixtures.ts";
|
|
import {
|
|
caseEvidenceValues,
|
|
caseModeValues,
|
|
casePriorityValues,
|
|
caseRequiredLists,
|
|
caseRequiredStrings,
|
|
caseRiskValues,
|
|
caseTypeValues,
|
|
requiredEnvKeys,
|
|
suiteRequiredLists,
|
|
suiteRequiredStrings,
|
|
suiteTypeValues,
|
|
troubleRequiredLists,
|
|
troubleRequiredStrings,
|
|
troubleshootingCategoryValues,
|
|
} from "../constants.ts";
|
|
import { boolValue, envExamplePath, envPath, listValue, loadSkills, loadStructuredItems, parseEnvFile, scalar } from "../fs.ts";
|
|
import { envKeyPattern, isEnvAnyGroup, splitEnvAnyGroup } from "../env-groups.ts";
|
|
import { parseSetupAutomationEntry, validateSetupAutomationEntry } from "../setup-automation.ts";
|
|
|
|
const refRe = /(?:\]\(|`)(references\/[A-Za-z0-9_.\-/]+\.md)(?:\)|`)/g;
|
|
|
|
function validateStructuredItem(item: StructuredItem, requiredStrings: string[], requiredLists: string[]): string[] {
|
|
const errors: string[] = [];
|
|
const listKeys = item.path.includes("/cases/") && scalar(item.fields, "mode") === "probe"
|
|
? requiredLists.filter((key) => key !== "env")
|
|
: requiredLists;
|
|
for (const key of requiredStrings) {
|
|
if (!scalar(item.fields, key)) errors.push(`${item.path}: missing '${key}'`);
|
|
}
|
|
for (const key of listKeys) {
|
|
if (listValue(item.fields, key).length === 0) errors.push(`${item.path}: missing '${key}' entries`);
|
|
}
|
|
const id = scalar(item.fields, "id");
|
|
if (id && !/^[a-z0-9][a-z0-9_-]*$/.test(id)) {
|
|
errors.push(`${item.path}: id must use lowercase letters, digits, dashes, or underscores`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateEnum(item: StructuredItem, key: string, values: string[]): string[] {
|
|
const value = scalar(item.fields, key);
|
|
if (!value) return [];
|
|
return values.includes(value) ? [] : [`${item.path}: '${key}' must be one of ${values.join(", ")}`];
|
|
}
|
|
|
|
function validateListEnum(item: StructuredItem, key: string, values: string[]): string[] {
|
|
const allowed = new Set(values);
|
|
return listValue(item.fields, key)
|
|
.filter((value) => !allowed.has(value))
|
|
.map((value) => `${item.path}: '${key}' contains unsupported value '${value}'`);
|
|
}
|
|
|
|
function validateDuplicateListValues(item: StructuredItem, keys: string[]): string[] {
|
|
const errors: string[] = [];
|
|
for (const key of keys) {
|
|
const seen = new Set<string>();
|
|
for (const value of listValue(item.fields, key)) {
|
|
if (seen.has(value)) errors.push(`${item.path}: '${key}' contains duplicate value '${value}'`);
|
|
seen.add(value);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateEnvKeyList(item: StructuredItem, key: string): string[] {
|
|
return listValue(item.fields, key)
|
|
.filter((value) => !envKeyPattern.test(value))
|
|
.map((value) => `${item.path}: '${key}' contains invalid env key '${value}'`);
|
|
}
|
|
|
|
function validateEnvKeyScalar(item: StructuredItem, key: string): string[] {
|
|
const value = scalar(item.fields, key);
|
|
if (!value) return [];
|
|
return envKeyPattern.test(value)
|
|
? []
|
|
: [`${item.path}: '${key}' contains invalid env key '${value}'`];
|
|
}
|
|
|
|
function validateJsonScalar(item: StructuredItem, key: string): string[] {
|
|
const value = scalar(item.fields, key);
|
|
if (!value) return [];
|
|
try {
|
|
JSON.parse(value);
|
|
return [];
|
|
} catch (error) {
|
|
return [`${item.path}: '${key}' must be valid JSON: ${(error as Error).message}`];
|
|
}
|
|
}
|
|
|
|
function validateEnvAnyList(item: StructuredItem, key: string): string[] {
|
|
return listValue(item.fields, key)
|
|
.filter((value) => !isEnvAnyGroup(value))
|
|
.map((value) => `${item.path}: '${key}' contains invalid env any-group '${value}'`);
|
|
}
|
|
|
|
function validateCaseItem(root: string, item: StructuredItem, skillNames: Set<string>, troubleIds: Set<string>, caseIds: Set<string>): string[] {
|
|
const errors = [
|
|
...validateEnum(item, "mode", caseModeValues),
|
|
...validateEnum(item, "type", caseTypeValues),
|
|
...validateEnum(item, "priority", casePriorityValues),
|
|
...validateEnum(item, "risk", caseRiskValues),
|
|
...validateListEnum(item, "evidence_required", caseEvidenceValues),
|
|
...validateDuplicateListValues(item, [
|
|
"tags",
|
|
"skills",
|
|
"env",
|
|
"env_any",
|
|
"automation_env",
|
|
"automation_env_any",
|
|
"setup_automation",
|
|
"setup_provides_env",
|
|
"evidence_required",
|
|
"troubleshooting",
|
|
]),
|
|
...validateEnvKeyList(item, "env"),
|
|
...validateEnvAnyList(item, "env_any"),
|
|
...validateEnvKeyList(item, "automation_env"),
|
|
...validateEnvAnyList(item, "automation_env_any"),
|
|
...validateEnvKeyList(item, "setup_provides_env"),
|
|
...validateEnvKeyScalar(item, "automation_pipeline_url_env"),
|
|
...validateEnvKeyScalar(item, "automation_pipeline_name_env"),
|
|
...validateJsonScalar(item, "automation_filesystem_checks_json"),
|
|
...listValue(item.fields, "setup_automation").flatMap((entry) => (
|
|
validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`)
|
|
)),
|
|
];
|
|
|
|
if (boolValue(item.fields, "ci_eligible") === undefined) {
|
|
errors.push(`${item.path}: missing or invalid boolean 'ci_eligible'`);
|
|
}
|
|
|
|
for (const skill of listValue(item.fields, "skills")) {
|
|
if (!skillNames.has(skill)) errors.push(`${item.path}: references unknown skill '${skill}'`);
|
|
}
|
|
|
|
for (const id of listValue(item.fields, "troubleshooting")) {
|
|
if (!troubleIds.has(id)) errors.push(`${item.path}: references unknown troubleshooting '${id}'`);
|
|
}
|
|
|
|
const automation = scalar(item.fields, "automation");
|
|
if (!automation && listValue(item.fields, "automation_env").length > 0) {
|
|
errors.push(`${item.path}: 'automation_env' requires 'automation'`);
|
|
}
|
|
if (!automation && listValue(item.fields, "automation_env_any").length > 0) {
|
|
errors.push(`${item.path}: 'automation_env_any' requires 'automation'`);
|
|
}
|
|
if (!automation && (scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env"))) {
|
|
errors.push(`${item.path}: automation pipeline env aliases require 'automation'`);
|
|
}
|
|
if (listValue(item.fields, "setup_provides_env").length > 0 && listValue(item.fields, "setup_automation").length === 0) {
|
|
errors.push(`${item.path}: 'setup_provides_env' requires 'setup_automation'`);
|
|
}
|
|
for (const key of ["automation_pipeline_url_env", "automation_pipeline_name_env"]) {
|
|
const value = scalar(item.fields, key);
|
|
if (!value) continue;
|
|
const declared = new Set([
|
|
...listValue(item.fields, "env"),
|
|
...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup),
|
|
...listValue(item.fields, "automation_env"),
|
|
...listValue(item.fields, "automation_env_any").flatMap(splitEnvAnyGroup),
|
|
]);
|
|
if (!declared.has(value)) {
|
|
errors.push(`${item.path}: '${key}' value '${value}' must be listed in env, env_any, automation_env, or automation_env_any`);
|
|
}
|
|
}
|
|
if (automation && !existsSync(join(root, automation))) {
|
|
errors.push(`${item.path}: automation script does not exist: ${automation}`);
|
|
}
|
|
for (const entry of listValue(item.fields, "setup_automation")) {
|
|
const spec = parseSetupAutomationEntry(entry);
|
|
if (spec.kind === "case" && spec.target === scalar(item.fields, "id")) {
|
|
errors.push(`${item.path}: setup_automation cannot reference the same case '${spec.target}'`);
|
|
}
|
|
}
|
|
|
|
const timeout = scalar(item.fields, "automation_response_timeout_ms");
|
|
if (timeout && (!/^\d+$/.test(timeout) || Number.parseInt(timeout, 10) <= 0)) {
|
|
errors.push(`${item.path}: 'automation_response_timeout_ms' must be a positive integer string`);
|
|
}
|
|
const streamOutput = scalar(item.fields, "automation_stream_output");
|
|
if (streamOutput && !["0", "1", "false", "true"].includes(streamOutput)) {
|
|
errors.push(`${item.path}: 'automation_stream_output' must be one of 0, 1, false, or true`);
|
|
}
|
|
const imageBase64Fixture = scalar(item.fields, "automation_image_base64_fixture");
|
|
if (imageBase64Fixture && !existsSync(join(root, imageBase64Fixture))) {
|
|
errors.push(`${item.path}: automation image fixture does not exist: ${imageBase64Fixture}`);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateSetupAutomationCycles(caseItems: StructuredItem[]): string[] {
|
|
const byId = new Map(caseItems.map((item) => [scalar(item.fields, "id"), item]));
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
const errors: string[] = [];
|
|
|
|
function visit(id: string, path: string[]): void {
|
|
if (visited.has(id)) return;
|
|
if (visiting.has(id)) {
|
|
const cycle = [...path.slice(path.indexOf(id)), id].join(" -> ");
|
|
const item = byId.get(id);
|
|
errors.push(`${item?.path ?? id}: setup_automation case cycle detected: ${cycle}`);
|
|
return;
|
|
}
|
|
const item = byId.get(id);
|
|
if (!item) return;
|
|
visiting.add(id);
|
|
for (const entry of listValue(item.fields, "setup_automation")) {
|
|
const spec = parseSetupAutomationEntry(entry);
|
|
if (spec.kind === "case") visit(spec.target, [...path, spec.target]);
|
|
}
|
|
visiting.delete(id);
|
|
visited.add(id);
|
|
}
|
|
|
|
for (const id of byId.keys()) visit(id, [id]);
|
|
return errors;
|
|
}
|
|
|
|
function validateTroubleshootingItem(item: StructuredItem, caseIds: Set<string>): string[] {
|
|
const errors = [
|
|
...validateEnum(item, "category", troubleshootingCategoryValues),
|
|
...validateDuplicateListValues(item, ["symptoms", "patterns", "likely_causes", "fix_steps", "related_cases"]),
|
|
];
|
|
for (const id of listValue(item.fields, "related_cases")) {
|
|
if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateFixtures(root: string, caseIds: Set<string>): string[] {
|
|
const { items, errors } = loadFixtureItems(root);
|
|
const result = [...errors];
|
|
const seen = new Map<string, string>();
|
|
for (const item of items) {
|
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.id)) {
|
|
result.push(`${item.manifest_path}: fixture id '${item.id}' must use lowercase letters, digits, dashes, or underscores`);
|
|
}
|
|
if (seen.has(item.id)) {
|
|
result.push(`${item.manifest_path}: duplicate fixture id '${item.id}' also used by ${seen.get(item.id)}`);
|
|
} else {
|
|
seen.set(item.id, item.manifest_path);
|
|
}
|
|
if (!item.exists) result.push(`${item.manifest_path}: fixture path does not exist: ${item.path}`);
|
|
for (const caseId of item.related_cases) {
|
|
if (!caseIds.has(caseId)) result.push(`${item.manifest_path}: fixture '${item.id}' references unknown case '${caseId}'`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateSuiteItem(item: StructuredItem, caseIds: Set<string>): string[] {
|
|
const errors = [
|
|
...validateEnum(item, "type", suiteTypeValues),
|
|
...validateEnum(item, "priority", casePriorityValues),
|
|
...validateDuplicateListValues(item, ["tags", "cases"]),
|
|
];
|
|
for (const id of listValue(item.fields, "cases")) {
|
|
if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateDuplicateIds(items: StructuredItem[], label: string): string[] {
|
|
const errors: string[] = [];
|
|
const seen = new Map<string, string>();
|
|
for (const item of items) {
|
|
const id = scalar(item.fields, "id");
|
|
if (!id) continue;
|
|
const key = `${item.skill}:${id}`;
|
|
if (seen.has(key)) errors.push(`${item.path}: duplicate ${label} id '${id}' also used by ${seen.get(key)}`);
|
|
else seen.set(key, item.path);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateGlobalDuplicateIds(items: StructuredItem[], label: string): string[] {
|
|
const errors: string[] = [];
|
|
const seen = new Map<string, string>();
|
|
for (const item of items) {
|
|
const id = scalar(item.fields, "id");
|
|
if (!id) continue;
|
|
if (seen.has(id)) errors.push(`${item.path}: duplicate global ${label} id '${id}' also used by ${seen.get(id)}`);
|
|
else seen.set(id, item.path);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateEnv(root: string): string[] {
|
|
const path = envPath(root);
|
|
const examplePath = envExamplePath(root);
|
|
const errors: string[] = [];
|
|
if (!existsSync(path)) return [`${path}: missing shared env file`];
|
|
const env = parseEnvFile(path);
|
|
for (const key of requiredEnvKeys) {
|
|
if (!(key in env)) errors.push(`${path}: missing ${key}`);
|
|
}
|
|
if (!existsSync(examplePath)) {
|
|
errors.push(`${examplePath}: missing env template`);
|
|
} else {
|
|
const example = parseEnvFile(examplePath);
|
|
for (const key of requiredEnvKeys) {
|
|
if (!(key in example)) errors.push(`${examplePath}: missing template key ${key}`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateSchemas(root: string): string[] {
|
|
const errors: string[] = [];
|
|
for (const name of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) {
|
|
const path = join(root, "schemas", name);
|
|
if (!existsSync(path)) {
|
|
errors.push(`${path}: missing schema`);
|
|
continue;
|
|
}
|
|
try {
|
|
JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (error) {
|
|
errors.push(`${path}: invalid JSON schema (${String(error)})`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateSkill(skill: Skill): string[] {
|
|
const errors: string[] = [];
|
|
if (!skill.name) errors.push(`${skill.path}: missing frontmatter name`);
|
|
if (!skill.description) errors.push(`${skill.path}: missing frontmatter description`);
|
|
if (skill.name && skill.name !== skill.directory) {
|
|
errors.push(`${skill.path}: name '${skill.name}' does not match directory '${skill.directory}'`);
|
|
}
|
|
|
|
const refs = new Set<string>();
|
|
for (const match of skill.body.matchAll(refRe)) refs.add(match[1]);
|
|
for (const ref of Array.from(refs).sort()) {
|
|
if (!existsSync(join(skill.path, ref))) {
|
|
errors.push(`${skill.path}: referenced file does not exist: ${ref}`);
|
|
}
|
|
}
|
|
|
|
const legacyTroubleshooting = join(skill.path, "references", "troubleshooting.md");
|
|
if (existsSync(legacyTroubleshooting)) {
|
|
const text = readFileSync(legacyTroubleshooting, "utf8");
|
|
if (text.includes("\n## ") && !text.includes("### Symptom")) {
|
|
errors.push(`${legacyTroubleshooting}: troubleshooting entries should include '### Symptom'`);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function commandValidate(root: string): number {
|
|
const skills = loadSkills(root);
|
|
const caseItems = loadStructuredItems(root, "cases");
|
|
const suiteItems = loadStructuredItems(root, "suites");
|
|
const troubleItems = loadStructuredItems(root, "troubleshooting");
|
|
const skillNames = new Set(skills.map((skill) => skill.name));
|
|
const caseIds = new Set(caseItems.map((item) => scalar(item.fields, "id")).filter(Boolean));
|
|
const troubleIds = new Set(troubleItems.map((item) => scalar(item.fields, "id")).filter(Boolean));
|
|
const errors = [
|
|
...validateEnv(root),
|
|
...validateSchemas(root),
|
|
...skills.flatMap(validateSkill),
|
|
...caseItems.flatMap((item) => validateStructuredItem(item, caseRequiredStrings, caseRequiredLists)),
|
|
...caseItems.flatMap((item) => validateCaseItem(root, item, skillNames, troubleIds, caseIds)),
|
|
...validateSetupAutomationCycles(caseItems),
|
|
...suiteItems.flatMap((item) => validateStructuredItem(item, suiteRequiredStrings, suiteRequiredLists)),
|
|
...suiteItems.flatMap((item) => validateSuiteItem(item, caseIds)),
|
|
...troubleItems.flatMap((item) => validateStructuredItem(item, troubleRequiredStrings, troubleRequiredLists)),
|
|
...troubleItems.flatMap((item) => validateTroubleshootingItem(item, caseIds)),
|
|
...validateFixtures(root, caseIds),
|
|
...validateDuplicateIds(caseItems, "case"),
|
|
...validateDuplicateIds(suiteItems, "suite"),
|
|
...validateDuplicateIds(troubleItems, "troubleshooting"),
|
|
...validateGlobalDuplicateIds(caseItems, "case"),
|
|
...validateGlobalDuplicateIds(suiteItems, "suite"),
|
|
...validateGlobalDuplicateIds(troubleItems, "troubleshooting"),
|
|
];
|
|
|
|
if (errors.length > 0) {
|
|
for (const error of errors) stderr.write(`ERROR: ${error}\n`);
|
|
return 1;
|
|
}
|
|
console.log("OK");
|
|
return 0;
|
|
}
|