UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

1,062 lines (1,061 loc) 86.9 kB
/** * Serve Command Handler * * Starts a local HTTP + WebSocket server and opens the browser dashboard. * Server stack: Hono serving static files, WebSocket PTY bridge, REST API. * * @issue #711 * @see #712 — WebSocket PTY bridge * @see #714 — React app scaffold */ import path from 'path'; import { existsSync, readFileSync } from 'fs'; import { spawnSync } from 'child_process'; import { createPtyWsHandler, registry as ptyRegistry } from '../../serve/pty-bridge.js'; import { telemetryStore, createEvent } from '../../serve/telemetry.js'; import { sandboxRegistry, normalizeSandboxEvent, } from '../../serve/sandbox-registry.js'; import { routeTask } from '../../serve/agent-router.js'; import { routeDispatch } from '../../serve/dispatch-router.js'; import { observeA2ATerminalState } from '../../serve/a2a-terminal-observer.js'; import { executorRegistry, validateRegisterPayload, validateDispatchPayload, validateEventEnvelope, } from '../../serve/executor-registry.js'; import { handleWebhook, IdempotencyCache, PushSecretRegistry, } from '../../a2a/webhook.js'; import { AiwgError, EXIT_CODES } from '../errors.js'; // A2A push-notification state — module-scoped so the test harness can // monkey-patch them in if needed. One process serves one set of secrets. const pushSecretRegistry = new PushSecretRegistry(); const webhookIdempotency = new IdempotencyCache(); const DEFAULT_PORT = 7337; const DEFAULT_HOST = '127.0.0.1'; /** * Parse --port, --bind, --no-open, --read-only flags from args */ function parseServeArgs(args) { let port = DEFAULT_PORT; let host = DEFAULT_HOST; let open = true; let readOnly = false; let sandbox = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--port' && args[i + 1]) { const parsed = parseInt(args[i + 1], 10); if (!isNaN(parsed)) port = parsed; i++; } else if (arg === '--bind' && args[i + 1]) { host = args[i + 1]; i++; } else if (arg === '--sandbox' && args[i + 1]) { sandbox = args[i + 1]; i++; } else if (arg === '--no-open') { open = false; } else if (arg === '--read-only') { readOnly = true; } } return { port, host, open, readOnly, sandbox }; } // ============================================================ // WebSocket routing (#851) // // @hono/node-server v1.x does not export createNodeWebSocket. // We wire WebSocket routes directly via the Node.js HTTP server's // 'upgrade' event and the `ws` npm package instead. // ============================================================ /** * Verbose logging gate. Set `AIWG_SERVE_DEBUG=1` to print per-event * traces (sandbox WS message flow, management/orchestrate proxy open/close). * Errors and warnings log unconditionally. */ const SERVE_DEBUG = process.env.AIWG_SERVE_DEBUG === '1'; function logServeWarn(tag, message, meta) { if (meta !== undefined) { console.warn(`[serve:${tag}] ${message}`, meta); } else { console.warn(`[serve:${tag}] ${message}`); } } function logServeDebug(tag, message, meta) { if (!SERVE_DEBUG) return; if (meta !== undefined) { console.log(`[serve:${tag}] ${message}`, meta); } else { console.log(`[serve:${tag}] ${message}`); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleSandboxWs(ws, sandboxId, token) { if (!sandboxRegistry.authenticate(sandboxId, token)) { ws.close(4001, 'Unauthorized'); return; } sandboxRegistry.setConnected(sandboxId, true); ws.on('message', (data) => { if (!sandboxRegistry.authenticate(sandboxId, token)) return; try { // #933: agentic-sandbox serializes SandboxEvent with // rename_all = "snake_case", so the wire tag and field names are // snake_case. normalizeSandboxEvent translates to the dot-notation // + camelCase shape handleEvent expects. Prior to this, every // agent_*/hitl_* event was silently dropped and the dashboard // reported "0 agents". const raw = JSON.parse(data.toString()); const event = normalizeSandboxEvent(raw); event.sandboxId = sandboxId; sandboxRegistry.handleEvent(event); } catch { /* ignore malformed events */ } }); ws.on('close', () => { sandboxRegistry.setConnected(sandboxId, false); }); ws.on('error', (err) => { console.error(`[sandbox-registry] WebSocket error for ${sandboxId}:`, err); }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleExecutorWs(ws, executorId, token) { if (!executorRegistry.authenticate(executorId, token)) { ws.close(4001, 'Unauthorized'); return; } // Duck-typed WS connection handle for registry.pushToExecutor() executorRegistry.setConnected(executorId, true, ws); ws.on('message', (data) => { if (!executorRegistry.authenticate(executorId, token)) return; try { const raw = JSON.parse(data.toString()); const { valid } = validateEventEnvelope(raw); if (!valid) { logServeWarn('executor-ws', `invalid event envelope from executor ${executorId} — ignored`); return; } const envelope = raw; // Ensure executor_id matches the authenticated connection envelope.executor_id = executorId; executorRegistry.handleEvent(envelope); } catch { /* ignore malformed events */ } }); ws.on('close', () => { executorRegistry.setConnected(executorId, false); logServeDebug('executor-ws', `executor ${executorId} WS disconnected`); }); ws.on('error', (err) => { console.error(`[executor-registry] WebSocket error for ${executorId}:`, err); }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handlePtyWs(ws, sessionId, command, cmdArgs, cwd, wsEndpoint, agentId) { // createPtyWsHandler expects a Hono-context-like object for param/query extraction. // We provide a minimal shim since we've already parsed the URL. const mockContext = { req: { param: (key) => key === 'sessionId' ? sessionId : undefined, query: () => ({ command, args: cmdArgs.join(','), ...(cwd ? { cwd } : {}), ...(wsEndpoint ? { wsEndpoint } : {}), ...(agentId ? { agentId } : {}), }), }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const handler = createPtyWsHandler(mockContext); handler.onOpen?.(null, ws); ws.on('message', (data) => { handler.onMessage?.({ data: data.toString() }); }); ws.on('close', () => { handler.onClose?.(); }); ws.on('error', (err) => { handler.onError?.(err); }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function setupWebSockets(httpServer, readOnly) { // eslint-disable-next-line @typescript-eslint/no-explicit-any let wsMod; try { // @ts-expect-error — ws lacks bundled types; we use the runtime constructor only wsMod = await import('ws'); } catch { console.warn('[serve] ws package not available — WebSocket routes disabled. Install with: npm install ws'); return; } // ws ships as CJS; ESM import may wrap in .default // eslint-disable-next-line @typescript-eslint/no-explicit-any const WebSocketServer = wsMod.WebSocketServer ?? wsMod.default?.WebSocketServer ?? wsMod.Server ?? wsMod.default?.Server; if (!WebSocketServer) { console.warn('[serve] Could not resolve WebSocketServer from ws package — WebSocket routes disabled.'); return; } const wss = new WebSocketServer({ noServer: true }); // eslint-disable-next-line @typescript-eslint/no-explicit-any httpServer.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const pathname = url.pathname; // /ws/sandbox/:sandboxId const sandboxMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)$/); if (sandboxMatch) { const sandboxId = sandboxMatch[1]; const token = url.searchParams.get('token') ?? ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any wss.handleUpgrade(req, socket, head, (ws) => { handleSandboxWs(ws, sandboxId, token); }); return; } // /ws/executors/:executorId — executor contract v1 bidirectional event stream (#1179) const executorMatch = pathname.match(/^\/ws\/executors\/([^/]+)$/); if (executorMatch) { const executorId = executorMatch[1]; const token = url.searchParams.get('token') ?? ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any wss.handleUpgrade(req, socket, head, (ws) => { handleExecutorWs(ws, executorId, token); }); return; } // /ws/pty/:sessionId (disabled in read-only mode) // Optional query params: // ?sandbox=<sandboxId> — target a specific registered sandbox // ?agent=<agentId> — target a specific agent within that sandbox // ?wsEndpoint=<url> — explicit management WS URL (overrides sandbox lookup) // Without these params the PTY bridge auto-detects the first connected sandbox. if (!readOnly) { const ptyMatch = pathname.match(/^\/ws\/pty\/([^/]+)$/); if (ptyMatch) { const sessionId = ptyMatch[1]; const command = url.searchParams.get('command') ?? 'aiwg'; const argsParam = url.searchParams.get('args'); const cmdArgs = argsParam ? argsParam.split(',') : ['mc', 'watch']; const cwd = url.searchParams.get('cwd') ?? undefined; const agentId = url.searchParams.get('agent') ?? undefined; // Resolve wsEndpoint: explicit param takes precedence over sandbox registry lookup let wsEndpoint = url.searchParams.get('wsEndpoint') ?? undefined; if (!wsEndpoint) { const sandboxId = url.searchParams.get('sandbox') ?? undefined; if (sandboxId) { const sb = sandboxRegistry.get(sandboxId); if (sb?.wsEndpoint) wsEndpoint = sb.wsEndpoint; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any wss.handleUpgrade(req, socket, head, (ws) => { handlePtyWs(ws, sessionId, command, cmdArgs, cwd, wsEndpoint, agentId); }); return; } } // /ws/sandbox/:sandboxId/sessions/:sessionId/orchestrate // Proxies to the sandbox management server's orchestrate WS endpoint. // Browser speaks the orchestrate protocol (screen_update frames) directly; // this bridge is purely a cross-origin WS relay. const orchMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)\/sessions\/([^/]+)\/orchestrate$/); if (orchMatch) { const sandboxId = orchMatch[1]; const sessionId = orchMatch[2]; const sandbox = sandboxRegistry.get(sandboxId); if (!sandbox) { logServeWarn('orch-proxy', `sandbox ${sandboxId} not found — rejecting orchestrate WS upgrade for session ${sessionId}`); socket.destroy(); return; } if (!sandbox.connected) { logServeWarn('orch-proxy', `sandbox ${sandboxId} not connected — rejecting orchestrate WS upgrade for session ${sessionId}`); socket.destroy(); return; } // Convert httpEndpoint to ws:// URL for the sandbox orchestrate path const orchWsUrl = sandbox.httpEndpoint.replace(/^http/, 'ws') + `/ws/sessions/${sessionId}/orchestrate`; logServeDebug('orch-proxy', `upgrading browser → ${orchWsUrl} for session ${sessionId}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any wss.handleUpgrade(req, socket, head, async (browserWs) => { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const WS = wsMod.WebSocket ?? wsMod.default?.WebSocket ?? wsMod.default; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sandboxWs = new WS(orchWsUrl); sandboxWs.on('open', () => { logServeDebug('orch-proxy', `upstream orchestrate WS open for session ${sessionId}`); // Relay sandbox → browser sandboxWs.on('message', (data) => { if (browserWs.readyState === 1) { try { browserWs.send(typeof data === 'string' ? data : data.toString()); } catch (err) { logServeWarn('orch-proxy', `relay sandbox→browser failed for ${sessionId}`, err); } } }); sandboxWs.on('close', (code, reason) => { logServeDebug('orch-proxy', `upstream orchestrate WS closed for ${sessionId}: code=${code} reason=${reason?.toString?.() ?? ''}`); try { browserWs.close(1001, 'Sandbox closed'); } catch { /* already closed */ } }); sandboxWs.on('error', (err) => { logServeWarn('orch-proxy', `upstream orchestrate WS error for ${sessionId}`, err); try { browserWs.close(1011, 'Sandbox WS error'); } catch { /* already closed */ } }); // Relay browser → sandbox browserWs.on('message', (data) => { if (sandboxWs.readyState === 1) { try { sandboxWs.send(typeof data === 'string' ? data : data.toString()); } catch (err) { logServeWarn('orch-proxy', `relay browser→sandbox failed for ${sessionId}`, err); } } }); browserWs.on('close', () => { try { sandboxWs.close(); } catch { /* ignore */ } }); }); sandboxWs.on('error', (err) => { logServeWarn('orch-proxy', `failed to connect to sandbox orchestrate WS at ${orchWsUrl}`, err); try { browserWs.close(1011, 'Could not connect to sandbox orchestrate WS'); } catch { /* already closed */ } }); } catch (err) { logServeWarn('orch-proxy', `orchestrate proxy threw for session ${sessionId}`, err); try { browserWs.close(1011, 'Orchestrate proxy error'); } catch { /* already closed */ } } }); return; } // /ws/sandbox/:sandboxId/management // Proxies to the sandbox management WS bus (wsEndpoint). // The management WS is a multicast bus — all agents and sessions share one connection. // The browser sends attach_session / send_input / pty_resize frames and receives // output / session_attached / session_detached frames for all agents. const mgmtMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)\/management$/); if (mgmtMatch) { const sandboxId = mgmtMatch[1]; const sandbox = sandboxRegistry.get(sandboxId); if (!sandbox) { logServeWarn('mgmt-proxy', `sandbox ${sandboxId} not found in registry — rejecting WS upgrade`); socket.destroy(); return; } if (!sandbox.connected) { logServeWarn('mgmt-proxy', `sandbox ${sandboxId} (${sandbox.name}) is not connected — rejecting WS upgrade. Last event: ${sandbox.lastEventAt}`); socket.destroy(); return; } // wsEndpoint is already a full ws:// URL (e.g. ws://localhost:8121) const mgmtWsUrl = sandbox.wsEndpoint; logServeDebug('mgmt-proxy', `upgrading browser → ${mgmtWsUrl} for sandbox ${sandboxId} (${sandbox.name})`); // eslint-disable-next-line @typescript-eslint/no-explicit-any wss.handleUpgrade(req, socket, head, async (browserWs) => { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const WS = wsMod.WebSocket ?? wsMod.default?.WebSocket ?? wsMod.default; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sandboxWs = new WS(mgmtWsUrl); // Open-race fix (#1151 follow-up): the browser fires its first // messages (subscribe + list_sessions) inside its own ws.onopen // handler, which fires the moment the upgrade completes — // BEFORE the upstream sandbox WS finishes opening. If we register // the browser→sandbox listener inside sandboxWs.on('open', …) the // browser's first messages have no listener yet and get silently // dropped, leaving the pane forever stuck on "Listing sessions…". // Two-pane setups hit this asymmetrically — pure timing decides // which pane "wins" the race. Workaround: register the browser // listener immediately, queue messages while upstream is still // connecting, flush on upstream open. const pendingFromBrowser = []; let upstreamOpen = false; browserWs.on('message', (data) => { if (upstreamOpen && sandboxWs.readyState === 1) { try { sandboxWs.send(typeof data === 'string' ? data : data.toString()); } catch (err) { logServeWarn('mgmt-proxy', `relay browser→sandbox failed for ${sandboxId}`, err); } } else { // Upstream not ready yet — queue and flush on open. Cap the // queue at a reasonable size so a hung upstream doesn't grow // memory unbounded. if (pendingFromBrowser.length < 64) { pendingFromBrowser.push(data); } else { logServeWarn('mgmt-proxy', `dropped browser message for ${sandboxId} — upstream still connecting and queue full`); } } }); browserWs.on('close', () => { try { sandboxWs.close(); } catch { /* ignore */ } }); sandboxWs.on('open', () => { logServeDebug('mgmt-proxy', `upstream WS open for sandbox ${sandboxId} (flushing ${pendingFromBrowser.length} queued msg)`); upstreamOpen = true; // Flush queued browser→sandbox messages in order. while (pendingFromBrowser.length) { const data = pendingFromBrowser.shift(); try { sandboxWs.send(typeof data === 'string' ? data : data.toString()); } catch (err) { logServeWarn('mgmt-proxy', `flush browser→sandbox failed for ${sandboxId}`, err); } } // Relay sandbox → browser sandboxWs.on('message', (data) => { if (browserWs.readyState === 1) { try { browserWs.send(typeof data === 'string' ? data : data.toString()); } catch (err) { logServeWarn('mgmt-proxy', `relay sandbox→browser failed for ${sandboxId}`, err); } } }); sandboxWs.on('close', (code, reason) => { logServeDebug('mgmt-proxy', `upstream WS closed for sandbox ${sandboxId}: code=${code} reason=${reason?.toString?.() ?? ''}`); try { browserWs.close(1001, 'Sandbox closed'); } catch { /* already closed */ } }); sandboxWs.on('error', (err) => { logServeWarn('mgmt-proxy', `upstream WS error for sandbox ${sandboxId} (${mgmtWsUrl})`, err); try { browserWs.close(1011, 'Sandbox WS error'); } catch { /* already closed */ } }); }); sandboxWs.on('error', (err) => { logServeWarn('mgmt-proxy', `failed to connect to sandbox management WS at ${mgmtWsUrl} for ${sandboxId}`, err); try { browserWs.close(1011, 'Could not connect to sandbox management WS'); } catch { /* already closed */ } }); } catch (err) { logServeWarn('mgmt-proxy', `management proxy threw for sandbox ${sandboxId}`, err); try { browserWs.close(1011, 'Management proxy error'); } catch { /* already closed */ } } }); return; } // Unknown WS path — reject cleanly socket.destroy(); }); } /** * Start the Hono HTTP server * * Uses dynamic require-style imports to avoid compile-time resolution of * optional deps (hono, @hono/node-server) that are not yet in package.json. * TypeScript sees only `unknown`-typed module shapes here. */ /** * In-process server bootstrap. Exported for tier-3 integration tests * (test/integration/serve-*.test.ts) so they can drive serve as a black-box * client without spawning a child node process. The CLI handler also calls * this internally — same code path, exactly one entry point. * * @see #1277 — moves the integration suite off spawn-based testing */ export async function startServer(opts) { // eslint-disable-next-line @typescript-eslint/no-explicit-any let honoMod; // eslint-disable-next-line @typescript-eslint/no-explicit-any let nodeMod; try { // Plain dynamic import — runtime-resolved, no static analysis issue, and // works in sandboxed VM contexts like vitest where the Function-constructor // import path raises ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING (#1277). // hono is an optionalDependency; tsc may not find its types under // `npm ci --omit=optional` (e.g. metadata-validation workflow). The // try/catch + auto-install fallback below handles that at runtime. // @ts-ignore — optional dep; may not be installed at typecheck time honoMod = await import('hono'); // @ts-ignore — optional dep; may not be installed at typecheck time nodeMod = await import('@hono/node-server'); } catch { // Auto-install optional serve dependencies on first use console.log('Installing serve dependencies (hono, @hono/node-server, ws)...'); const result = spawnSync('npm', ['install', '--save-optional', 'hono', '@hono/node-server', 'ws'], { stdio: 'inherit' }); if (result.status !== 0) { throw new AiwgError({ code: 'ERR_SERVE_DEPS_INSTALL_FAILED', message: 'Failed to install serve dependencies (hono, @hono/node-server, ws)', hint: 'Install manually: npm install hono @hono/node-server ws', exitCode: EXIT_CODES.GENERAL, }); } // Retry imports after install try { // @ts-ignore — optional dep; may not be installed at typecheck time honoMod = await import('hono'); // @ts-ignore — optional dep; may not be installed at typecheck time nodeMod = await import('@hono/node-server'); } catch (err) { throw new AiwgError({ code: 'ERR_SERVE_DEPS_LOAD_FAILED', message: 'Serve dependencies installed but could not be loaded', hint: 'Try: npm install hono @hono/node-server ws', exitCode: EXIT_CODES.GENERAL, cause: err, }); } } const { Hono } = honoMod; const { serve } = nodeMod; // eslint-disable-next-line @typescript-eslint/no-explicit-any const app = new Hono(); // WebSocket routes are handled via Node.js upgrade event below (see setupWebSockets). // @hono/node-server v1.x does not export createNodeWebSocket. // Health check app.get('/api/health', (c) => c.json({ status: 'ok', readOnly: opts.readOnly })); // Connection status — server health, PTY sessions, sandboxes, subsystem status (#887) const serverStartTime = Date.now(); const COUNTS_TTL_MS = 5000; const countsCache = new Map(); const FETCH_TIMEOUT_MS = 1500; async function fetchWithTimeout(url) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const resp = await fetch(url, { signal: ctrl.signal }); if (!resp.ok) return null; return await resp.json(); } finally { clearTimeout(timer); } } async function getInventoryCounts(s) { if (!s.connected) return { vmCount: null, containerCount: null }; const cached = countsCache.get(s.id); if (cached && Date.now() - cached.at < COUNTS_TTL_MS) return cached.counts; let vmCount = null; let containerCount = null; try { const [vms, containers] = await Promise.all([ fetchWithTimeout(`${s.httpEndpoint}/api/v1/vms`).catch(() => null), fetchWithTimeout(`${s.httpEndpoint}/api/v1/containers`).catch(() => null), ]); if (vms && typeof vms.total === 'number') { vmCount = vms.total; } if (containers && typeof containers.total === 'number') { containerCount = containers.total; } } catch { /* ignore */ } const counts = { vmCount, containerCount }; countsCache.set(s.id, { at: Date.now(), counts }); return counts; } app.get('/api/connections', async (c) => { const uptime = Date.now() - serverStartTime; // PTY sessions // eslint-disable-next-line @typescript-eslint/no-explicit-any const sessions = [...ptyRegistry['sessions'].keys()]; // Sandboxes — augmented with VM/container counts (#1157). // null counts indicate the sandbox is offline or didn't respond; the UI // renders these as `?` rather than `0` so missing data is distinguishable // from a confirmed empty inventory. const sandboxList = sandboxRegistry.list(); const allSandboxes = await Promise.all(sandboxList.map(async (s) => { const counts = await getInventoryCounts({ id: s.id, httpEndpoint: s.httpEndpoint, connected: s.connected }); return { id: s.id, name: s.name, connected: s.connected, agentCount: s.agentCount, vmCount: counts.vmCount, containerCount: counts.containerCount, }; })); // Ralph subsystem — read .aiwg/ralph/registry.json if present let ralphStatus = 'unknown'; let activeLoops = 0; try { const ralphPath = path.join(process.cwd(), '.aiwg', 'ralph', 'registry.json'); if (existsSync(ralphPath)) { const data = JSON.parse(readFileSync(ralphPath, 'utf-8')); activeLoops = (data.active_loops ?? []).filter((l) => l.status === 'running').length; ralphStatus = activeLoops > 0 ? 'active' : 'idle'; } } catch { /* ignore */ } // Missions subsystem — check for mc session directory let missionsStatus = 'unknown'; let missionsCount = 0; try { const mcPath = path.join(process.cwd(), '.aiwg', 'mc'); if (existsSync(mcPath)) { missionsStatus = 'idle'; const registryPath = path.join(mcPath, 'registry.json'); if (existsSync(registryPath)) { const data = JSON.parse(readFileSync(registryPath, 'utf-8')); const activeSessions = (data.sessions ?? []).filter((s) => s.status === 'running'); missionsCount = activeSessions.length; if (missionsCount > 0) missionsStatus = 'active'; } } } catch { /* ignore */ } // Daemon subsystem — check for daemon PID file let daemonStatus = 'unknown'; try { const daemonPid = path.join(process.cwd(), '.aiwg', 'daemon', 'daemon.pid'); daemonStatus = existsSync(daemonPid) ? 'running' : 'stopped'; } catch { /* ignore */ } // RLM subsystem — check for rlm state let rlmStatus = 'unknown'; try { const rlmPath = path.join(process.cwd(), '.aiwg', 'rlm'); rlmStatus = existsSync(rlmPath) ? 'idle' : 'stopped'; } catch { /* ignore */ } // Semantic memory — check for memory index let memoryStatus = 'unknown'; try { const memPath = path.join(process.cwd(), '.aiwg', 'memory'); memoryStatus = existsSync(memPath) ? 'active' : 'stopped'; } catch { /* ignore */ } return c.json({ server: { status: 'ok', readOnly: opts.readOnly, uptime }, ptySessions: sessions, sandboxes: allSandboxes, mcpServers: [], subsystems: { ralph: { status: ralphStatus, activeLoops }, missions: { status: missionsStatus, count: missionsCount }, daemon: { status: daemonStatus }, rlm: { status: rlmStatus }, memory: { status: memoryStatus }, }, }); }); // REST stubs — filled in by #715 / #716 app.get('/api/sessions', (c) => { const sessions = [...ptyRegistry['sessions'].keys()]; return c.json({ sessions }); }); // Mission Control API stubs (#715) app.get('/api/missions', (c) => c.json({ missions: [], sessions: [] })); app.get('/api/sessions/:id/missions', (c) => c.json({ missions: [] })); // Legacy dispatch route — stub preserved for backward compat // TODO: remove after v1.x once all callers migrate to /api/v1/sessions/:id/dispatch app.post('/api/sessions/:id/dispatch', async (c) => { // 301 → /api/v1/sessions/:id/dispatch so existing callers get a clear redirect signal const sessionId = c.req.param('id'); return c.redirect(`/api/v1/sessions/${sessionId}/dispatch`, 301); }); app.put('/api/missions/:id/pause', (c) => c.json({ ok: true })); app.put('/api/missions/:id/resume', (c) => c.json({ ok: true })); app.delete('/api/missions/:id', (c) => c.json({ ok: true })); // ── Executor Registry API (#1179) ────────────────────────────────────────── // POST /api/v1/executors/register → 201 with executor_id + token app.post('/api/v1/executors/register', async (c) => { let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } const { valid, errors } = validateRegisterPayload(body); if (!valid) { return c.json({ error: `Invalid register payload: ${errors}` }, 400); } const result = executorRegistry.register(body); if ('status' in result && result.status === 400) { return c.json({ error: result.error }, 400); } // Emit telemetry (reuse agent.spawn as closest match for executor registration) const resp = result; telemetryStore.ingest(createEvent('agent.spawn', resp.executor_id, { name: body.name })); return c.json(resp, 201); }); // DELETE /api/v1/executors/:id → 204; auth required app.delete('/api/v1/executors/:id', (c) => { const executorId = c.req.param('id'); const authHeader = c.req.header('authorization') ?? ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; if (!executorRegistry.authenticate(executorId, token)) { return c.json({ error: 'Unauthorized' }, 401); } const ok = executorRegistry.deregister(executorId, 'operator_deleted'); if (!ok) return c.json({ error: 'Executor not found' }, 404); return new Response(null, { status: 204 }); }); // GET /api/v1/executors → list app.get('/api/v1/executors', (c) => { return c.json({ executors: executorRegistry.list() }); }); // GET /api/v1/executors/:id → single executor status app.get('/api/v1/executors/:id', (c) => { const summary = executorRegistry.get(c.req.param('id')); if (!summary) return c.json({ error: 'Executor not found' }, 404); return c.json(summary); }); // POST /api/v1/sessions/:id/dispatch → 202; replaces the #715 stub app.post('/api/v1/sessions/:id/dispatch', async (c) => { const sessionId = c.req.param('id'); let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } // 1. Validate payload against schema const { valid, errors } = validateDispatchPayload(body); if (!valid) { return c.json({ error: `Invalid dispatch payload: ${errors}` }, 400); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const payload = body; const missionId = payload.mission_id; const longRunning = payload.long_running === true; const executorFilter = payload.executor_filter ?? {}; // 2. Resolve executor via registry's pickByFilter const pickResult = executorRegistry.pickByFilter(executorFilter, longRunning); if (!pickResult) { if (longRunning) { return c.json({ error: 'no_resumable_executor_available' }, 503); } return c.json({ error: 'no_executor_available' }, 503); } const { executor } = pickResult; // 3. Forward dispatch via the dispatch router (#1252): try A2A v2 first, // fall back to v1 /dispatch on 404 with a structured warning. let estimatedStart; let a2aInstanceId; let dispatchPath = 'v2'; let a2aTask = undefined; try { const result = await routeDispatch(executor, payload, { onV1Fallback: (info) => { logServeWarn('dispatch', `v1 fallback for executor ${info.executorId}: ${info.reason}`); telemetryStore.ingest(createEvent('v1.dispatch.fallback', sessionId, info, missionId)); }, onDeprecation: (info) => { logServeWarn('dispatch', `v1 deprecation observed at ${info.path}` + (info.sunset ? ` (sunset: ${info.sunset})` : '')); telemetryStore.ingest(createEvent('v1.deprecation.observed', sessionId, { ...info }, missionId)); }, }); dispatchPath = result.dispatchPath; a2aInstanceId = result.a2aInstanceId; a2aTask = result.task; if (result.estimatedStart) estimatedStart = result.estimatedStart; } catch (err) { const msg = err.message ?? String(err); logServeWarn('dispatch', `executor ${executor.executorId} dispatch failed: ${msg}`); executorRegistry.assignMission(missionId, executor.executorId); executorRegistry.failMission(missionId, msg); telemetryStore.ingest(createEvent('mission.abort', sessionId, { missionId, executorId: executor.executorId, error: msg, }, missionId)); // Distinguish unreachable (network/timeout) from forward errors. const status = /v1 dispatch failed: \d/.test(msg) ? 502 : 502; const errorTag = /unreachable|ECONN|fetch failed/i.test(msg) ? 'executor_unreachable' : 'executor_forward_failed'; return c.json({ error: errorTag, detail: msg }, status); } // 4. Record the mission and emit telemetry executorRegistry.assignMission(missionId, executor.executorId); if (dispatchPath === 'v2' && a2aTask && a2aInstanceId) { void observeA2ATerminalState(executorRegistry, executor, missionId, a2aInstanceId, a2aTask, { onError: (err) => { logServeWarn('dispatch', `A2A terminal observer failed for mission ${missionId}: ${err.message ?? String(err)}`); }, }); } telemetryStore.ingest(createEvent('mission.dispatch', sessionId, { missionId, executorId: executor.executorId, objective: payload.objective, completion: payload.completion, }, missionId)); // 5. Return 202 Accepted const dispatchResp = { mission_id: missionId, executor_id: executor.executorId, status: 'assigned', dispatch_path: dispatchPath, }; if (a2aInstanceId) dispatchResp.a2a_instance_id = a2aInstanceId; if (estimatedStart) dispatchResp.estimated_start = estimatedStart; return c.json(dispatchResp, 202); }); // GET /api/v1/missions/:id → mission status snapshot app.get('/api/v1/missions/:id', (c) => { const mission = executorRegistry.getMission(c.req.param('id')); if (!mission) return c.json({ error: 'Mission not found' }, 404); return c.json({ mission_id: mission.missionId, executor_id: mission.executorId, state: mission.state, created_at: mission.createdAt, updated_at: mission.updatedAt, completed_at: mission.completedAt, recent_events: mission.recentEvents, pty_session_ref: mission.ptySessionRef, exit_code: mission.exitCode, error: mission.error, }); }); // POST /api/v1/missions/:id/hitl_response → 200; forwards to owning executor over WS app.post('/api/v1/missions/:id/hitl_response', async (c) => { const missionId = c.req.param('id'); const mission = executorRegistry.getMission(missionId); if (!mission) return c.json({ error: 'Mission not found' }, 404); let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const payload = body; if (!payload.hitl_id || !payload.response) { return c.json({ error: 'hitl_id and response are required' }, 400); } // Push hitl_responded event to the executor over WS const envelope = { event: 'mission.hitl_responded', executor_id: mission.executorId, mission_id: missionId, ts: new Date().toISOString(), data: { hitl_id: payload.hitl_id, response: payload.response, responded_at: new Date().toISOString(), }, }; const pushed = executorRegistry.pushToExecutor(mission.executorId, envelope); if (!pushed) { return c.json({ error: 'executor_not_connected' }, 503); } return c.json({ ok: true }); }); // POST /api/v1/missions/:id/pause → 200 app.post('/api/v1/missions/:id/pause', (c) => { const missionId = c.req.param('id'); const mission = executorRegistry.getMission(missionId); if (!mission) return c.json({ error: 'Mission not found' }, 404); // Forward pause command to executor over WS executorRegistry.pushToExecutor(mission.executorId, { event: 'mission.paused', executor_id: mission.executorId, mission_id: missionId, ts: new Date().toISOString(), data: { state: 'paused', reason: 'operator_request' }, }); executorRegistry.transitionMission(missionId, 'paused'); return c.json({ ok: true }); }); // POST /api/v1/missions/:id/resume → 200 app.post('/api/v1/missions/:id/resume', (c) => { const missionId = c.req.param('id'); const mission = executorRegistry.getMission(missionId); if (!mission) return c.json({ error: 'Mission not found' }, 404); executorRegistry.pushToExecutor(mission.executorId, { event: 'mission.resumed', executor_id: mission.executorId, mission_id: missionId, ts: new Date().toISOString(), data: { state: 'running', resumed_from: 'paused' }, }); executorRegistry.transitionMission(missionId, 'running'); return c.json({ ok: true }); }); // POST /api/v1/missions/:id/abort → 200 app.post('/api/v1/missions/:id/abort', (c) => { const missionId = c.req.param('id'); const mission = executorRegistry.getMission(missionId); if (!mission) return c.json({ error: 'Mission not found' }, 404); executorRegistry.pushToExecutor(mission.executorId, { event: 'mission.aborted', executor_id: mission.executorId, mission_id: missionId, ts: new Date().toISOString(), data: { state: 'aborted', aborted_by: 'operator', reason: 'operator_request' }, }); executorRegistry.transitionMission(missionId, 'aborted'); return c.json({ ok: true }); }); // ── A2A push notification webhook receiver (#1256) ──────────────────── // // Receives StreamResponse-shape payloads pushed by an agentic-sandbox // executor when SSE isn't available (e.g. serverless missions). HMAC // verification is on the raw body, replay window is 5 minutes, // event-id idempotency is in-process. Per-mission secrets are // registered via POST /api/v1/push-configs. // // The endpoint path is configurable via env so deployments behind a // load-balancer with path rewriting can match upstream. const webhookPath = process.env['AIWG_A2A_WEBHOOK_PATH'] ?? '/aiwg/webhooks/a2a'; app.post(webhookPath, async (c) => { const configId = c.req.query('configId') ?? c.req.query('config_id') ?? ''; const signature = c.req.header('x-aiwg-signature') ?? c.req.header('X-AIWG-Signature') ?? undefined; const eventId = c.req.header('x-aiwg-event-id') ?? c.req.header('X-AIWG-Event-Id') ?? undefined; // Read raw body bytes — signature is computed over UTF-8 bytes. const rawText = await c.req.text(); const bodyBuf = Buffer.from(rawText, 'utf8'); const result = await handleWebhook(configId, bodyBuf, signature, eventId, { registry: pushSecretRegistry, idempotency: webhookIdempotency, route: async (entry, event) => { // Append to mission recentEvents via a synthesized envelope. // The 'mission.webhook' event type falls through the registry's // default case (no state mutation), but timestamp + telemetry // still record the delivery for operator visibility. if (entry.missionId) { const mission = executorRegistry.getMission(entry.missionId); if (mission) { executorRegistry.handleEvent({ event: 'mission.webhook', executor_id: mission.executorId, mission_id: entry.missionId, ts: new Date().toISOString(), data: { stream_event: event }, }); } } telemetryStore.ingest(createEvent('a2a.webhook.received', entry.missionId ?? entry.configId, { configId: entry.configId, ...(entry.taskId ? { taskId: entry.taskId } : {}), })); }, }); return c.json(result.body, result.status); }); // POST /api/v1/push-configs → register a per-mission webhook secret. // Returns { configId } so the caller can pass it to // A2AClient.createPushNotificationConfig with `metadata.aiwg.config_id`. app.post('/api/v1/push-configs', async (c) => { let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } const p = body; if (!p.configId || typeof p.configId !== 'string') { return c.json({ error: 'configId is required' }, 400); } if (!p.secret || typeof p.secret !== 'string' || p.secret.length < 16) { return c.json({ error: 'secret is required and must be ≥16 chars' }, 400); } pushSecretRegistry.register({ configId: p.configId, secret: p.secret, ...(p.missionId ? { missionId: p.missionId } : {}), ...(p.taskId ? { taskId: p.taskId } : {}), ...(p.metadata ? { metadata: p.metadata } : {}), }); return c.json({ ok: true, configId: p.configId }, 201); }); // DELETE /api/v1/push-configs/:configId → unregister on mission complete. app.delete('/api/v1/push-configs/:configId', (c) => { const configId = c.req.param('configId'); const removed = pushSecretRegistry.unregister(configId); if (!removed) return c.json({ error: 'configId not found' }, 404); return new Response(null, { status: 204 }); }); // GET /api/v1/push-configs → debug surface; lists registered configIds. app.get('/api/v1/push-configs', (_c) => { return _c.json({ count: pushSecretRegistry.size() }); }); // Telemetry API (#716) app.get('/api/telemetry', (c) => { const sid = c.req.query('sessionId'); const limit = parseInt(c.req.query('limit') ?? '100', 10); const events = telemetryStore.query(sid || 'default', { limit }); return c.json({ events }); }); app.get('/api/telemetry/metrics', (c) => { const sid = c.req.query('sessionId') || 'default'; return c.json(telemetryStore.metrics(sid)); }); app.post('/api/telemetry', async (c) => { try { const body = await c.req.json(); telemetryStore.ingest(body); return c.json({ ok: true }, 201); } catch { return c.json({ error: 'Invalid event' }, 400); } }); if (!opts.readOnly) { app.post('/api/sessions', (c) => c.json({ id: null, error: 'Use /ws/pty/:sessionId to start a PTY session' }, 501)); } // ---- Sandbox Registration API (#731) ---- // Register a sandbox instance app.post('/api/sandboxes/register', async (c) => {