import crypto from 'node:crypto'; import { spawn as nodeSpawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { EventEmitter } from 'node:events'; import { Agent, fetch as undiciFetch } from 'undici'; const DEFAULT_OUTPUT_LIMIT = 64 * 1024; const DEFAULT_ALLOWED_ENV = Object.freeze(['PATH', 'LANG', 'LC_ALL', 'TMPDIR']); function adapterError(code, message, { retryable = false, cause = null } = {}) { const error = new Error(message, cause ? { cause } : undefined); error.code = code; error.retryable = retryable; return error; } function appendBounded(current, chunk, limit) { const next = `${current}${Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '')}`; return next.length <= limit ? next : next.slice(next.length - limit); } function isLoopbackHostname(hostname) { const normalized = String(hostname ?? '').trim().toLowerCase(); return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]' || normalized.endsWith('.localhost'); } function isLocalExecutorHostname(hostname) { const normalized = String(hostname ?? '').trim().toLowerCase(); return isLoopbackHostname(normalized) || normalized === 'host.docker.internal' || normalized === 'gateway.docker.internal'; } function normalizeBaseUrl(value, { allowRemote = false } = {}) { const url = new URL(String(value ?? '').trim()); if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('Executor adapter URL must use HTTP or HTTPS'); } if (!allowRemote && !isLoopbackHostname(url.hostname)) { throw new Error('Remote executor targets require explicit allowRemote'); } url.username = ''; url.password = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } async function responseError(response, code, prefix) { const text = await response.text().catch(() => ''); return adapterError( code, `${prefix} (${response.status})${text ? `: ${text.slice(0, 512)}` : ''}`, { retryable: response.status >= 500 || response.status === 429 }, ); } async function* parseSse(body) { const decoder = new TextDecoder(); let buffer = ''; for await (const chunk of body) { buffer += decoder.decode(chunk, { stream: true }); while (true) { const boundary = buffer.search(/\r?\n\r?\n/); if (boundary < 0) break; const raw = buffer.slice(0, boundary); const separatorLength = buffer.startsWith('\r\n\r\n', boundary) ? 4 : 2; buffer = buffer.slice(boundary + separatorLength); let event = 'message'; const data = []; for (const line of raw.split(/\r?\n/)) { if (line.startsWith('event:')) event = line.slice(6).trim() || 'message'; if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); } if (data.length) { const text = data.join('\n'); let parsed = text; try { parsed = JSON.parse(text); } catch { // Preserve non-JSON Goosed events as bounded text. } yield { event, data: parsed }; } } } } function goosedEventType(event) { // Real Goosed SSE frames use `event: message` (or omit event) and put the // semantic type on `data.type` (e.g. Message / Finish). Prefer data.type so // the generic SSE event name does not mask Finish. const dataType = String( event?.data?.type ?? event?.data?.event_type ?? '', ).trim().toLowerCase(); if (dataType) return dataType; return String(event?.event ?? '').trim().toLowerCase(); } function terminalGoosedEvent(event) { const type = goosedEventType(event); if (['finish', 'finished', 'completed', 'error', 'failed'].includes(type)) { return true; } // Some Goose builds emit `{ type: "Error", error: "..." }` without event name. return Boolean(event?.data?.error) && type === ''; } export function createGoosedExecutorAdapter({ baseUrl, secret = '', fetchImpl = null, allowRemote = false, tlsInsecure = false, provider = '', model = '', workspaceResolver = async (reference) => reference?.id ?? null, } = {}) { const target = normalizeBaseUrl(baseUrl, { allowRemote }); const targetUrl = new URL(target); if (tlsInsecure && !isLocalExecutorHostname(targetUrl.hostname)) { throw new Error('Insecure executor TLS is restricted to local targets'); } const transport = fetchImpl ?? undiciFetch; if (typeof transport !== 'function') throw new Error('Goosed adapter requires fetch'); const dispatcher = tlsInsecure ? new Agent({ connect: { rejectUnauthorized: false } }) : null; const active = new Map(); function headers(extra = {}) { return { accept: 'application/json', 'content-type': 'application/json', ...(secret ? { 'X-Secret-Key': secret } : {}), ...extra, }; } async function submit(request, { jobId, signal = null, emit = async () => {}, } = {}) { const controller = new AbortController(); const abort = () => controller.abort(signal?.reason); if (signal?.aborted) abort(); else signal?.addEventListener?.('abort', abort, { once: true }); active.set(jobId, controller); try { const workingDir = await workspaceResolver(request.task.workspaceRef); if (!workingDir) { throw adapterError( 'GOOSED_WORKSPACE_UNRESOLVED', 'Goosed workspace reference could not be resolved', ); } const start = await transport(`${target}/agent/start`, { method: 'POST', headers: headers(), body: JSON.stringify({ working_dir: workingDir, enable_context_memory: false, }), signal: controller.signal, ...(dispatcher ? { dispatcher } : {}), }); if (!start.ok) throw await responseError(start, 'GOOSED_START_FAILED', 'Goosed start failed'); const session = await start.json(); const sessionId = String(session?.id ?? '').trim(); if (!sessionId) { throw adapterError('GOOSED_SESSION_ID_MISSING', 'Goosed start response missing session id'); } await emit('executor_job_session_created', { sessionRef: { kind: 'goosed-session', id: sessionId }, }); const providerId = String(provider ?? '').trim(); const modelId = String(model ?? '').trim(); if (providerId) { const providerRes = await transport(`${target}/agent/update_provider`, { method: 'POST', headers: headers(), body: JSON.stringify({ session_id: sessionId, provider: providerId, ...(modelId ? { model: modelId } : {}), }), signal: controller.signal, ...(dispatcher ? { dispatcher } : {}), }); if (!providerRes.ok) { throw await responseError( providerRes, 'GOOSED_PROVIDER_UPDATE_FAILED', 'Goosed provider update failed', ); } await emit('executor_job_provider_set', { provider: providerId, model: modelId || null, }); } const eventsResponse = await transport( `${target}/sessions/${encodeURIComponent(sessionId)}/events`, { method: 'GET', headers: headers({ accept: 'text/event-stream' }), signal: controller.signal, ...(dispatcher ? { dispatcher } : {}), }, ); if (!eventsResponse.ok || !eventsResponse.body) { throw await responseError( eventsResponse, 'GOOSED_EVENTS_FAILED', 'Goosed event stream failed', ); } const finishPromise = (async () => { let eventCount = 0; let finalEvent = null; for await (const event of parseSse(eventsResponse.body)) { eventCount += 1; const eventType = (goosedEventType(event) || 'message').slice(0, 96); await emit('executor_job_adapter_event', { adapter: 'goosed', eventType, eventCount, }); if (terminalGoosedEvent(event)) { finalEvent = event; break; } } return { eventCount, finalEvent }; })(); // Goosed /reply requires created + metadata.userVisible/agentVisible. const userMessage = { id: crypto.randomUUID(), role: 'user', created: Math.floor(Date.now() / 1000), metadata: { userVisible: true, agentVisible: true, }, content: [{ type: 'text', text: request.task.instruction }], }; const reply = await transport( `${target}/sessions/${encodeURIComponent(sessionId)}/reply`, { method: 'POST', headers: headers(), body: JSON.stringify({ // Goosed requires a UUID request_id; Executor Job ids are not UUIDs. request_id: crypto.randomUUID(), user_message: userMessage, }), signal: controller.signal, ...(dispatcher ? { dispatcher } : {}), }, ); if (!reply.ok) throw await responseError(reply, 'GOOSED_REPLY_FAILED', 'Goosed reply failed'); reply.body?.cancel?.().catch?.(() => {}); const finished = await finishPromise; const finalType = goosedEventType(finished.finalEvent); if (['error', 'failed'].includes(finalType)) { const detail = String( finished.finalEvent?.data?.error ?? finished.finalEvent?.data?.message ?? 'Goosed reported a failed terminal event', ).slice(0, 500); throw adapterError('GOOSED_RUN_FAILED', detail); } return { outcome: 'completed', summary: `Goosed session completed after ${finished.eventCount} events`, artifactRefs: [{ kind: 'goosed-session', id: sessionId }], metrics: { eventCount: finished.eventCount }, }; } catch (error) { if (controller.signal.aborted || error?.name === 'AbortError') { throw adapterError('GOOSED_CANCELLED', 'Goosed execution was cancelled'); } throw error; } finally { signal?.removeEventListener?.('abort', abort); active.delete(jobId); } } return { id: 'goosed', label: 'Goosed', kind: 'remote', enabled: true, dispatchImplemented: true, capabilities: ['session-execution', 'streaming', 'cancel'], submit, async cancel(jobId) { const controller = active.get(jobId); if (!controller) return false; controller.abort(); return true; }, async getState(jobId) { return { active: active.has(jobId) }; }, async health() { try { const response = await transport(`${target}/status`, { method: 'GET', headers: headers(), signal: AbortSignal.timeout(2_000), ...(dispatcher ? { dispatcher } : {}), }); return { ok: response.ok, status: response.status }; } catch (error) { return { ok: false, status: null, code: String(error?.code ?? error?.name ?? 'GOOSED_UNREACHABLE').slice(0, 128), }; } }, async *streamEvents() { throw adapterError( 'GOOSED_STREAM_PUSH_ONLY', 'Goosed events are delivered through submit emit callbacks', ); }, }; } async function resolveWorkspace(reference, { allowedRoot, aliases = {}, } = {}) { const alias = String(reference?.id ?? '').trim(); const configured = aliases[alias]; if (!configured) { throw adapterError( 'EXECUTOR_WORKSPACE_ALIAS_UNKNOWN', `Workspace alias is not configured: ${alias || ''}`, ); } const root = await fs.realpath(allowedRoot); const resolved = await fs.realpath(path.resolve(configured)); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { throw adapterError( 'EXECUTOR_WORKSPACE_OUTSIDE_ROOT', 'Workspace resolves outside the configured worker root', ); } return resolved; } function safeCommand(value) { const command = String(value ?? '').trim(); if (!command || /[\r\n\0]/.test(command)) { throw new Error('Process executor requires a safe command'); } return command; } export function createProcessExecutorAdapter({ id, label = id, command, buildArgs, allowedRoot, workspaceAliases = {}, spawnImpl = nodeSpawn, outputLimit = DEFAULT_OUTPUT_LIMIT, allowedEnv = DEFAULT_ALLOWED_ENV, extraEnv = {}, artifactCollector = async ({ request }) => [request.task.workspaceRef], } = {}) { const executorId = String(id ?? '').trim().toLowerCase(); if (!['aider', 'openhands'].includes(executorId)) { throw new Error('Process executor id must be aider or openhands'); } const executable = safeCommand(command); if (!allowedRoot) throw new Error('Process executor requires allowedRoot'); if (typeof buildArgs !== 'function') throw new Error('Process executor requires buildArgs'); const active = new Map(); async function submit(request, { jobId, signal = null, emit = async () => {}, } = {}) { const cwd = await resolveWorkspace(request.task.workspaceRef, { allowedRoot, aliases: workspaceAliases, }); const args = buildArgs(request.task.instruction, request); if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) { throw new Error(`${executorId} buildArgs must return a string array`); } const env = {}; for (const key of allowedEnv) { if (process.env[key] != null) env[key] = process.env[key]; } Object.assign(env, extraEnv); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let settled = false; const child = spawnImpl(executable, args, { cwd, env, shell: false, detached: false, stdio: ['ignore', 'pipe', 'pipe'], }); active.set(jobId, child); const cleanup = () => { active.delete(jobId); signal?.removeEventListener?.('abort', onAbort); }; const fail = (error) => { if (settled) return; settled = true; cleanup(); reject(error); }; const onAbort = () => { child.kill('SIGTERM'); fail(adapterError('EXECUTOR_PROCESS_CANCELLED', `${label} execution was cancelled`)); }; if (signal?.aborted) onAbort(); else signal?.addEventListener?.('abort', onAbort, { once: true }); child.stdout?.on?.('data', (chunk) => { stdout = appendBounded(stdout, chunk, outputLimit); void emit('executor_job_output_chunk', { adapter: executorId, stream: 'stdout', bytes: Buffer.byteLength(chunk), }); }); child.stderr?.on?.('data', (chunk) => { stderr = appendBounded(stderr, chunk, outputLimit); void emit('executor_job_output_chunk', { adapter: executorId, stream: 'stderr', bytes: Buffer.byteLength(chunk), }); }); child.once?.('error', (error) => { fail(adapterError( 'EXECUTOR_PROCESS_START_FAILED', `${label} failed to start: ${error.message}`, { retryable: true, cause: error }, )); }); child.once?.('exit', async (code, exitSignal) => { if (settled) return; if (code !== 0) { fail(adapterError( 'EXECUTOR_PROCESS_FAILED', `${label} exited with ${exitSignal ?? code}: ${stderr.slice(-1024)}`, )); return; } try { const artifactRefs = await artifactCollector({ executor: executorId, request, cwd, stdout, stderr, }); settled = true; cleanup(); resolve({ outcome: 'completed', summary: `${label} completed successfully`, artifactRefs, metrics: { stdoutBytes: Buffer.byteLength(stdout), stderrBytes: Buffer.byteLength(stderr), }, }); } catch (error) { fail(error); } }); }); } return { id: executorId, label, kind: 'isolated-process', enabled: true, dispatchImplemented: true, capabilities: ['code-edit', 'cancel', 'artifact-refs', 'workspace-alias'], submit, async cancel(jobId) { const child = active.get(jobId); if (!child) return false; child.kill('SIGTERM'); return true; }, async getState(jobId) { return { active: active.has(jobId) }; }, async health() { try { await fs.access(executable); await fs.access(allowedRoot); return { ok: true }; } catch (error) { return { ok: false, code: String(error?.code ?? 'EXECUTOR_PROCESS_UNAVAILABLE').slice(0, 128), }; } }, async *streamEvents() { throw adapterError( 'EXECUTOR_PROCESS_STREAM_PUSH_ONLY', 'Process events are delivered through submit emit callbacks', ); }, }; } export function createAiderExecutorAdapter(options = {}) { return createProcessExecutorAdapter({ id: 'aider', label: 'Aider', buildArgs: (instruction) => ['--yes-always', '--message', instruction], ...options, }); } export function createOpenHandsExecutorAdapter(options = {}) { return createProcessExecutorAdapter({ id: 'openhands', label: 'OpenHands', buildArgs: (instruction) => ['--headless', '--task', instruction], ...options, }); } export const executorAdapterInternals = { DEFAULT_ALLOWED_ENV, DEFAULT_OUTPUT_LIMIT, EventEmitter, adapterError, appendBounded, isLoopbackHostname, isLocalExecutorHostname, normalizeBaseUrl, parseSse, resolveWorkspace, goosedEventType, terminalGoosedEvent, };