mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 23:44: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.
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import type { CommandContext } from "../types.ts";
|
|
import { optionString, parseOptions, usage } from "../cli.ts";
|
|
import { findStructuredItem, loadEnv } from "../fs.ts";
|
|
import {
|
|
latestLangBotLogPath,
|
|
logPatternContextFromStructuredItem,
|
|
renderLogFinding,
|
|
renderLogSuccessSignal,
|
|
scanLogSources,
|
|
scanLogText,
|
|
strictLogGuardExitCode,
|
|
type LogFinding,
|
|
type LogGuardPatternContext,
|
|
type LogGuardResult,
|
|
type LogSuccessSignal,
|
|
} from "../log-guard.ts";
|
|
|
|
type LogGuardSession = {
|
|
source: "log-guard-session";
|
|
run_id: string;
|
|
started_at: string;
|
|
started_at_local: string;
|
|
backend_log: string;
|
|
case_id: string;
|
|
case_skill: string;
|
|
};
|
|
|
|
type WatchSummary = {
|
|
mode: "watch";
|
|
status: string;
|
|
path: string;
|
|
started_at_local: string;
|
|
finished_at_local: string;
|
|
bytes_read: number;
|
|
findings: LogFinding[];
|
|
success_signals: LogSuccessSignal[];
|
|
};
|
|
|
|
function pad2(value: number): string {
|
|
return String(value).padStart(2, "0");
|
|
}
|
|
|
|
function pad3(value: number): string {
|
|
return String(value).padStart(3, "0");
|
|
}
|
|
|
|
function localIsoWithOffset(date: Date): string {
|
|
const offsetMinutes = -date.getTimezoneOffset();
|
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
const absoluteOffset = Math.abs(offsetMinutes);
|
|
return [
|
|
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
|
|
`T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`,
|
|
`${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`,
|
|
].join("");
|
|
}
|
|
|
|
function timestampSlug(localIso: string): string {
|
|
return localIso
|
|
.replace(/T/, "-")
|
|
.replace(/[.:+]/g, "-")
|
|
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function writeOrPrint(content: string, output: string | undefined): void {
|
|
if (!output) {
|
|
console.log(content.trimEnd());
|
|
return;
|
|
}
|
|
const path = resolve(output);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, content, "utf8");
|
|
console.log(path);
|
|
}
|
|
|
|
function positiveIntegerOption(options: Record<string, string | boolean>, key: string, fallback: number): number {
|
|
const raw = optionString(options, key);
|
|
if (!raw) return fallback;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!/^\d+$/.test(raw) || parsed <= 0) return fallback;
|
|
return parsed;
|
|
}
|
|
|
|
function splitPatternList(value: string | undefined): string[] {
|
|
if (!value) return [];
|
|
return value
|
|
.split(/\s*\|\s*|\s*,\s*/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function patternContextFromOptions(root: string, options: Record<string, string | boolean>): LogGuardPatternContext {
|
|
const caseId = optionString(options, "case");
|
|
const base = caseId ? logPatternContextFromStructuredItem(findStructuredItem(root, "cases", caseId)) : {};
|
|
return {
|
|
successPatterns: [
|
|
...(base.successPatterns ?? []),
|
|
...splitPatternList(optionString(options, "success-pattern")),
|
|
],
|
|
failurePatterns: [
|
|
...(base.failurePatterns ?? []),
|
|
...splitPatternList(optionString(options, "failure-pattern")),
|
|
],
|
|
expectedFailures: [
|
|
...(base.expectedFailures ?? []),
|
|
...splitPatternList(optionString(options, "expected-failure")),
|
|
],
|
|
relatedTroubleshootingIds: base.relatedTroubleshootingIds ?? [],
|
|
};
|
|
}
|
|
|
|
function latestOrExplicitBackendLog(root: string, options: Record<string, string | boolean>): string {
|
|
const explicit = optionString(options, "backend-log");
|
|
if (explicit) return resolve(explicit);
|
|
const auto = latestLangBotLogPath(loadEnv(root));
|
|
return auto ? resolve(auto) : "";
|
|
}
|
|
|
|
function renderSources(result: LogGuardResult): string[] {
|
|
const lines: string[] = [];
|
|
if (result.sources.length === 0) {
|
|
lines.push("- sources: no log files provided; use --backend-log or configure LANGBOT_REPO.");
|
|
return lines;
|
|
}
|
|
lines.push("- sources:");
|
|
for (const source of result.sources) {
|
|
const origin = source.auto_detected ? ", auto" : "";
|
|
const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`;
|
|
const range = source.start_line === undefined || source.end_line === undefined
|
|
? ""
|
|
: `, lines ${source.start_line}-${source.end_line}`;
|
|
const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`;
|
|
lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function renderLogGuardMarkdown(title: string, result: LogGuardResult, extra: string[] = []): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# ${title}`);
|
|
lines.push("");
|
|
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
lines.push(`Status: ${result.status}`);
|
|
lines.push(`Scan mode: ${result.scan.mode}`);
|
|
if (result.scan.since) lines.push(`Since: ${result.scan.since}`);
|
|
if (result.scan.until) lines.push(`Until: ${result.scan.until}`);
|
|
if (result.scan.tail_lines !== undefined) lines.push(`Tail lines: ${result.scan.tail_lines}`);
|
|
if (extra.length > 0) {
|
|
lines.push("");
|
|
lines.push("## Context");
|
|
for (const item of extra) lines.push(`- ${item}`);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Sources");
|
|
lines.push(...renderSources(result));
|
|
if (result.scan.warnings.length > 0) {
|
|
lines.push("");
|
|
lines.push("## Scan Warnings");
|
|
for (const warning of result.scan.warnings) lines.push(`- ${warning}`);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Findings");
|
|
if (result.findings.length === 0) lines.push("- None.");
|
|
else for (const finding of result.findings) lines.push(renderLogFinding(finding));
|
|
lines.push("");
|
|
lines.push("## Success Signals");
|
|
if (result.success_signals.length === 0) lines.push("- None.");
|
|
else for (const signal of result.success_signals) lines.push(renderLogSuccessSignal(signal));
|
|
lines.push("");
|
|
return `${lines.join("\n").trimEnd()}\n`;
|
|
}
|
|
|
|
function statusFromEvents(findings: LogFinding[], successSignals: LogSuccessSignal[]): string {
|
|
if (findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input")) return "fail";
|
|
if (findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false)) return "fail";
|
|
if (findings.some((finding) => finding.severity === "env_issue")) return "env_issue";
|
|
if (findings.some((finding) => finding.severity === "warning")) return "warning";
|
|
if (successSignals.length > 0) return "pass";
|
|
return "no_activity";
|
|
}
|
|
|
|
function strictSummaryExitCode(status: string): number {
|
|
return status === "fail" || status === "env_issue" ? 1 : 0;
|
|
}
|
|
|
|
function sessionDir(options: Record<string, string | boolean>): string {
|
|
return optionString(options, "output-dir") ?? join("reports", "log-guards");
|
|
}
|
|
|
|
function sessionPath(options: Record<string, string | boolean>, runId: string): string {
|
|
return join(sessionDir(options), `${runId}.json`);
|
|
}
|
|
|
|
function readSession(options: Record<string, string | boolean>): LogGuardSession | undefined {
|
|
const runId = optionString(options, "run-id");
|
|
const explicitSession = optionString(options, "session");
|
|
const path = explicitSession ? resolve(explicitSession) : runId ? resolve(sessionPath(options, runId)) : "";
|
|
if (!path || !existsSync(path)) return undefined;
|
|
return JSON.parse(readFileSync(path, "utf8")) as LogGuardSession;
|
|
}
|
|
|
|
export function commandLogScan(ctx: CommandContext): number {
|
|
const { positional, options } = parseOptions(ctx.args.slice(2));
|
|
if (positional.length > 0) usage();
|
|
|
|
const result = scanLogSources(ctx.root, options, patternContextFromOptions(ctx.root, options));
|
|
const output = optionString(options, "output");
|
|
const content = options.json === true
|
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
: renderLogGuardMarkdown("Log Guard Scan", result, [
|
|
optionString(options, "case") ? `case: ${optionString(options, "case")}` : "case: none",
|
|
options.strict === true ? "strict: yes" : "strict: no",
|
|
]);
|
|
writeOrPrint(content, output);
|
|
return options.strict === true ? strictLogGuardExitCode(result) : 0;
|
|
}
|
|
|
|
export async function commandLogWatch(ctx: CommandContext): Promise<number> {
|
|
const { positional, options } = parseOptions(ctx.args.slice(2));
|
|
if (positional.length > 0) usage();
|
|
|
|
const path = latestOrExplicitBackendLog(ctx.root, options);
|
|
if (!path) {
|
|
console.error("ERROR: no backend log found; pass --backend-log or configure LANGBOT_REPO.");
|
|
return 1;
|
|
}
|
|
if (!existsSync(path)) {
|
|
console.error(`ERROR: backend log does not exist: ${path}`);
|
|
return 1;
|
|
}
|
|
|
|
const context = patternContextFromOptions(ctx.root, options);
|
|
const intervalMs = positiveIntegerOption(options, "interval-ms", 1000);
|
|
const durationMs = optionString(options, "duration-ms")
|
|
? positiveIntegerOption(options, "duration-ms", 0)
|
|
: 0;
|
|
const startedAtLocal = localIsoWithOffset(new Date());
|
|
const findings: LogFinding[] = [];
|
|
const successSignals: LogSuccessSignal[] = [];
|
|
let bytesRead = 0;
|
|
let offset = options["from-start"] === true ? 0 : statSync(path).size;
|
|
let baseLineNumber = options["from-start"] === true
|
|
? 0
|
|
: readFileSync(path).subarray(0, offset).toString("utf8").split(/\r?\n/).length - 1;
|
|
let carry = "";
|
|
|
|
if (options.json !== true) {
|
|
console.log(`# Log Guard Watch`);
|
|
console.log(`Path: ${path}`);
|
|
console.log(`Started: ${startedAtLocal}`);
|
|
console.log(`Mode: ${options["from-start"] === true ? "from-start" : "new-lines"}`);
|
|
}
|
|
|
|
const startedMs = Date.now();
|
|
let stopRequested = false;
|
|
const stop = (): void => {
|
|
stopRequested = true;
|
|
};
|
|
process.once("SIGINT", stop);
|
|
process.once("SIGTERM", stop);
|
|
|
|
const poll = (): void => {
|
|
const buffer = readFileSync(path);
|
|
if (buffer.length < offset) {
|
|
offset = 0;
|
|
baseLineNumber = 0;
|
|
carry = "";
|
|
}
|
|
if (buffer.length === offset) return;
|
|
|
|
const chunk = buffer.subarray(offset).toString("utf8");
|
|
offset = buffer.length;
|
|
bytesRead += Buffer.byteLength(chunk);
|
|
const text = `${carry}${chunk}`;
|
|
const hasCompleteLine = /\r?\n$/.test(text);
|
|
const lastNewline = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r"));
|
|
if (!hasCompleteLine && lastNewline === -1) {
|
|
carry = text;
|
|
return;
|
|
}
|
|
|
|
const complete = hasCompleteLine ? text : text.slice(0, lastNewline + 1);
|
|
carry = hasCompleteLine ? "" : text.slice(lastNewline + 1);
|
|
if (!complete) return;
|
|
|
|
const result = scanLogText(ctx.root, "backend", path, complete, {}, context, baseLineNumber, false);
|
|
baseLineNumber += complete.split(/\r?\n/).length - 1;
|
|
findings.push(...result.findings);
|
|
successSignals.push(...result.success_signals);
|
|
|
|
if (options.json !== true) {
|
|
for (const finding of result.findings) console.log(renderLogFinding(finding));
|
|
for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal));
|
|
}
|
|
};
|
|
|
|
try {
|
|
do {
|
|
poll();
|
|
if (stopRequested) break;
|
|
if (durationMs > 0 && Date.now() - startedMs >= durationMs) break;
|
|
await delay(Math.min(intervalMs, durationMs > 0 ? Math.max(1, durationMs - (Date.now() - startedMs)) : intervalMs));
|
|
} while (!stopRequested);
|
|
|
|
if (carry) {
|
|
const result = scanLogText(ctx.root, "backend", path, carry, {}, context, baseLineNumber, false);
|
|
findings.push(...result.findings);
|
|
successSignals.push(...result.success_signals);
|
|
if (options.json !== true) {
|
|
for (const finding of result.findings) console.log(renderLogFinding(finding));
|
|
for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal));
|
|
}
|
|
}
|
|
} finally {
|
|
process.off("SIGINT", stop);
|
|
process.off("SIGTERM", stop);
|
|
}
|
|
|
|
const summary: WatchSummary = {
|
|
mode: "watch",
|
|
status: statusFromEvents(findings, successSignals),
|
|
path,
|
|
started_at_local: startedAtLocal,
|
|
finished_at_local: localIsoWithOffset(new Date()),
|
|
bytes_read: bytesRead,
|
|
findings,
|
|
success_signals: successSignals,
|
|
};
|
|
|
|
if (options.json === true) {
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
} else {
|
|
console.log(`Status: ${summary.status}`);
|
|
console.log(`Bytes read: ${summary.bytes_read}`);
|
|
}
|
|
return options.strict === true ? strictSummaryExitCode(summary.status) : 0;
|
|
}
|
|
|
|
export function commandLogGuard(ctx: CommandContext): number {
|
|
const sub = ctx.args[2];
|
|
if (sub === "start") return commandLogGuardStart(ctx);
|
|
if (sub === "stop") return commandLogGuardStop(ctx);
|
|
usage();
|
|
}
|
|
|
|
function commandLogGuardStart(ctx: CommandContext): number {
|
|
const { positional, options } = parseOptions(ctx.args.slice(3));
|
|
if (positional.length > 0) usage();
|
|
|
|
const now = new Date();
|
|
const startedAtLocal = localIsoWithOffset(now);
|
|
const runId = optionString(options, "run-id") ?? `log-guard-${timestampSlug(startedAtLocal)}`;
|
|
const caseId = optionString(options, "case") ?? "";
|
|
const caseItem = caseId ? findStructuredItem(ctx.root, "cases", caseId) : undefined;
|
|
const session: LogGuardSession = {
|
|
source: "log-guard-session",
|
|
run_id: runId,
|
|
started_at: now.toISOString(),
|
|
started_at_local: startedAtLocal,
|
|
backend_log: latestOrExplicitBackendLog(ctx.root, options),
|
|
case_id: caseId,
|
|
case_skill: caseItem?.skill ?? "",
|
|
};
|
|
const path = resolve(sessionPath(options, runId));
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, "utf8");
|
|
|
|
const result = {
|
|
...session,
|
|
path,
|
|
stop_command: `bin/lbs log guard stop --run-id ${runId} --output-dir ${sessionDir(options)}`,
|
|
};
|
|
if (options.json === true) console.log(JSON.stringify(result, null, 2));
|
|
else {
|
|
console.log(`# Log Guard Session`);
|
|
console.log(`Run: ${runId}`);
|
|
console.log(`Started: ${startedAtLocal}`);
|
|
console.log(`Session: ${path}`);
|
|
if (session.backend_log) console.log(`Backend log: ${session.backend_log}`);
|
|
if (session.case_id) console.log(`Case: ${session.case_id}`);
|
|
console.log(`Stop: ${result.stop_command}`);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function commandLogGuardStop(ctx: CommandContext): number {
|
|
const { positional, options } = parseOptions(ctx.args.slice(3));
|
|
if (positional.length > 0) usage();
|
|
|
|
const session = readSession(options);
|
|
if (!session) {
|
|
console.error("ERROR: log guard session not found; pass --run-id with --output-dir or --session.");
|
|
return 1;
|
|
}
|
|
|
|
const now = new Date();
|
|
const scanOptions: Record<string, string | boolean> = {
|
|
...options,
|
|
since: optionString(options, "since") ?? session.started_at_local,
|
|
until: optionString(options, "until") ?? localIsoWithOffset(now),
|
|
};
|
|
if (session.backend_log && typeof scanOptions["backend-log"] !== "string") {
|
|
scanOptions["backend-log"] = session.backend_log;
|
|
}
|
|
|
|
const caseId = optionString(options, "case") ?? session.case_id;
|
|
const context = caseId
|
|
? logPatternContextFromStructuredItem(findStructuredItem(ctx.root, "cases", caseId))
|
|
: patternContextFromOptions(ctx.root, options);
|
|
const result = scanLogSources(ctx.root, scanOptions, context);
|
|
const output = optionString(options, "output") ?? join(sessionDir(options), `${session.run_id}.md`);
|
|
const content = options.json === true
|
|
? `${JSON.stringify({ session, result }, null, 2)}\n`
|
|
: renderLogGuardMarkdown("Log Guard Report", result, [
|
|
`run_id: ${session.run_id}`,
|
|
`started: ${session.started_at_local}`,
|
|
`finished: ${scanOptions.until}`,
|
|
caseId ? `case: ${caseId}` : "case: none",
|
|
]);
|
|
writeOrPrint(content, options.json === true ? optionString(options, "output") : output);
|
|
return options["no-strict"] === true ? 0 : strictLogGuardExitCode(result);
|
|
}
|