mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-23 05:54:22 +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.
252 lines
9.4 KiB
TypeScript
252 lines
9.4 KiB
TypeScript
import { env as processEnv } from "node:process";
|
|
import type { StructuredItem } from "./types.ts";
|
|
import { loadFixtureItems } from "./fixtures.ts";
|
|
import { listValue, loadEnv, scalar } from "./fs.ts";
|
|
import { splitEnvAnyGroup } from "./env-groups.ts";
|
|
|
|
type EnvSource = Record<string, string | undefined>;
|
|
|
|
export type EnvReadiness = {
|
|
status: "ready" | "missing" | "not_required";
|
|
required: string[];
|
|
configured: string[];
|
|
missing: string[];
|
|
values: Record<string, string>;
|
|
};
|
|
|
|
export type AutomationReadiness = EnvReadiness & {
|
|
script: string;
|
|
defaulted: string[];
|
|
pipeline_env_required: boolean;
|
|
env_aliases: Array<{
|
|
target: string;
|
|
source: string;
|
|
configured: boolean;
|
|
}>;
|
|
};
|
|
|
|
export type ManualReadiness = {
|
|
status: "manual_check" | "not_required";
|
|
preconditions: string[];
|
|
setup: string[];
|
|
cleanup: string[];
|
|
};
|
|
|
|
export type FixtureReadiness = {
|
|
status: "ready" | "missing" | "not_required";
|
|
required: Array<{
|
|
id: string;
|
|
kind: string;
|
|
path: string;
|
|
exists: boolean;
|
|
}>;
|
|
missing: string[];
|
|
};
|
|
|
|
const secretKeyRe = /(?:api[_-]?key|authorization|bearer|credential|jwt|oauth|password|secret|token)/i;
|
|
|
|
export function redactEnvValue(key: string, value: string): string {
|
|
if (!value) return "";
|
|
if (secretKeyRe.test(key)) return "[redacted]";
|
|
return value.replace(/(https?:\/\/)([^:@/\s]+):([^@/\s]+)@/i, "$1[redacted]@");
|
|
}
|
|
|
|
export function runtimeEnv(root: string): Record<string, string> {
|
|
const result: Record<string, string> = { ...loadEnv(root) };
|
|
for (const [key, value] of Object.entries(processEnv)) {
|
|
if (typeof value === "string") result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function envReadiness(
|
|
keys: string[],
|
|
env: EnvSource,
|
|
defaults: Record<string, string> = {},
|
|
anyGroups: string[] = [],
|
|
providedBySetup: Set<string> = new Set(),
|
|
): EnvReadiness {
|
|
const required = [...keys];
|
|
const configured = required.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key));
|
|
const missing = required.filter((key) => !env[key] && !defaults[key] && !providedBySetup.has(key));
|
|
const values: Record<string, string> = Object.fromEntries(
|
|
required.map((key) => [key, redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup))]),
|
|
);
|
|
|
|
for (const group of anyGroups) {
|
|
const keysInGroup = splitEnvAnyGroup(group);
|
|
required.push(group);
|
|
const configuredKeys = keysInGroup.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key));
|
|
if (configuredKeys.length === 0) missing.push(group);
|
|
else configured.push(...configuredKeys);
|
|
for (const key of keysInGroup) {
|
|
values[key] = redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup));
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: required.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing",
|
|
required,
|
|
configured: Array.from(new Set(configured)),
|
|
missing,
|
|
values,
|
|
};
|
|
}
|
|
|
|
function setupProvidedValue(key: string, providedBySetup: Set<string>): string {
|
|
return providedBySetup.has(key) ? "[provided by setup_automation]" : "";
|
|
}
|
|
|
|
export function setupProvidedEnv(item: StructuredItem): Set<string> {
|
|
return new Set(listValue(item.fields, "setup_provides_env"));
|
|
}
|
|
|
|
export function automationEnvDefaults(item: StructuredItem, env: EnvSource = processEnv): Record<string, string> {
|
|
const mapping: Array<[string, string]> = [
|
|
["automation_prompt", "LANGBOT_E2E_PROMPT"],
|
|
["automation_prompts_json", "LANGBOT_E2E_PROMPTS_JSON"],
|
|
["automation_expected_text", "LANGBOT_E2E_EXPECTED_TEXT"],
|
|
["automation_response_timeout_ms", "LANGBOT_E2E_RESPONSE_TIMEOUT_MS"],
|
|
["automation_stream_output", "LANGBOT_E2E_STREAM_OUTPUT"],
|
|
["automation_image_base64_fixture", "LANGBOT_E2E_IMAGE_BASE64_PATH"],
|
|
["automation_runner_config_patch_json", "LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON"],
|
|
["automation_restore_runner_config", "LANGBOT_E2E_RESTORE_RUNNER_CONFIG"],
|
|
["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"],
|
|
["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"],
|
|
["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"],
|
|
["automation_filesystem_checks_json", "LANGBOT_E2E_FILESYSTEM_CHECKS_JSON"],
|
|
["automation_plugin_package", "LANGBOT_E2E_PLUGIN_PACKAGE"],
|
|
["automation_expected_plugin_id", "LANGBOT_E2E_EXPECTED_PLUGIN_ID"],
|
|
["automation_expected_tool", "LANGBOT_E2E_EXPECTED_TOOL"],
|
|
];
|
|
const defaults: Record<string, string> = {};
|
|
for (const [field, envKey] of mapping) {
|
|
const value = scalar(item.fields, field);
|
|
if (value) defaults[envKey] = expandEnvRefs(value, env);
|
|
}
|
|
const failurePatterns = listValue(item.fields, "failure_patterns");
|
|
if (failurePatterns.length > 0) defaults.LANGBOT_E2E_FAILURE_SIGNALS = failurePatterns.join("\n");
|
|
return defaults;
|
|
}
|
|
|
|
function expandEnvRefs(value: string, env: EnvSource): string {
|
|
return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_match, braced, bare) => {
|
|
return env[braced || bare] || "";
|
|
});
|
|
}
|
|
|
|
export function caseEnvReadiness(item: StructuredItem, env: EnvSource): EnvReadiness {
|
|
const aliasSources = new Set(automationEnvAliases(item, env).map((alias) => alias.source));
|
|
const provided = setupProvidedEnv(item);
|
|
return envReadiness(
|
|
listValue(item.fields, "env").filter((key) => !aliasSources.has(key)),
|
|
env,
|
|
{},
|
|
listValue(item.fields, "env_any"),
|
|
provided,
|
|
);
|
|
}
|
|
|
|
function automationEnvAliases(item: StructuredItem, env: EnvSource): Array<{
|
|
target: string;
|
|
source: string;
|
|
configured: boolean;
|
|
}> {
|
|
const provided = setupProvidedEnv(item);
|
|
const mapping: Array<[string, string]> = [
|
|
["automation_pipeline_url_env", "LANGBOT_E2E_PIPELINE_URL"],
|
|
["automation_pipeline_name_env", "LANGBOT_E2E_PIPELINE_NAME"],
|
|
];
|
|
return mapping
|
|
.map(([field, target]) => {
|
|
const source = scalar(item.fields, field);
|
|
return source ? { target, source, configured: Boolean(env[source]) || provided.has(source) } : null;
|
|
})
|
|
.filter((item): item is { target: string; source: string; configured: boolean } => item !== null);
|
|
}
|
|
|
|
export function automationPipelineEnvRequired(item: StructuredItem): boolean {
|
|
return Boolean(scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env"));
|
|
}
|
|
|
|
export function caseAutomationReadiness(item: StructuredItem, env: EnvSource): AutomationReadiness {
|
|
const script = scalar(item.fields, "automation");
|
|
const aliases = automationEnvAliases(item, env);
|
|
const aliasSources = new Set(aliases.map((alias) => alias.source));
|
|
const defaults = automationEnvDefaults(item, env);
|
|
const provided = setupProvidedEnv(item);
|
|
const requiredKeys = listValue(item.fields, "automation_env").filter((key) => !aliasSources.has(key));
|
|
const readiness = envReadiness(requiredKeys, env, defaults, listValue(item.fields, "automation_env_any"), provided);
|
|
const defaulted = requiredKeys.filter((key) => !env[key] && Boolean(defaults[key]));
|
|
const aliasConfigured = aliases.some((alias) => alias.configured);
|
|
const aliasMissing = automationPipelineEnvRequired(item) && !aliasConfigured
|
|
? [aliases.map((alias) => alias.source).join("|")]
|
|
: [];
|
|
const missing = [...readiness.missing, ...aliasMissing].filter(Boolean);
|
|
const configured = [
|
|
...readiness.configured,
|
|
...aliases.filter((alias) => alias.configured).map((alias) => alias.source),
|
|
];
|
|
const values = {
|
|
...readiness.values,
|
|
...Object.fromEntries(aliases.map((alias) => [
|
|
alias.source,
|
|
redactEnvValue(alias.source, env[alias.source] ?? setupProvidedValue(alias.source, provided)),
|
|
])),
|
|
};
|
|
return {
|
|
...readiness,
|
|
status: script ? missing.length === 0 ? "ready" : "missing" : "not_required",
|
|
script,
|
|
defaulted,
|
|
required: [...readiness.required, ...aliases.map((alias) => alias.source)],
|
|
configured,
|
|
missing,
|
|
values,
|
|
pipeline_env_required: automationPipelineEnvRequired(item),
|
|
env_aliases: aliases,
|
|
};
|
|
}
|
|
|
|
export function resolvedAutomationEnvOverrides(item: StructuredItem, env: EnvSource): Record<string, string> {
|
|
const overrides: Record<string, string> = {};
|
|
for (const alias of automationEnvAliases(item, env)) {
|
|
const value = env[alias.source];
|
|
if (value) overrides[alias.target] = value;
|
|
}
|
|
for (const [key, value] of Object.entries(automationEnvDefaults(item, env))) {
|
|
overrides[key] = expandEnvRefs(value, env);
|
|
}
|
|
if (automationPipelineEnvRequired(item)) overrides.LANGBOT_E2E_PIPELINE_REQUIRED = "1";
|
|
return overrides;
|
|
}
|
|
|
|
export function caseManualReadiness(item: StructuredItem): ManualReadiness {
|
|
const preconditions = listValue(item.fields, "preconditions");
|
|
const setup = listValue(item.fields, "setup");
|
|
const cleanup = listValue(item.fields, "cleanup");
|
|
return {
|
|
status: preconditions.length > 0 || setup.length > 0 ? "manual_check" : "not_required",
|
|
preconditions,
|
|
setup,
|
|
cleanup,
|
|
};
|
|
}
|
|
|
|
export function caseFixtureReadiness(root: string, caseId: string): FixtureReadiness {
|
|
const fixtures = loadFixtureItems(root).items
|
|
.filter((item) => item.related_cases.includes(caseId))
|
|
.map((item) => ({
|
|
id: item.id,
|
|
kind: item.kind,
|
|
path: item.path,
|
|
exists: item.exists,
|
|
}));
|
|
const missing = fixtures.filter((item) => !item.exists).map((item) => item.id);
|
|
return {
|
|
status: fixtures.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing",
|
|
required: fixtures,
|
|
missing,
|
|
};
|
|
}
|