maestro/src/bridge/pieces-api.ts
2026-06-04 03:03:12 +00:00

493 lines
22 KiB
TypeScript

import { type Application, type Request, type Response } from 'express';
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { parse, stringify } from 'yaml';
import { patchYaml } from './yaml-patch.js';
import { detectDrift, type DriftStatus } from '../engine/reflection/drift-detect.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { logger } from '../logger.js';
export type PieceSource = 'builtin' | 'global-custom' | 'user-custom';
interface PieceSummary {
name: string;
description: string;
triggers?: { keywords: string[] };
drift?: DriftStatus;
requiredMcp?: string[];
/** Backward-compat: true for any non-builtin (global-custom OR user-custom). */
custom: boolean;
source: PieceSource;
/** Set only when source === 'user-custom'. */
ownerId?: string;
}
function loadPieceFile(filePath: string): any {
const raw = readFileSync(filePath, 'utf-8');
return parse(raw);
}
function listPieceFiles(piecesDir: string): string[] {
return readdirSync(piecesDir)
.filter(f => f.endsWith('.yaml'))
.map(f => join(piecesDir, f));
}
// Phase 4 (SSH): movements using these tools must declare allowed_ssh_connections.
// Kept inline (not imported from engine/) so this API module stays decoupled
// from SSH internals — pieces can be validated even when SSH is disabled.
const SSH_TOOL_NAMES = new Set(['SshExec', 'SshUpload', 'SshDownload']);
const ALLOWED_SSH_ID = /^[a-f0-9-]{8,}$/;
function validatePiece(piece: any): string | null {
if (!piece.name || !/^[a-z0-9-]+$/.test(piece.name)) return 'name must be lowercase alphanumeric with hyphens';
if (!Array.isArray(piece.movements) || piece.movements.length === 0) return 'movements must be non-empty array';
// Required so `while (steps < piece.max_movements)` actually iterates;
// otherwise the run aborts with "Exceeded max movements (undefined)".
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
return 'max_movements is required (positive integer)';
}
const names = new Set(piece.movements.map((m: any) => m.name));
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
// Terminal moves (COMPLETE/ABORT/ASK) go through the `complete` tool now.
// default_next is engine-internal (context overflow / ASK limit / SpawnSubTask
// unavailable fallback) and still accepts COMPLETE/ABORT/ASK.
const validRuleNexts = new Set([...names, 'WAIT_SUBTASKS']);
const validDefaultNexts = new Set([...names, 'COMPLETE', 'ABORT', 'ASK', 'WAIT_SUBTASKS']);
for (const m of piece.movements) {
if (m.default_next && !validDefaultNexts.has(m.default_next)) {
return `movement "${m.name}": default_next "${m.default_next}" is invalid`;
}
if (Array.isArray(m.rules)) {
for (const r of m.rules) {
if (!validRuleNexts.has(r.next)) {
if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') {
return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`;
}
return `movement "${m.name}": rule next "${r.next}" is invalid`;
}
}
}
// Phase 4: allowed_ssh_connections consistency + format
const list = m.allowed_ssh_connections;
const tools = Array.isArray(m.allowed_tools) ? m.allowed_tools : [];
const hasSshTool = tools.some((t: unknown) => typeof t === 'string' && SSH_TOOL_NAMES.has(t));
if (list === undefined) {
if (hasSshTool) {
return `movement "${m.name}": allowed_ssh_connections is required when allowed_tools contains SSH tool(s)`;
}
} else if (!Array.isArray(list)) {
return `movement "${m.name}": allowed_ssh_connections must be an array`;
} else {
for (let i = 0; i < list.length; i++) {
const entry = list[i];
if (typeof entry !== 'string') {
return `movement "${m.name}": allowed_ssh_connections[${i}] must be a string`;
}
if (entry !== '*' && !ALLOWED_SSH_ID.test(entry)) {
return `movement "${m.name}": allowed_ssh_connections[${i}]="${entry}" must be '*' or a lowercase hex/hyphen id (8+ chars)`;
}
}
}
}
return null;
}
const VALID_PIECE_NAME = /^[a-z0-9-]+$/;
function validateName(name: string): boolean {
return VALID_PIECE_NAME.test(name);
}
const VALID_SOURCES = new Set<string>(['builtin', 'user-custom', 'global-custom']);
/**
* Returns true when `source` is absent (caller wants priority resolution).
* Returns false when `source` is a known valid value.
* Returns a 400 error string when `source` is present but unrecognised.
*/
function parseSourceParam(source: string | undefined): { valid: true; value: PieceSource | undefined } | { valid: false; error: string } {
if (source === undefined) return { valid: true, value: undefined };
if (VALID_SOURCES.has(source)) return { valid: true, value: source as PieceSource };
return { valid: false, error: 'Invalid source' };
}
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
if (customPiecesDir) {
const customPath = join(customPiecesDir, `${name}.yaml`);
if (existsSync(customPath)) return { path: customPath, custom: true };
}
const builtinPath = join(piecesDir, `${name}.yaml`);
if (existsSync(builtinPath)) return { path: builtinPath, custom: false };
return null;
}
export interface PiecesApiOptions {
piecesDir: string;
/** Optional admin-managed shared custom dir (global to all users). */
customPiecesDir?: string;
/**
* Root of per-user data (typically `./data/users`). When set, each authenticated
* user can read/create/update/delete pieces under `{userPiecesRootDir}/{userId}/pieces/`.
* When unset, per-user piece support is disabled and non-admin POST returns 503.
*/
userPiecesRootDir?: string;
}
/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
const LOCAL_OWNER = 'local';
type AuthedUser = { id: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
// No req.user → legacy (auth disabled or test/internal). Treat as admin so
// existing callers without auth middleware continue to work.
return !user || user.role === 'admin';
}
/**
* Lookup priority for a given caller:
* 1. Caller's own user-custom dir (overrides everything below).
* No-auth callers use the 'local' owner id.
* 2. Global custom dir (admin-managed, all users see).
* 3. Built-in dir.
*/
function findPieceForCaller(
opts: PiecesApiOptions,
user: AuthedUser | undefined,
name: string,
): { path: string; source: PieceSource; ownerId?: string } | null {
if (opts.userPiecesRootDir) {
const ownerId = user?.id ?? LOCAL_OWNER;
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`);
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
}
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
if (existsSync(gcPath)) return { path: gcPath, source: 'global-custom' };
}
const biPath = join(opts.piecesDir, `${name}.yaml`);
if (existsSync(biPath)) return { path: biPath, source: 'builtin' };
return null;
}
/**
* Mount the pieces REST API. Read endpoints (GET) require only authentication
* (any logged-in user can list/read pieces visible to them). Write endpoints
* (POST/PUT/DELETE) enforce per-piece authorization:
* - built-in / global-custom: admin only
* - user-custom: owner or admin
*/
export function mountPiecesApi(
app: Application,
optsOrPiecesDir: PiecesApiOptions | string,
legacyCustomPiecesDir?: string,
): void {
// Backwards-compatible signature: mountPiecesApi(app, piecesDir, customPiecesDir?)
const opts: PiecesApiOptions = typeof optsOrPiecesDir === 'string'
? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir }
: optsOrPiecesDir;
app.get('/api/pieces', (req: Request, res: Response) => {
try {
const user = getUser(req);
const pieces: PieceSummary[] = [];
// Build custom sources (user-custom first, then global-custom).
// Within custom umbrella: dedup by name so user-custom wins over global-custom.
// Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
if (opts.userPiecesRootDir) {
// No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults.
const ownerId = user?.id ?? LOCAL_OWNER;
const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
}
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
}
// Emit custom pieces (dedup within custom umbrella only).
const seenCustom = new Set<string>();
for (const { dir, source, ownerId } of customSources) {
for (const f of listPieceFiles(dir)) {
try {
const p = loadPieceFile(f);
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
if (seenCustom.has(name)) continue;
seenCustom.add(name);
let drift: DriftStatus | undefined;
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
drift = detectDrift(f, builtinPath);
}
pieces.push({
name,
description: p.description,
triggers: p.triggers,
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
custom: true,
source,
ownerId,
drift,
});
} catch {
// skip malformed piece files
}
}
}
// Always emit ALL built-ins (never hidden by custom pieces of the same name).
if (existsSync(opts.piecesDir)) {
for (const f of listPieceFiles(opts.piecesDir)) {
try {
const p = loadPieceFile(f);
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
pieces.push({
name,
description: p.description,
triggers: p.triggers,
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
custom: false,
source: 'builtin',
});
} catch {
// skip malformed piece files
}
}
}
res.json({ pieces });
} catch (e) {
res.status(500).json({ error: `Failed to list pieces: ${e}` });
}
});
app.get('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value;
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
if (requestedSource === 'builtin') {
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) {
const ownerId = user?.id ?? LOCAL_OWNER;
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
}
} else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
}
} else {
// No source param: priority resolution (user-custom > global-custom > builtin).
found = findPieceForCaller(opts, user, req.params.name);
}
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
const piece = loadPieceFile(found.path);
res.json({
piece: {
...piece,
requiredMcp: Array.isArray(piece.required_mcp) ? piece.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
},
custom: found.source !== 'builtin',
source: found.source,
ownerId: found.ownerId,
});
} catch (e) {
res.status(500).json({ error: `Failed to read piece: ${e}` });
}
});
app.put('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value;
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
if (requestedSource === 'builtin') {
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) {
const ownerId = user?.id ?? LOCAL_OWNER;
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
}
} else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
}
} else {
found = findPieceForCaller(opts, user, req.params.name);
}
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
if (found.source !== 'user-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
return;
}
} else if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
// Different user's user-custom — and not admin. Should be unreachable since
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
return;
}
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
if (req.body.name !== req.params.name) {
res.status(400).json({ ok: false, error: 'Body name must match URL parameter' }); return;
}
// Use parseDocument + setIn so untouched regions keep their original
// formatting (block styles, inline arrays, blank lines, comments).
// Full re-serialization via stringify would e.g. convert `instruction: |`
// to `instruction: >`, changing runtime prompt behavior. See #151.
const originalText = readFileSync(found.path, 'utf-8');
const patched = patchYaml(originalText, req.body);
writeFileSync(found.path, patched, 'utf-8');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to update piece: ${e}` });
}
});
app.post('/api/pieces', (req: Request, res: Response) => {
try {
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
const user = getUser(req);
// POST always creates in user-custom dir. "+" is always Create Custom.
// Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
// No-auth / legacy with userPiecesRootDir: use the 'local' owner id so pieces are
// user-custom (deletable) and never pollute piecesDir.
// No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
let destDir: string;
let createdSource: PieceSource;
if (opts.userPiecesRootDir) {
// Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set.
// Authenticated users: 503 guard below is only needed when root is NOT set.
const ownerId = user?.id ?? LOCAL_OWNER;
destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
mkdirSync(destDir, { recursive: true });
createdSource = 'user-custom';
} else if (user) {
// Authenticated caller but userPiecesRootDir is not configured — cannot safely write.
res.status(503).json({ ok: false, error: 'User piece storage not configured' });
return;
} else if (opts.customPiecesDir) {
// Legacy (no-auth) with global custom dir configured.
destDir = opts.customPiecesDir;
mkdirSync(destDir, { recursive: true });
createdSource = 'global-custom';
} else {
// Pure legacy single-user: fall back to piecesDir (existing behavior).
destDir = opts.piecesDir;
createdSource = 'builtin';
}
// Always reject if the name collides with a built-in — Custom and Default are
// separate namespaces. (This also covers the admin case, since admins should
// use PUT to update an existing built-in, not POST to create a duplicate.)
const builtinPath = join(opts.piecesDir, `${req.body.name}.yaml`);
if (existsSync(builtinPath) && destDir !== opts.piecesDir) {
res.status(409).json({ ok: false, error: `"${req.body.name}" は組み込み (built-in) Piece と同名です。Custom Piece には別名を付けてください。` });
return;
}
// Reject if the caller's visible piece with this name already exists
// (global-custom or caller's own user-custom).
if (findPieceForCaller(opts, user, req.body.name)) {
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
}
const filePath = join(destDir, `${req.body.name}.yaml`);
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} source=${createdSource} actor=${user?.id ?? 'legacy'}`);
res.status(201).json({ ok: true, source: createdSource });
} catch (e) {
res.status(500).json({ error: `Failed to create piece: ${e}` });
}
});
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value;
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
if (requestedSource === 'builtin') {
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) {
const ownerId = user?.id ?? LOCAL_OWNER;
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
}
} else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
}
} else {
found = findPieceForCaller(opts, user, req.params.name);
}
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Built-in (Default) pieces are non-deletable for everyone — including admins.
// This covers general/chat and all other built-ins. Admins may still EDIT
// built-ins via PUT; only deletion is prohibited.
if (found.source === 'builtin') {
res.status(403).json({ ok: false, error: 'Cannot delete a built-in (Default) piece' }); return;
}
// Authz for non-builtin sources: global-custom → admin; user-custom → owner.
if (found.source === 'global-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can delete global-custom pieces' });
return;
}
} else if (found.source === 'user-custom') {
if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
return;
}
}
unlinkSync(found.path);
logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete piece: ${e}` });
}
});
}