feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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).
|
||||
* 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 && user) {
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
|
||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
|
||||
}
|
||||
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 seen = new Set<string>();
|
||||
const pieces: PieceSummary[] = [];
|
||||
|
||||
// Order matters: user-custom overrides global-custom, which overrides built-in.
|
||||
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||
if (opts.userPiecesRootDir && user) {
|
||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
|
||||
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
|
||||
}
|
||||
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
|
||||
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||
}
|
||||
if (existsSync(opts.piecesDir)) {
|
||||
sources.push({ dir: opts.piecesDir, source: 'builtin' });
|
||||
}
|
||||
|
||||
for (const { dir, source, ownerId } of sources) {
|
||||
for (const f of listPieceFiles(dir)) {
|
||||
try {
|
||||
const p = loadPieceFile(f);
|
||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
// Drift is meaningful only for global-custom that shadows a built-in.
|
||||
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: source !== 'builtin',
|
||||
source,
|
||||
ownerId,
|
||||
drift,
|
||||
});
|
||||
} 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 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 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 && !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);
|
||||
const adminOrLegacy = isAdminOrLegacy(user);
|
||||
|
||||
// Determine destination dir:
|
||||
// - admin / legacy → preserve existing behavior (write to piecesDir).
|
||||
// - non-admin user → write to their user-custom dir.
|
||||
let destDir: string;
|
||||
if (adminOrLegacy) {
|
||||
destDir = opts.piecesDir;
|
||||
} else {
|
||||
if (!opts.userPiecesRootDir) {
|
||||
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
|
||||
return;
|
||||
}
|
||||
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Reject if any visible-to-caller piece with this name already exists
|
||||
// (built-in, global-custom, or caller's 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} actor=${user?.id ?? 'legacy'}`);
|
||||
res.status(201).json({ ok: true });
|
||||
} 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; }
|
||||
if (req.params.name === 'general' || req.params.name === 'chat') {
|
||||
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
|
||||
}
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
|
||||
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
|
||||
if (found.source !== 'user-custom') {
|
||||
if (!isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
|
||||
return;
|
||||
}
|
||||
} else if (found.ownerId !== user?.id && !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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user