maestro/src/bridge/skills-api.ts
2026-06-03 05:08:00 +00:00

421 lines
13 KiB
TypeScript

import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs';
import { join, relative } from 'path';
import { randomBytes } from 'crypto';
import type { SkillCatalog, SkillEntry } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js';
import { logger } from '../logger.js';
import { handleInstallFromUrl } from './skills-git-install.js';
const MAX_CONTENT_SIZE = 64 * 1024; // 64 KB
export interface MountSkillsApiOptions {
skillCatalog: SkillCatalog;
requireAuth?: RequestHandler;
requireAdmin?: RequestHandler;
authActive?: boolean;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
}
type AuthedUser = { id?: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function getUserId(req: Request): string {
const user = getUser(req);
return user?.id ?? 'local';
}
function isAdmin(req: Request): boolean {
const user = getUser(req);
return user?.role === 'admin';
}
/**
* Recursively list files in a directory, skipping symlinks.
* Returns paths relative to `baseDir`.
*/
function listDirFiles(baseDir: string, maxDepth: number = 5): string[] {
const results: string[] = [];
function walk(dir: string, depth: number): void {
if (depth > maxDepth) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
walk(fullPath, depth + 1);
} else if (stat.isFile()) {
results.push(relative(baseDir, fullPath));
}
}
}
walk(baseDir, 0);
return results;
}
export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void {
const { skillCatalog } = opts;
// JSON body parser for skills endpoints
app.use('/api/skills', express.json());
// Auth gating
if (opts.authActive && opts.requireAuth) {
app.use('/api/skills', opts.requireAuth);
}
// ── GET /api/skills ── list skills ──────────────────────────────
app.get('/api/skills', (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const scope = (req.query.scope as string) ?? 'all';
if (!['all', 'system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: all, system, user' });
return;
}
const entries = skillCatalog.getForUser(userId);
const filtered = scope === 'all'
? entries
: entries.filter(e => e.source === scope);
const skills = filtered.map(e => ({
name: e.name,
description: e.description,
triggers: e.triggers,
source: e.source,
hasDir: e.dirPath !== null,
}));
res.json({ skills });
} catch (e) {
res.status(500).json({ error: `Failed to list skills: ${e}` });
}
});
// ── POST /api/skills/install-from-url ── Git URL install ────────
// Must be before /:name routes to avoid Express matching 'install-from-url' as :name
app.post('/api/skills/install-from-url', handleInstallFromUrl({
skillCatalog: opts.skillCatalog,
auditLog: opts.auditLog,
}));
// ── GET /api/skills/:name ── skill detail ──────────────────────
app.get('/api/skills/:name', (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
try {
const userId = getUserId(req);
const scopeHint = req.query.scope as string | undefined;
// Find the entry matching name (and optional scope filter)
const entries = skillCatalog.getForUser(userId);
let entry: SkillEntry | undefined;
if (scopeHint && ['system', 'user'].includes(scopeHint)) {
entry = entries.find(e => e.name === name && e.source === scopeHint);
}
if (!entry) {
entry = entries.find(e => e.name === name);
}
if (!entry) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Read content via catalog
const contentResult = skillCatalog.getSkillContent(name, userId);
const content = contentResult?.content ?? '';
// Read raw file for frontmatter
let raw = '';
try {
raw = readFileSync(entry.filePath, 'utf-8');
} catch { /* skip */ }
// File listing for directory skills
let files: string[] | undefined;
if (entry.dirPath) {
files = listDirFiles(entry.dirPath);
}
// Security scan
let findings;
if (entry.dirPath) {
findings = scanSkillDirectory(entry.dirPath);
} else {
findings = scanSkillContent(raw);
}
res.json({
name: entry.name,
description: entry.description,
triggers: entry.triggers,
source: entry.source,
hasDir: entry.dirPath !== null,
content,
files,
findings,
maxSeverity: maxSeverity(findings),
});
} catch (e) {
res.status(500).json({ error: `Failed to read skill: ${e}` });
}
});
// ── POST /api/skills ── create single-file skill ──────────────
app.post('/api/skills', async (req: Request, res: Response) => {
try {
const { name, content, scope } = req.body ?? {};
// Validate name
if (!name || typeof name !== 'string' || !VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name (lowercase alphanumeric, hyphens, underscores)' });
return;
}
// Validate scope
if (!scope || !['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// Validate content
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can create system skills' });
return;
}
const userId = getUserId(req);
// Determine destination directory
const destDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Check for existing skill (directory or flat file)
const destDirPath = join(destDir, name);
const destFlatPath = join(destDir, `${name}.md`);
if (existsSync(destDirPath) || existsSync(destFlatPath)) {
res.status(409).json({ error: 'Skill already exists' });
return;
}
// Scan content before writing
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Always create directory format: {name}/SKILL.md
const tmpDir = join(destDir, `.tmp-${randomBytes(8).toString('hex')}`);
mkdirSync(tmpDir, { recursive: true });
writeFileSync(join(tmpDir, 'SKILL.md'), content, 'utf-8');
renameSync(tmpDir, destDirPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.create', actor, { name, scope, severity });
}
logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.status(201).json({ name, scope, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to create skill: ${e}` });
}
});
// ── PUT /api/skills/:name ── edit skill content ───────────────
app.put('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can edit system skills' });
return;
}
try {
const { content } = req.body ?? {};
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill file: either flat file or directory with SKILL.md
let targetPath: string | null = null;
const flatPath = join(baseDir, `${name}.md`);
const dirSkillPath = join(baseDir, name, 'SKILL.md');
if (existsSync(dirSkillPath)) {
targetPath = dirSkillPath;
} else if (existsSync(flatPath)) {
targetPath = flatPath;
}
if (!targetPath) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Scan new content
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Atomic write: tmpfile in same directory as target → rename
const targetDir = targetPath === dirSkillPath ? join(baseDir, name) : baseDir;
const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`);
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, targetPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.update', actor, { name, scope, severity });
}
logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.json({ ok: true, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to update skill: ${e}` });
}
});
// ── DELETE /api/skills/:name ── delete skill ──────────────────
app.delete('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can delete system skills' });
return;
}
try {
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill: directory or flat file
const dirPath = join(baseDir, name);
const flatPath = join(baseDir, `${name}.md`);
let deleted = false;
if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) {
rmSync(dirPath, { recursive: true, force: true });
deleted = true;
} else if (existsSync(flatPath) && lstatSync(flatPath).isFile()) {
unlinkSync(flatPath);
deleted = true;
}
if (!deleted) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.delete', actor, { name, scope });
}
logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete skill: ${e}` });
}
});
}