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

315 lines 12.7 kB
/** * Local Executor Handler * * Implements `aiwg local-executor serve` — boots a no-sandbox host-process * executor as a conformance shim over DaemonSupervisor, then registers it * with `aiwg serve` via the executor contract v1 handshake. * * Security note: * This executor has `isolation:none`. Agent code runs directly in the host * process under the same filesystem permissions as the operator. It inherits * the Claude Code `--dangerously-skip-permissions` posture when invoked via * the standard agent runner. Do not expose on non-loopback interfaces unless * you understand the risk (see docs/local-executor-guide.md §Security). * * @issue #1181 * @see docs/contracts/executor.v1.md * @see tools/ralph-external/executor-shim.mjs */ import path from 'path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { randomUUID } from 'crypto'; const DEFAULT_PORT = 8200; const DEFAULT_BIND = '127.0.0.1'; const DEFAULT_AIWG_SERVE = 'http://127.0.0.1:7337'; const IDENTITY_FILE = path.join('.aiwg', 'local-executor', 'identity.json'); /** * Resolve or generate-once the executor identity. * Written to `.aiwg/local-executor/identity.json` so restarts reuse the same * UUID (token reclaim via identity store on the aiwg-serve side). */ function resolveExecutorId(override) { if (override) return override; const abs = path.resolve(IDENTITY_FILE); if (existsSync(abs)) { try { const raw = readFileSync(abs, 'utf-8'); const data = JSON.parse(raw); if (data.executor_id) return data.executor_id; } catch { /* fall through to generate */ } } const id = randomUUID(); try { mkdirSync(path.dirname(abs), { recursive: true }); writeFileSync(abs, JSON.stringify({ executor_id: id, created_at: new Date().toISOString() }, null, 2), 'utf-8'); } catch { /* non-fatal — use in-memory id */ } return id; } /** * Parse `aiwg local-executor serve` flags. */ function parseLocalExecutorArgs(args) { let port = DEFAULT_PORT; let bind = DEFAULT_BIND; let aiwgServe = process.env['AIWG_SERVE_ENDPOINT'] ?? DEFAULT_AIWG_SERVE; let maxConcurrency = 4; let executorIdFlag; for (let i = 0; i < args.length; i++) { const arg = args[i]; if ((arg === '--port' || arg === '-p') && args[i + 1]) { const parsed = parseInt(args[i + 1], 10); if (!isNaN(parsed)) port = parsed; i++; } else if (arg === '--bind' && args[i + 1]) { bind = args[i + 1]; i++; } else if (arg === '--aiwg-serve' && args[i + 1]) { aiwgServe = args[i + 1]; i++; } else if (arg === '--max-concurrency' && args[i + 1]) { const n = parseInt(args[i + 1], 10); if (!isNaN(n) && n > 0) maxConcurrency = n; i++; } else if (arg === '--executor-id' && args[i + 1]) { executorIdFlag = args[i + 1]; i++; } } return { port, bind, aiwgServe, maxConcurrency, executorId: resolveExecutorId(executorIdFlag), }; } /** * Emit a warning when the executor binds on a non-loopback interface. */ function warnIfNonLoopback(bind) { const loopbackAddrs = new Set(['127.0.0.1', '::1', 'localhost']); if (!loopbackAddrs.has(bind)) { console.warn('[local-executor] WARNING: binding on non-loopback interface ' + bind + '.\n' + ' This executor has isolation:none — agent code runs in the host process.\n' + ' Ensure this interface is not reachable by untrusted parties.\n' + ' The executor token will be rotated by aiwg-serve on first non-loopback register.'); } } /** * `aiwg local-executor serve` handler. * * Boots a DaemonSupervisor + ExecutorShim, registers with aiwg-serve, * then keeps the process alive until SIGINT/SIGTERM. */ export const localExecutorServeHandler = { id: 'local-executor-serve', name: 'Local Executor Serve', description: 'Start the local no-sandbox executor (isolation:none, Core+HITL conformance)', category: 'project', aliases: [], async execute(ctx) { // The subcommand is: `aiwg local-executor serve [flags]` // ctx.args[0] === 'serve' (consumed by router) or flags directly const flagArgs = ctx.args[0] === 'serve' ? ctx.args.slice(1) : ctx.args; const opts = parseLocalExecutorArgs(flagArgs); warnIfNonLoopback(opts.bind); // ── Dynamic imports (same pattern as serve.ts) ────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any let ExecutorShim; // eslint-disable-next-line @typescript-eslint/no-explicit-any let startExecutorServer; // eslint-disable-next-line @typescript-eslint/no-explicit-any let DaemonSupervisor; try { const shimMod = await (new Function('m', 'return import(m)'))(path.join(ctx.frameworkRoot, 'tools', 'ralph-external', 'executor-shim.mjs')); ExecutorShim = shimMod.ExecutorShim; startExecutorServer = shimMod.startExecutorServer; } catch (err) { return { message: `Failed to load executor-shim: ${err.message}`, exitCode: 1, }; } try { const dsMod = await (new Function('m', 'return import(m)'))(path.join(ctx.frameworkRoot, 'tools', 'ralph-external', 'daemon-supervisor.mjs')); DaemonSupervisor = dsMod.DaemonSupervisor; } catch (err) { return { message: `Failed to load DaemonSupervisor: ${err.message}`, exitCode: 1, }; } // AgentSupervisor is optional — shim works without it but task execution // requires it. Try loading from ralph-external/orchestrator.mjs. let agentSupervisorInstance = null; try { const orchMod = await (new Function('m', 'return import(m)'))(path.join(ctx.frameworkRoot, 'tools', 'ralph-external', 'orchestrator.mjs')); const OrchClass = orchMod.Orchestrator ?? orchMod.default; if (OrchClass) { const orch = new OrchClass({ maxConcurrent: opts.maxConcurrency }); agentSupervisorInstance = orch.agentSupervisor ?? orch; } } catch { /* orchestrator optional */ } // Minimal stub if no real agent supervisor is available (allows shim to // boot for conformance testing without a real Claude/Codex install) if (!agentSupervisorInstance) { const { EventEmitter } = await import('events'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const stub = new (class StubAgentSupervisor extends EventEmitter { tasks = new Map(); counter = 0; submit(prompt, options = {}) { const id = `task-${++this.counter}`; this.tasks.set(id, { id, prompt, ...options }); // Simulate async start queueMicrotask(() => { this.emit('task:started', { taskId: id, pid: 50000 + this.counter }); }); return { id }; } cancel(taskId) { if (this.tasks.has(taskId)) { this.tasks.delete(taskId); this.emit('task:cancelled', { taskId }); return true; } return false; } async shutdown() { this.tasks.clear(); } })(); agentSupervisorInstance = stub; } // Build supervisor + shim const supervisor = new DaemonSupervisor({ agentSupervisor: agentSupervisorInstance, maxConcurrent: opts.maxConcurrency, }); const restBase = `http://${opts.bind}:${opts.port}`; const wsBase = `ws://${opts.bind}:${opts.port}`; const shim = new ExecutorShim({ supervisor, executorId: opts.executorId, name: 'aiwg-local-executor', version: '1.0.0', restBase, wsBase, aiwgServeUrl: opts.aiwgServe, }); // Register with aiwg-serve let token; try { token = await shim.register(); } catch (err) { return { message: `Failed to register with aiwg-serve at ${opts.aiwgServe}: ${err.message}\n` + 'Is aiwg serve running? Start it with: aiwg serve --no-open', exitCode: 1, }; } // Start the executor's own HTTP+WS server let server; try { server = await startExecutorServer(shim, { port: opts.port, bind: opts.bind, token, }); } catch (err) { await shim.deregister(); return { message: `Failed to start executor server: ${err.message}`, exitCode: 1, }; } console.log(`Local executor: ${server.url}`); console.log(`Registered with: ${opts.aiwgServe}`); console.log(`Executor ID: ${opts.executorId}`); console.log(`Max concurrency: ${opts.maxConcurrency}`); console.log('Press Ctrl+C to stop.'); // Status ticker — log active mission count every 30 s const ticker = setInterval(() => { const st = supervisor.status(); console.log(`[local-executor] running=${st.concurrencyUsed}/${st.concurrencyMax} ` + `queued=${st.queueDepth} circuit=${st.circuitState?.state ?? 'ok'}`); }, 30_000); // Keep alive until SIGINT/SIGTERM await new Promise((resolve) => { let settled = false; const cleanup = async (signal) => { if (settled) return; settled = true; clearInterval(ticker); console.log(`\n[local-executor] ${signal} received — shutting down…`); try { await shim.shutdown(); } catch { /* ignore */ } try { server.close(); } catch { /* ignore */ } try { await supervisor.shutdown(5_000); } catch { /* ignore */ } resolve(); }; process.once('SIGINT', () => { void cleanup('SIGINT'); }); process.once('SIGTERM', () => { void cleanup('SIGTERM'); }); }); return { exitCode: 0, message: 'Local executor stopped.' }; }, }; /** * Top-level `aiwg local-executor` handler. * Dispatches to subcommands (currently only `serve`). */ export const localExecutorHandler = { id: 'local-executor', name: 'Local Executor', description: 'Manage the local no-sandbox executor (aiwg local-executor serve)', category: 'project', aliases: [], async execute(ctx) { const [subcommand, ...rest] = ctx.args; if (!subcommand || subcommand === 'serve') { const innerCtx = { ...ctx, args: ['serve', ...rest] }; return localExecutorServeHandler.execute(innerCtx); } if (subcommand === '--help' || subcommand === 'help') { console.log('Usage: aiwg local-executor serve [flags]\n' + '\n' + 'Flags:\n' + ' --port <n> Bind port (default: 8200)\n' + ' --bind <host> Bind interface (default: 127.0.0.1)\n' + ' --aiwg-serve <url> Registration target (default: http://127.0.0.1:7337)\n' + ' --max-concurrency <n> Max concurrent missions (default: 4)\n' + ' --executor-id <uuid> Override stable executor ID\n' + '\n' + 'Environment:\n' + ' AIWG_SERVE_ENDPOINT Override --aiwg-serve default\n'); return { exitCode: 0 }; } return { message: `Unknown subcommand: ${subcommand}. Run \`aiwg local-executor help\` for usage.`, exitCode: 1, }; }, }; //# sourceMappingURL=local-executor.js.map