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(['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(); 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}` }); } }); }