Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/blissito_easybits.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
3.8 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "blissito/easybits", "files": {"app/.server/core/sandboxOperations.ts": {"e342fd61012661ab2121c091957981fb9a2ee3b1": {"t": 1785745584.6621077, "n": 134981, "h": "5f8b3c7a1f9403a3", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n /** BYOK: credencial del MOTOR de este tenant para ESTE turno. Ver resolveEngineApiKey. */\n engineApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.engineApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.engineApiKey ? { engineApiKey: body.engineApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\n// Templates cuyo cerebro arma el MCP `easybits` a partir de EASYBITS_API_KEY (y, en\n// Code Mode, pega a la REST API v2 con EASYBITS_BASE_URL). SIN la key el runtime NO\n// registra el server → el agente se queda sin NINGUNA tool de EasyBits y su\n// EASYBITS_TOOL_GROUP/buckets quedan inertes, aunque la app se los mande por turno.\n// Vivía duplicado en 3 ramas por template (y faltaba entero en codex-worker, que por\n// eso arrancaba mudo de tools); una sola lista evita que el próximo motor lo repita.\nconst EASYBITS_TOOLED_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"rust-ghosty\",\n \"cagent-ghosty\",\n]);\n\n// Los workers de la FLOTA son always-on y su llave ES su acceso a las tools: si el\n// dueño no tiene una en el vault se MINTEA persistente con sus scopes. Los demás\n// templates se quedan en solo-vault (comportamiento previo): sin key corren con sus\n// tools locales, que es aceptable para ellos y evita minteos sorpresa.\nconst EASYBITS_MINTING_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n]);\n\nasync function injectEasybitsAccess(\n ctx: AuthContext,\n params: { template: SandboxTemplate; name?: string },\n env: Record<string, string>\n): Promise<void> {\n // Base de la REST API v2 para Code Mode. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP = \"scripting\".\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n if (env.EASYBITS_API_KEY) return; // el env del caller siempre gana\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n return;\n }\n if (!EASYBITS_MINTING_TEMPLATES.has(params.template)) return;\n const minted = await createApiKey(ctx.user.id, {\n name: `${params.template}-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n // Red de seguridad de siesta: con esto, el timer de `timeoutSeconds` del host\n // SUSPENDE (snapshot) en vez de DESTRUIR. Lo usan los workers de flota, cuyo\n // apagado normal lo hace el reaper propio de easybits — si ese latido se para\n // (deploy, restart, health check caído), esto es lo único que devuelve la RAM.\n suspendOnIdle?: boolean;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_BASE_URL + EASYBITS_API_KEY los resuelve injectEasybitsAccess (abajo,\n // común a todos los templates con tools de EasyBits).\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // (open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty también arman el MCP de\n // easybits desde EASYBITS_API_KEY — lo resuelve injectEasybitsAccess más abajo.)\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n // EASYBITS_API_KEY (obligatoria acá: es a la vez la llave del LLM medido y la del\n // MCP) la resuelve/mintea injectEasybitsAccess más abajo.\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el CLI headless usa OPENAI_API_KEY (doc\n // oficial; el launcher hace `codex login --with-api-key` con ella porque el\n // app-server NO la lee del entorno). Se resuelve del vault por su nombre canónico,\n // igual que la inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // Acceso a las tools de EasyBits — UNA sola vez, para todos los templates que las\n // consumen. Va después de las ramas por template para que el env que ellas fijen\n // (o el del caller) siga ganando.\n if (EASYBITS_TOOLED_TEMPLATES.has(params.template)) {\n await injectEasybitsAccess(ctx, params, env);\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n suspendOnIdle: params.suspendOnIdle,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n engineApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message credencial del motor del tenant (BYOK).\n engineApiKey: body.engineApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "3bf5cf5984152a15c2431ccdb90c67830978b6cf": {"t": 1785754514.7573102, "n": 134981, "h": "5f8b3c7a1f9403a3", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n /** BYOK: credencial del MOTOR de este tenant para ESTE turno. Ver resolveEngineApiKey. */\n engineApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.engineApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.engineApiKey ? { engineApiKey: body.engineApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\n// Templates cuyo cerebro arma el MCP `easybits` a partir de EASYBITS_API_KEY (y, en\n// Code Mode, pega a la REST API v2 con EASYBITS_BASE_URL). SIN la key el runtime NO\n// registra el server → el agente se queda sin NINGUNA tool de EasyBits y su\n// EASYBITS_TOOL_GROUP/buckets quedan inertes, aunque la app se los mande por turno.\n// Vivía duplicado en 3 ramas por template (y faltaba entero en codex-worker, que por\n// eso arrancaba mudo de tools); una sola lista evita que el próximo motor lo repita.\nconst EASYBITS_TOOLED_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"rust-ghosty\",\n \"cagent-ghosty\",\n]);\n\n// Los workers de la FLOTA son always-on y su llave ES su acceso a las tools: si el\n// dueño no tiene una en el vault se MINTEA persistente con sus scopes. Los demás\n// templates se quedan en solo-vault (comportamiento previo): sin key corren con sus\n// tools locales, que es aceptable para ellos y evita minteos sorpresa.\nconst EASYBITS_MINTING_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n]);\n\nasync function injectEasybitsAccess(\n ctx: AuthContext,\n params: { template: SandboxTemplate; name?: string },\n env: Record<string, string>\n): Promise<void> {\n // Base de la REST API v2 para Code Mode. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP = \"scripting\".\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n if (env.EASYBITS_API_KEY) return; // el env del caller siempre gana\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n return;\n }\n if (!EASYBITS_MINTING_TEMPLATES.has(params.template)) return;\n const minted = await createApiKey(ctx.user.id, {\n name: `${params.template}-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n // Red de seguridad de siesta: con esto, el timer de `timeoutSeconds` del host\n // SUSPENDE (snapshot) en vez de DESTRUIR. Lo usan los workers de flota, cuyo\n // apagado normal lo hace el reaper propio de easybits — si ese latido se para\n // (deploy, restart, health check caído), esto es lo único que devuelve la RAM.\n suspendOnIdle?: boolean;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_BASE_URL + EASYBITS_API_KEY los resuelve injectEasybitsAccess (abajo,\n // común a todos los templates con tools de EasyBits).\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // (open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty también arman el MCP de\n // easybits desde EASYBITS_API_KEY — lo resuelve injectEasybitsAccess más abajo.)\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n // EASYBITS_API_KEY (obligatoria acá: es a la vez la llave del LLM medido y la del\n // MCP) la resuelve/mintea injectEasybitsAccess más abajo.\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el CLI headless usa OPENAI_API_KEY (doc\n // oficial; el launcher hace `codex login --with-api-key` con ella porque el\n // app-server NO la lee del entorno). Se resuelve del vault por su nombre canónico,\n // igual que la inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // Acceso a las tools de EasyBits — UNA sola vez, para todos los templates que las\n // consumen. Va después de las ramas por template para que el env que ellas fijen\n // (o el del caller) siga ganando.\n if (EASYBITS_TOOLED_TEMPLATES.has(params.template)) {\n await injectEasybitsAccess(ctx, params, env);\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n suspendOnIdle: params.suspendOnIdle,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n engineApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message credencial del motor del tenant (BYOK).\n engineApiKey: body.engineApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "3f24f99c2f9597ee926a246ced8d49abae9a3cb8": {"t": 1785754516.551192, "n": 134638, "h": "10db376b8eb12e1f", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\n// Templates cuyo cerebro arma el MCP `easybits` a partir de EASYBITS_API_KEY (y, en\n// Code Mode, pega a la REST API v2 con EASYBITS_BASE_URL). SIN la key el runtime NO\n// registra el server → el agente se queda sin NINGUNA tool de EasyBits y su\n// EASYBITS_TOOL_GROUP/buckets quedan inertes, aunque la app se los mande por turno.\n// Vivía duplicado en 3 ramas por template (y faltaba entero en codex-worker, que por\n// eso arrancaba mudo de tools); una sola lista evita que el próximo motor lo repita.\nconst EASYBITS_TOOLED_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"rust-ghosty\",\n \"cagent-ghosty\",\n]);\n\n// Los workers de la FLOTA son always-on y su llave ES su acceso a las tools: si el\n// dueño no tiene una en el vault se MINTEA persistente con sus scopes. Los demás\n// templates se quedan en solo-vault (comportamiento previo): sin key corren con sus\n// tools locales, que es aceptable para ellos y evita minteos sorpresa.\nconst EASYBITS_MINTING_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n]);\n\nasync function injectEasybitsAccess(\n ctx: AuthContext,\n params: { template: SandboxTemplate; name?: string },\n env: Record<string, string>\n): Promise<void> {\n // Base de la REST API v2 para Code Mode. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP = \"scripting\".\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n if (env.EASYBITS_API_KEY) return; // el env del caller siempre gana\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n return;\n }\n if (!EASYBITS_MINTING_TEMPLATES.has(params.template)) return;\n const minted = await createApiKey(ctx.user.id, {\n name: `${params.template}-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n // Red de seguridad de siesta: con esto, el timer de `timeoutSeconds` del host\n // SUSPENDE (snapshot) en vez de DESTRUIR. Lo usan los workers de flota, cuyo\n // apagado normal lo hace el reaper propio de easybits — si ese latido se para\n // (deploy, restart, health check caído), esto es lo único que devuelve la RAM.\n suspendOnIdle?: boolean;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_BASE_URL + EASYBITS_API_KEY los resuelve injectEasybitsAccess (abajo,\n // común a todos los templates con tools de EasyBits).\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // (open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty también arman el MCP de\n // easybits desde EASYBITS_API_KEY — lo resuelve injectEasybitsAccess más abajo.)\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n // EASYBITS_API_KEY (obligatoria acá: es a la vez la llave del LLM medido y la del\n // MCP) la resuelve/mintea injectEasybitsAccess más abajo.\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el CLI headless usa OPENAI_API_KEY (doc\n // oficial; el launcher hace `codex login --with-api-key` con ella porque el\n // app-server NO la lee del entorno). Se resuelve del vault por su nombre canónico,\n // igual que la inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // Acceso a las tools de EasyBits — UNA sola vez, para todos los templates que las\n // consumen. Va después de las ramas por template para que el env que ellas fijen\n // (o el del caller) siga ganando.\n if (EASYBITS_TOOLED_TEMPLATES.has(params.template)) {\n await injectEasybitsAccess(ctx, params, env);\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n suspendOnIdle: params.suspendOnIdle,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "3e4a7a371483bd4b8258ff987af9298b9330e4b4": {"t": 1785754520.0064244, "n": 134408, "h": "8f950ab57e7547ed", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n // Red de seguridad de siesta: con esto, el timer de `timeoutSeconds` del host\n // SUSPENDE (snapshot) en vez de DESTRUIR. Lo usan los workers de flota, cuyo\n // apagado normal lo hace el reaper propio de easybits — si ese latido se para\n // (deploy, restart, health check caído), esto es lo único que devuelve la RAM.\n suspendOnIdle?: boolean;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el Codex SDK headless usa OPENAI_API_KEY\n // (doc oficial). Se resuelve del vault por su nombre canónico, igual que la\n // inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv del registro).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n suspendOnIdle: params.suspendOnIdle,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "2f4e449fc3086fca966e838c7f33bdad60505cb4": {"t": 1785754523.1254072, "n": 134006, "h": "118f054d6bc762fd", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el Codex SDK headless usa OPENAI_API_KEY\n // (doc oficial). Se resuelve del vault por su nombre canónico, igual que la\n // inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv del registro).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "ceee381d297c6a5e6c52e560a4001a853bdedc76": {"t": 1785754526.2452664, "n": 131091, "h": "33076e8e18d4bb0e", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el Codex SDK headless usa OPENAI_API_KEY\n // (doc oficial). Se resuelve del vault por su nombre canónico, igual que la\n // inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv del registro).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "39d21b33015bee48636c3aff7952c5a36255799f": {"t": 1785754528.1437416, "n": 130487, "h": "3975461ba210ea6a", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el Codex SDK headless usa OPENAI_API_KEY\n // (doc oficial). Se resuelve del vault por su nombre canónico, igual que la\n // inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv del registro).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "511d04f215b09b691e5a329e34744d0cdb83ff3e": {"t": 1785754530.0271332, "n": 130123, "h": "f2bca01737aff62d", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el Codex SDK headless usa OPENAI_API_KEY\n // (doc oficial). Se resuelve del vault por su nombre canónico, igual que la\n // inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv del registro).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "b94a4ce5b506256ed06649b1db799fcde91b030c": {"t": 1785754535.6834319, "n": 130112, "h": "890bc32597881548", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): igual que la inyección DeepSeek, la key se\n // resuelve del vault por su nombre canónico. Dormant hasta que el worker se hornee\n // en el host (Fase 2). Ver ~/lib/fleetEngines (engine \"codex\").\n if (params.template === \"codex-worker\" && !env.CODEX_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"CODEX_API_KEY\").catch(() => null);\n if (k) env.CODEX_API_KEY = k;\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "3ba3e66a516b716400645cd7ecba243f000c51ae": {"t": 1785754559.4613893, "n": 128376, "h": "ca60b4fdf56813f8", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "1e1a7a3ccb121f5b137aa5441f1d3105fade8e3b": {"t": 1785754561.4321513, "n": 127970, "h": "6be4179efe9e8ff4", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "22b00a7ec96474ae0399221a6376fda603f8cffe": {"t": 1785754567.8504543, "n": 127523, "h": "be696511db114f26", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "be799af70f24ee7c37ff4a9c15de69d2a6b0770d": {"t": 1785754571.6374605, "n": 127232, "h": "3be11f884681af52", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "f66bb5625e716804afe1dda6ff80bc30f89901d3": {"t": 1785754575.9400308, "n": 126943, "h": "a76e8fd91b759dc7", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n return resp.children ?? [];\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "68aaa08de3bfadfaf82c4d663998f75bb7cf6a1b": {"t": 1785754578.4134758, "n": 120447, "h": "33c642e5c2591f0c", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "4120e365965dd3d22fc07f2785df027d078aa6ef": {"t": 1785754580.3708313, "n": 120069, "h": "812484d21e439cae", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // Code Mode: el agente escribe scripts que pegan a la REST API v2 con su\n // $EASYBITS_API_KEY + esta base. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP =\n // \"scripting\" (ya fluye al env del VM y el worker lo lee → --tools scripting).\n // NO se fuerza un default aquí para no voltear toda la flota de golpe.\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "283f1f4988fd42c1832fbd592bbd3b30cfa99657": {"t": 1785754583.2516704, "n": 119438, "h": "12a8451e0328ba60", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "2ab5bf6db90f02b00b32c68058cc8cd76359d770": {"t": 1785754587.3281329, "n": 118969, "h": "69057b4959223499", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "9c8e0fea4ad4b1f20cf7b6c3f39b4e6e7333183c": {"t": 1785754590.250117, "n": 118680, "h": "537f6b88f88652c2", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // fleetAgent's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "2078cba69ef7780ff0de6f5ce783f6af6aeb9562": {"t": 1785754592.6649098, "n": 118602, "h": "7ca753a689010bfa", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by poolOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt || body.mcpServers) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (pool/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en poolOperations).\n mcpServers: body.mcpServers,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "18ee2d116d3e18e637dd4fe8174e7246cfff3d5c": {"t": 1785754594.2816536, "n": 118129, "h": "b1f7def74320c941", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.appendSystemPrompt) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string; denikApiKey?: string; appendSystemPrompt?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (pool/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "8a225dc309ca64c0178f76b0736d2d3a2d66a58d": {"t": 1785754597.0501742, "n": 117857, "h": "cba4d1669eb04fcd", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n appendSystemPrompt?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n // Per-message denik org key (pool/Nik): el worker arma el MCP denik scopeado\n // a ese org SOLO para este turno.\n if (body.denikApiKey) payload.denikApiKey = body.denikApiKey;\n // Per-message personalización por-org (capa 3): el worker la APPENDEA a su\n // persona del pool (que a su vez se appendea al preset claude_code). Nunca\n // sobreescribe la base de EasyBits.\n if (body.appendSystemPrompt) payload.appendSystemPrompt = body.appendSystemPrompt;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string; denikApiKey?: string; appendSystemPrompt?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (pool/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "2d2e29efceda205a8f3a53d954d72f412ea379bd": {"t": 1785754598.7894247, "n": 117393, "h": "52f465c3a8b24fa7", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n // Per-message denik org key (pool/Nik): el worker arma el MCP denik scopeado\n // a ese org SOLO para este turno.\n if (body.denikApiKey) payload.denikApiKey = body.denikApiKey;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string; denikApiKey?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (pool/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "b314330e996a7cdd0f13da0c375376c99da6564f": {"t": 1785754600.8007183, "n": 116257, "h": "32f53ef453324f35", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "5889cc4308d76e1f99e77b2f45c66878de3e0f00": {"t": 1785754604.185601, "n": 112708, "h": "74579eaafa71dfa1", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de pool o agente persistente):\n // el router del pool NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "ed48693e2464831d0270e3803efca1bab2bd243b": {"t": 1785754606.2252944, "n": 112144, "h": "9c13ec0db05cb38a", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el pool (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n return callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "bfc84abf15f717ee9255a53d0ea081d2a5e182c7": {"t": 1785754611.446082, "n": 111695, "h": "6751c38b525c75c0", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n if (active >= plan.concurrentSandboxes) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${plan.concurrentSandboxes} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: plan.concurrentSandboxes,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n return callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "131f2711b44043f39a8e77094ed616e7fc250f56": {"t": 1785754614.7171154, "n": 111695, "h": "6ddc32859ff1a9a3", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Explicit resource override (wins over `size`/template default). Used by the\n // pool to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (pool worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n if (active >= plan.concurrentSandboxes) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${plan.concurrentSandboxes} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: plan.concurrentSandboxes,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n return callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Esta máquina no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Esta máquina no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (pool worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_API_KEY = la llave que le da al worker las tools de EasyBits vía MCP\n // (el runtime arma el server `easybits` cuando esta var existe). Del vault del\n // dueño; si no hay, se mintea una persistente con sus scopes (como ghosty-gc).\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `claude-worker-${params.name || \"pool\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "3ca5392707c8568687053b59d775a40237d30a35": {"t": 1785754618.9187005, "n": 110328, "h": "9bab0c2e50f1e1de", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = SIZE_RESOURCES[size];\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n if (active >= plan.concurrentSandboxes) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${plan.concurrentSandboxes} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: plan.concurrentSandboxes,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n return callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Esta máquina no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Esta máquina no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\" ||\n // claude-worker wires the easybits MCP (upload_file, docs, DBs) over stdio\n // when EASYBITS_API_KEY is present; pull it from the owner's vault so the\n // pool's workers get the catalog without persona.env having to carry it.\n params.template === \"claude-worker\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "6214b92c401d29937eb735e07adf1b58f18db2f5": {"t": 1785754624.7083108, "n": 110040, "h": "49e09cd58746447e", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n env?: Record<string, string>;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = SIZE_RESOURCES[size];\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n if (active >= plan.concurrentSandboxes) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${plan.concurrentSandboxes} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: plan.concurrentSandboxes,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n return callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the pool (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Esta máquina no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Esta máquina no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por pool.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty connect to the easybits MCP\n // (dynamic tools) when they have the user's EasyBits key. Pull it from the vault; without\n // it the agent still runs on local tools. Caller env wins. (rust-ghosty's wrapper writes\n // ~/.deepseek/mcp.json from EASYBITS_API_KEY; cagent-ghosty's start script writes the MCP\n // toolset into /data/agent.yaml from the same key.)\n if (\n (params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"cagent-ghosty\") &&\n !env.EASYBITS_API_KEY\n ) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.EASYBITS_API_KEY) {\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n } else {\n const minted = await createApiKey(ctx.user.id, {\n name: `ghosty-gc-${params.name || \"agent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n }\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Owner-only — looks up an agent by id and confirms ownership.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.agent.findMany({\n where: { ownerId: ctx.user.id },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n ctx.user.id\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: { content: string; sessionId?: string }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}, "e80bda5a39dc5f72d884cfc7cc8f267173855f8a": {"t": 1786068277.7810912, "n": 134981, "h": "5f8b3c7a1f9403a3", "c": "import { randomBytes } from \"node:crypto\";\nimport { promises as dns } from \"node:dns\";\nimport { db } from \"../db\";\nimport type { AuthContext } from \"../apiAuth\";\nimport { requireScope } from \"../apiAuth\";\nimport { getSecretValue } from \"./secretOperations\";\nimport { createApiKey } from \"../iam\";\nimport { can, delegatedAccountIds, SCOPES } from \"../delegation\";\nimport { mintComputeKey, revokeSandboxKeys, COMPUTE_BASE_URL } from \"../compute/gateway\";\nimport type { SandboxTemplate } from \"../sandbox/schemas\";\nimport { PLANS, getUserPlan } from \"../../lib/plans\";\nimport type { SandboxSessionKind } from \"./sandboxSessions\";\nimport {\n openSandboxSession,\n markSandboxSuspended,\n markSandboxResumed,\n closeSandboxSession,\n} from \"./sandboxSessions\";\n\nconst HOST_URL = process.env.SANDBOX_HOST_URL || \"\";\nconst HOST_TOKEN = process.env.SANDBOX_HOST_TOKEN || \"\";\n// Break-glass operator token: lets deliberate platform flows (releasePermanent,\n// billing rollback) destroy/suspend a Protected box. NEVER exposed to agents or\n// user API keys — the MCP-reachable destroy path omits it, so a leaked WRITE key\n// or runaway agent cannot kill a paying client's permanent VM. Server env only.\nconst OPERATOR_TOKEN = process.env.SANDBOX_HOST_OPERATOR_TOKEN || \"\";\n// Default cuando el caller no pasa timeoutSeconds. 30 min: cómodo para una\n// sesión interactiva por MCP/SDK sin morir a media tarea; sigue auto-limpiando\n// cajas olvidadas. Quien quiera más extiende hasta su ventana de plan (Tera 24h).\nconst DEFAULT_TIMEOUT_S = 1800;\n\n// Templates de EJECUCIÓN DE CÓDIGO donde eb.compute auto-inyecta una\n// ComputeKey (OPENAI_API_KEY) — el código del usuario llama al LLM managed\n// sin su propia key. Excluye chat runtimes (chat-openai/ghostyclaw/openclaw)\n// que tienen su propia auth de provider. Las cajas base (python/ubuntu/etc)\n// van por createSandbox (raw) — wiring pendiente, ver memoria.\nconst COMPUTE_AUTOINJECT_TEMPLATES = new Set<SandboxTemplate>([\n \"node-agent\",\n \"claude-code\",\n]);\n\n// Templates que sirven un escritorio noVNC en :6080. createAgent auto-expone ese\n// puerto al terminar el bring-up y guarda la URL pública en Agent.desktopUrl, así\n// el panel de ghosty-studio (y cualquier cliente MCP/SDK) puede embeber el desktop.\nconst DESKTOP_TEMPLATES = new Set<SandboxTemplate>([\n \"desktop-ghosty\",\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// Templates que además sirven una terminal web (ttyd→tmux en :7681). createAgent\n// expone ese puerto y guarda Agent.terminalUrl con la credencial basic-auth\n// (operator:<embedToken>) inline — el shell NO queda abierto sólo por el subdominio.\nconst TERMINAL_TEMPLATES = new Set<SandboxTemplate>([\n \"computer-ghosty\",\n \"computer-ghosty-gemini\",\n]);\n\n// SandboxTemplate se deriva de SANDBOX_TEMPLATES en ../sandbox/schemas\n// (fuente única). Se re-exporta para mantener compatibilidad con quien lo\n// importa desde aquí (p.ej. routes/api/v2/agents.ts).\nexport type { SandboxTemplate };\n\nexport interface AgentSpec {\n port?: number;\n protocol?: \"http\" | \"sse\" | \"ws\" | \"cli-stdin\" | \"acp\";\n health_path?: string;\n health_command?: string;\n unit?: string;\n env_file?: string;\n message_path?: string;\n}\n\nexport interface EnvSpec {\n name: string;\n label?: string;\n secret?: boolean;\n required?: boolean;\n default?: string;\n}\n\nexport interface ConnectionMode {\n id: string;\n label: string;\n}\n\nexport interface TemplateInfo {\n name: string;\n display?: string;\n description?: string;\n tier?: \"chat-embed\" | \"coding-harness\" | \"autonomous\" | \"custom\" | \"base\";\n image: string;\n memoryMb: number;\n vcpus: number;\n agent?: AgentSpec;\n requiredEnv?: EnvSpec[];\n connectionModes?: ConnectionMode[];\n}\n\nexport interface SandboxRecord {\n sandboxId: string;\n template: SandboxTemplate;\n status: \"starting\" | \"running\" | \"suspended\" | \"stopped\" | \"error\" | \"lost\";\n createdAt: string;\n expiresAt: string;\n ownerId: string;\n metadata?: Record<string, string>;\n // Cajas host-managed con sleep/wake (team boxes tipo ghosty-chat): el host\n // suspende al vencer expiresAt (idle) y DESTRUYE al vencer hardExpiresAt (cap\n // absoluto). Ambos vienen en el JSON del host; los usa el HUD para el reloj de\n // muerte de una caja dormida. Zero-time (\"0001-…\") = sin cap duro.\n suspendOnIdle?: boolean;\n hardExpiresAt?: string;\n}\n\nexport interface ExecResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n durationMs: number;\n truncated?: boolean;\n}\n\nexport interface FileEntry {\n name: string;\n path: string;\n size: number;\n isDir: boolean;\n modifiedAt: string;\n}\n\nfunction ensureConfigured(): void {\n if (!HOST_URL || !HOST_TOKEN) {\n throw new Error(\n \"Sandbox host not configured. Set SANDBOX_HOST_URL and SANDBOX_HOST_TOKEN env vars.\"\n );\n }\n}\n\n// openAgentMessageStream: open a streaming connection to the runtime via\n// the sandbox-host proxy. Returns the raw ReadableStream — caller decides\n// how to parse (chat-runtime simple SSE vs Goose ACP JSON-RPC).\n//\n// Two modes via overloads:\n// - Standard {content, sessionId} body → chat-runtime path \"/message\"\n// - Override with rawBody + path + headers → Goose \"/acp\" JSON-RPC, etc.\nexport interface AgentMessageStream {\n stream: ReadableStream<Uint8Array>;\n headers: Headers;\n}\n\nexport async function openAgentMessageStream(\n sandboxId: string,\n ownerId: string,\n body: {\n content?: string;\n sessionId?: string;\n denikApiKey?: string;\n /** BYOK: credencial del MOTOR de este tenant para ESTE turno. Ver resolveEngineApiKey. */\n engineApiKey?: string;\n appendSystemPrompt?: string;\n // Per-turn EXTRA MCP servers (name→serverDef) for the worker to merge over\n // its baked builtins. Resolved per-group by fleetAgentOperations.resolveGroupMcpServers.\n mcpServers?: Record<string, unknown>;\n // Per-turn builtins to REMOVE (e.g. [\"easybits\"]) — the worker deletes these\n // from its merged MCP set. Resolved per-group by resolveDisabledBuiltins.\n disabledBuiltins?: string[];\n // Per-turn EasyBits tool group (?tools=) — per-number capabilities. The worker\n // applies it to its easybits MCP, overriding the spawn-env default.\n toolGroup?: string;\n // Per-turn env vars injected into the worker's shell (code-mode capabilities).\n // Consumed by the worker template; forwarded verbatim via rawBody.\n turnEnv?: Record<string, string>;\n port?: number;\n path?: string;\n rawBody?: unknown;\n headers?: Record<string, string>;\n method?: string;\n }\n): Promise<AgentMessageStream> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}/v1/sandbox/${sandboxId}/agent/message`;\n const payload: Record<string, unknown> = { port: body.port };\n if (body.path) payload.path = body.path;\n if (body.method) payload.method = body.method;\n if (body.headers) payload.headers = body.headers;\n if (body.rawBody !== undefined) {\n payload.rawBody = body.rawBody;\n } else if (body.denikApiKey || body.engineApiKey || body.appendSystemPrompt || body.mcpServers || body.disabledBuiltins?.length || body.toolGroup || body.turnEnv) {\n // ⚠️ El host (sandbox-host /v1/sandbox/:id/agent/message) SOLO reenvía\n // {content, sessionId} de los campos top-level — descarta cualquier extra.\n // Para que campos del worker (denikApiKey, appendSystemPrompt) lleguen al\n // microVM hay que mandarlos como `rawBody` (passthrough verbatim que el host\n // sí reenvía tal cual). Sin esto el worker nunca recibe la key → sin MCP denik.\n payload.rawBody = {\n content: body.content,\n ...(body.sessionId ? { sessionId: body.sessionId } : {}),\n ...(body.denikApiKey ? { denikApiKey: body.denikApiKey } : {}),\n ...(body.engineApiKey ? { engineApiKey: body.engineApiKey } : {}),\n ...(body.appendSystemPrompt ? { appendSystemPrompt: body.appendSystemPrompt } : {}),\n ...(body.mcpServers ? { mcpServers: body.mcpServers } : {}),\n ...(body.disabledBuiltins?.length ? { disabledBuiltins: body.disabledBuiltins } : {}),\n ...(body.toolGroup ? { toolGroup: body.toolGroup } : {}),\n ...(body.turnEnv && Object.keys(body.turnEnv).length ? { turnEnv: body.turnEnv } : {}),\n };\n } else {\n payload.content = body.content;\n // Claude CLI exige session IDs en formato UUID — pasaba \"default\" como\n // fallback rompía `claude --resume default`. Omitimos el campo cuando\n // no hay sessionId real para que el daemon (o el agent-runner) genere\n // un UUID fresh en lugar de heredar este literal.\n if (body.sessionId) payload.sessionId = body.sessionId;\n }\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n \"X-Easybits-Owner\": ownerId,\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => \"\");\n throw new Error(\n `sandbox host POST agent/message → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return { stream: res.body, headers: res.headers };\n}\n\n// ─────────────── ACP (Agent Client Protocol) helpers ───────────────\n//\n// Goose serve exposes JSON-RPC 2.0 over SSE on /acp. These helpers do the\n// minimal wire-protocol work we need: initialize handshake, session/new,\n// session/prompt, and a TransformStream that converts ACP notifications\n// to the simple {type:\"chunk\"|\"done\"|\"error\"} SSE events the embed widget\n// already consumes for chat-runtime.\n\nconst ACP_HEADERS = { Accept: \"application/json, text/event-stream\" };\n\nfunction parseSSEDataLines(chunk: string): unknown[] {\n // Returns array of parsed JSON values from \"data: ...\" lines in chunk.\n const out: unknown[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"data: \")) {\n try {\n out.push(JSON.parse(line.slice(6)));\n } catch {\n // ignore non-JSON\n }\n }\n }\n return out;\n}\n\n// Reads the response from an ACP JSON-RPC POST and returns the FIRST\n// `result` payload matching `id`. Used for synchronous handshake calls\n// (initialize, session/new) where we don't care about streaming.\nasync function readAcpRpcResult(\n stream: ReadableStream<Uint8Array>,\n id: number\n): Promise<{ result?: unknown; error?: { code: number; message: string } }> {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n id?: number;\n result?: unknown;\n error?: { code: number; message: string };\n }>) {\n if (evt.id === id && (evt.result !== undefined || evt.error !== undefined)) {\n await reader.cancel();\n return { result: evt.result, error: evt.error };\n }\n }\n }\n }\n return { error: { code: -1, message: \"stream closed without matching result\" } };\n}\n\n// runAcpHandshake: initialize + session/new. Returns both:\n// - acpTransportSessionId: header from initialize, must be sent on every\n// subsequent ACP call (Goose serve enforces this).\n// - acpSessionId: returned from session/new, identifies the agent session.\n// Called eager during startAgent so the first user message has zero\n// handshake overhead.\nasync function runAcpHandshake(\n sandboxId: string,\n ownerId: string,\n port: number,\n messagePath: string\n): Promise<{ acpTransportSessionId: string; acpSessionId: string }> {\n // 1. initialize\n const init = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: ACP_HEADERS,\n rawBody: {\n jsonrpc: \"2.0\",\n id: 1,\n method: \"initialize\",\n params: { protocolVersion: 1, clientCapabilities: {} },\n },\n });\n const acpTransportSessionId = init.headers.get(\"acp-session-id\") ?? \"\";\n if (!acpTransportSessionId) {\n throw new Error(\"ACP initialize: server did not return Acp-Session-Id header\");\n }\n const initRes = await readAcpRpcResult(init.stream, 1);\n if (initRes.error) {\n throw new Error(`ACP initialize failed: ${initRes.error.message}`);\n }\n\n // 2. session/new — must carry Acp-Session-Id from initialize.\n const sess = await openAgentMessageStream(sandboxId, ownerId, {\n port,\n path: messagePath,\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: 2,\n method: \"session/new\",\n params: { cwd: \"/\", mcpServers: [] },\n },\n });\n const sessRes = await readAcpRpcResult(sess.stream, 2);\n if (sessRes.error) {\n throw new Error(`ACP session/new failed: ${sessRes.error.message}`);\n }\n const acpSessionId = (sessRes.result as { sessionId?: string })?.sessionId;\n if (!acpSessionId) {\n throw new Error(\"ACP session/new returned no sessionId\");\n }\n return { acpTransportSessionId, acpSessionId };\n}\n\n// transformAcpStream: takes an ACP SSE stream of JSON-RPC notifications\n// and emits the simple {type:\"chunk\"|\"done\"|\"error\"} SSE format that the\n// embed widget already understands. The widget stays protocol-agnostic.\nfunction transformAcpStream(\n upstream: ReadableStream<Uint8Array>,\n promptId: number\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n function emit(controller: ReadableStreamDefaultController, evt: unknown) {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n }\n\n function handleEvent(\n controller: ReadableStreamDefaultController,\n raw: string\n ) {\n type AcpContent = { text?: string } | Array<{ text?: string }>;\n for (const evt of parseSSEDataLines(raw) as Array<{\n method?: string;\n params?: { update?: { sessionUpdate?: string; content?: AcpContent } };\n id?: number;\n result?: unknown;\n error?: { message: string };\n }>) {\n // ACP notification with content chunks. Goose emits `content` as a\n // singular object {type:\"text\", text:\"...\"} but the spec also allows\n // an array — handle both.\n if (evt.method === \"session/update\") {\n const upd = evt.params?.update;\n if (upd?.sessionUpdate === \"agent_message_chunk\" && upd.content) {\n const arr = Array.isArray(upd.content) ? upd.content : [upd.content];\n const text = arr.map((c) => c.text ?? \"\").join(\"\");\n if (text) emit(controller, { type: \"chunk\", value: text });\n }\n continue;\n }\n // Final response to the original prompt id → done\n if (evt.id === promptId) {\n if (evt.error) {\n emit(controller, { type: \"error\", message: evt.error.message });\n } else {\n emit(controller, { type: \"done\" });\n }\n }\n }\n }\n\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n handleEvent(controller, event);\n }\n }\n } catch (e) {\n emit(controller, {\n type: \"error\",\n message: e instanceof Error ? e.message : String(e),\n });\n } finally {\n controller.close();\n }\n },\n });\n}\n\nasync function callHost<T>(\n method: \"GET\" | \"POST\" | \"DELETE\" | \"PATCH\",\n path: string,\n body?: unknown,\n ownerId?: string,\n timeoutMs = 120_000,\n asOperator = false\n): Promise<T> {\n ensureConfigured();\n const url = `${HOST_URL.replace(/\\/$/, \"\")}${path}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${HOST_TOKEN}`,\n \"Content-Type\": \"application/json\",\n };\n if (ownerId) headers[\"X-Easybits-Owner\"] = ownerId;\n // Operator break-glass: only deliberate platform flows pass asOperator. The\n // header is the sole way to destroy/suspend a Protected box on the host.\n if (asOperator && OPERATOR_TOKEN) headers[\"X-Operator-Token\"] = OPERATOR_TOKEN;\n\n // Solo GET es idempotente → reintentar ante red/5xx. POST/DELETE no se\n // reintentan para evitar doble-spawn de VMs o doble-destroy.\n const maxAttempts = method === \"GET\" ? 3 : 1;\n let lastErr: unknown;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const res = await fetch(url, {\n method,\n headers,\n body: body ? JSON.stringify(body) : undefined,\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => \"\");\n // Reintenta solo en 502/503/504 (host transitoriamente caído).\n if (\n attempt < maxAttempts &&\n (res.status === 502 || res.status === 503 || res.status === 504)\n ) {\n lastErr = new Error(\n `sandbox host ${method} ${path} → ${res.status}`\n );\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw new Error(\n `sandbox host ${method} ${path} → ${res.status}: ${text.slice(0, 500)}`\n );\n }\n return (await res.json()) as T;\n } catch (e) {\n const isTimeout = e instanceof Error && e.name === \"TimeoutError\";\n const wrapped = isTimeout\n ? new Error(`sandbox host ${method} ${path} → timeout after ${timeoutMs}ms`)\n : e;\n // Reintenta errores de red/timeout solo en GET.\n if (attempt < maxAttempts && (isTimeout || e instanceof TypeError)) {\n lastErr = wrapped;\n await new Promise((r) => setTimeout(r, attempt === 1 ? 300 : 900));\n continue;\n }\n throw wrapped;\n }\n }\n throw lastErr;\n}\n\n// Resolve the OWNER id to send to the host for a box-addressed op, AND authorize\n// the caller in one shot (micro-IAM, see ../delegation):\n// - no db.sandbox row → ephemeral box → owner is the caller (today's behavior);\n// - caller IS the owner → owner;\n// - caller has a \"machines\" delegation over the owner's account → owner;\n// - otherwise → 404 (not yours, not delegated → never reaches the host).\n// The host is owner-scoped (X-Easybits-Owner), so a delegate MUST send the\n// owner's id or the box is invisible — this is the single place that decides it.\nexport async function effectiveOwnerId(ctx: AuthContext, sandboxId: string): Promise<string> {\n // Unified surface: a box is owned via db.sandbox (permanent machine) OR\n // db.agent (autonomous agent). Resolve the owner from whichever tracks it, so\n // delegation works the same whether the box is a machine or an agent.\n let ownerId = (\n await db.sandbox.findUnique({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n if (!ownerId) {\n ownerId = (\n await db.agent.findFirst({ where: { sandboxId }, select: { ownerId: true } })\n )?.ownerId;\n }\n if (!ownerId) return ctx.user.id; // not tracked → ephemeral, caller owns it\n if (ownerId === ctx.user.id) return ownerId; // owner\n if (await can(ctx, ownerId, SCOPES.MACHINES)) return ownerId; // delegated\n throw new Response(\n JSON.stringify({ error: \"SandboxNotFound\", message: \"Sandbox no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n\n// Clase de tamaño → recursos crudos que entiende el host. \"s\" = default del\n// template (no enviamos overrides). El host clampa por seguridad (1-8 vCPU,\n// ≤8192 MB, ≤24576 MB disco). diskMb adjunta un volumen ext4 en /app.\nconst SIZE_RESOURCES: Record<\n \"s\" | \"m\" | \"l\" | \"xl\",\n { vcpus?: number; memoryMb?: number; diskMb?: number }\n> = {\n s: {},\n m: { vcpus: 2, memoryMb: 2048, diskMb: 4096 },\n l: { vcpus: 4, memoryMb: 4096, diskMb: 12288 },\n xl: { vcpus: 8, memoryMb: 8192, diskMb: 24576 },\n};\nconst SIZE_ORDER = [\"s\", \"m\", \"l\", \"xl\"] as const;\n\nexport async function createSandbox(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n timeoutSeconds?: number;\n name?: string;\n metadata?: Record<string, string>;\n persistent?: boolean;\n size?: \"s\" | \"m\" | \"l\" | \"xl\";\n // Sleep/wake: suspender al idle (snapshot) en vez de destruir; destruir a\n // hardTtlSeconds. Reenviados verbatim al host (/v1/sandbox).\n suspendOnIdle?: boolean;\n hardTtlSeconds?: number;\n // Explicit resource override (wins over `size`/template default). Used by the\n // fleetAgent to size workers per channel (e.g. 512MB tiny VMs). Firecracker sets RAM\n // at boot — not hot-resizable; to \"grow\" you spawn a bigger/another VM.\n memoryMb?: number;\n vcpus?: number;\n env?: Record<string, string>;\n // Clasificación para la telemetría de ciclo de vida (SandboxSession). NO se\n // reenvía al host: solo etiqueta el intervalo para poder desglosar el uso por\n // tipo de caja. Lo pone el caller porque el template no basta (claude-worker\n // es worker de flota Y agente embed standalone).\n kind?: SandboxSessionKind;\n }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n\n // Gate de tamaño por plan (aplica también a persistentes — una VM grande\n // cuesta independientemente del reaper). \"s\" siempre permitido.\n const size = params.size ?? \"s\";\n if (SIZE_ORDER.indexOf(size) > SIZE_ORDER.indexOf(plan.maxSandboxSize)) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxSizeExceeded\",\n message: `Tu plan permite VMs hasta tamaño \"${plan.maxSandboxSize}\". Sube de plan para usar \"${size}\".`,\n requested: size,\n max: plan.maxSandboxSize,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const resources = { ...SIZE_RESOURCES[size] };\n // Explicit override (fleetAgent worker sizing). Sent to the host, which honors\n // memoryMb/vcpus over the template default.\n if (params.memoryMb) resources.memoryMb = params.memoryMb;\n if (params.vcpus) resources.vcpus = params.vcpus;\n // Templates persistentes (agentes de marca) opt-in al flag persistent → el host\n // les salta el reaper. Son deliberados/pagados, NO sandboxes efímeras: por eso\n // NO cuentan contra el cap anti-runaway ni contra el TTL por plan.\n // `params.persistent` permite a un caller (p.ej. Ghosty Launch) pedir una VM\n // always-on para hostear una app web sobre un template genérico (node/bun/etc).\n const persistent =\n params.persistent === true ||\n params.template === \"ghostyclaw\" ||\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"cagent-ghosty\";\n\n // Anti-runaway SOLO para efímeras: tope de cajas activas concurrentes por plan\n // (hace real el \"N simultáneos\" de plans.ts; corta loops de sandbox_create).\n // fail-open: si listSandboxes falla o no devuelve array, NO bloqueamos el spawn.\n if (!persistent) {\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n // Budget = plan.concurrentSandboxes + add-ons reservados. MISMO denominador\n // que el fleetAgent (spawnVm) y el HUD (\"X/N sandboxes\") — sin esto, comprar add-ons\n // no subía este límite (quedaba en 3 aunque el HUD dijera 5).\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({ machines: 0, agents: 0 }));\n const sandboxBudget = plan.concurrentSandboxes + reserved.machines;\n if (active >= sandboxBudget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${sandboxBudget} cajas activas alcanzado. Suspende o borra una, o sube de plan.`,\n active,\n max: sandboxBudget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (params.timeoutSeconds && params.timeoutSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: params.timeoutSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n }\n const timeout = Math.min(\n Math.max(params.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 30),\n plan.maxSandboxTtlSeconds\n );\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n timeoutSeconds: timeout,\n name: params.name,\n metadata: params.metadata,\n persistent,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n suspendOnIdle: params.suspendOnIdle,\n hardTtlSeconds: params.hardTtlSeconds,\n env: params.env,\n ...resources,\n },\n ctx.user.id\n );\n // Telemetría de ciclo de vida: abre el intervalo de esta caja. Fire-and-forget\n // — openSandboxSession se auto-cachea, nunca puede tumbar un spawn.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: params.kind ?? \"sandbox\",\n template: params.template,\n memMb: resources.memoryMb,\n vcpus: resources.vcpus,\n persistent,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Lower-level create for ALWAYS-ON machines (hosting product). Bypasses the\n// per-plan size/TTL/concurrency gates — machines have their own gate (paid\n// plan + Stripe item, enforced in machineOperations.createMachine) — and\n// passes explicit {vcpus, memoryMb, diskMb} from HOSTING_CATALOG instead of\n// the s/m/l/xl enum. Always persistent (no reaper). `cpuMode` is forwarded to\n// the host for the reserved-CPU cgroup floor (host honors it when supported;\n// ignored for shared).\nexport async function createSandboxRaw(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n name?: string;\n metadata?: Record<string, string>;\n vcpus: number;\n memoryMb: number;\n diskMb: number;\n cpuMode?: \"shared\" | \"reserved\";\n // protected: lock against destroy/suspend (host requires X-Operator-Token).\n // Defaults OFF at create so the caller's billing-failure rollback (a normal\n // destroy) still works; callers lock the box via persistSandbox AFTER\n // billing is attached. See createPermanent.\n protected?: boolean;\n }\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n \"/v1/sandbox\",\n {\n template: params.template,\n name: params.name,\n metadata: params.metadata,\n persistent: true,\n protected: params.protected ?? false,\n vcpus: params.vcpus,\n memoryMb: params.memoryMb,\n diskMb: params.diskMb,\n cpuMode: params.cpuMode ?? \"shared\",\n },\n ctx.user.id\n );\n // Máquina always-on (hosting): mismo intervalo, kind \"machine\" para poder\n // separar el consumo facturado del efímero.\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: rec.sandboxId,\n kind: \"machine\",\n template: params.template,\n memMb: params.memoryMb,\n vcpus: params.vcpus,\n persistent: true,\n startedAt: rec.createdAt ? new Date(rec.createdAt) : undefined,\n });\n return rec;\n}\n\n// Promote an existing (ephemeral) sandbox to always-on: clears the host reaper\n// so the VM survives past its TTL. This is the host capability that makes\n// \"spin up, then keep it permanent\" safe — without it, billing a flat machine\n// over an ephemeral box would let the reaper destroy a VM we're charging for.\n// Returns the updated record. Host endpoint: POST /v1/sandbox/:id/persist.\nexport async function persistSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { protected?: boolean }\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/persist`,\n { protected: opts?.protected ?? true },\n ctx.user.id\n );\n}\n\n// Server-to-server suspend (no AuthContext) — used by the Stripe webhook to\n// pause a delinquent owner's machines. Owner-scoped on the host via the header.\nexport async function suspendSandboxRaw(\n ownerId: string,\n sandboxId: string\n): Promise<SandboxRecord> {\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n ownerId\n );\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Server-to-server exec (no AuthContext) — used by trusted internal jobs such\n// as the backup cron. Owner-scoped on the host via the header. Do NOT expose\n// to user/agent surfaces (no scope check); only call from server-side crons.\nexport async function execSandboxRaw(\n ownerId: string,\n sandboxId: string,\n command: string,\n timeoutSeconds = 120\n): Promise<ExecResult> {\n const t = Math.min(timeoutSeconds, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n { command, timeoutSeconds: t },\n ownerId,\n (t + 15) * 1000\n );\n}\n\nexport async function listSandboxes(ctx: AuthContext): Promise<SandboxRecord[]> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord[]>(\"GET\", `/v1/sandbox?owner=${ctx.user.id}`, undefined, ctx.user.id);\n}\n\nexport async function getSandbox(ctx: AuthContext, sandboxId: string): Promise<SandboxRecord> {\n requireScope(ctx, \"READ\");\n return callHost<SandboxRecord>(\"GET\", `/v1/sandbox/${sandboxId}`, undefined, await effectiveOwnerId(ctx, sandboxId));\n}\n\nexport interface WaitUntilRunningOptions {\n timeoutMs?: number;\n intervalMs?: number;\n}\n\nexport async function waitUntilRunning(\n ctx: AuthContext,\n sandboxId: string,\n options: WaitUntilRunningOptions = {}\n): Promise<SandboxRecord> {\n const { timeoutMs = 30_000, intervalMs = 500 } = options;\n const deadline = Date.now() + timeoutMs;\n let last: SandboxRecord | undefined;\n while (Date.now() <= deadline) {\n last = await getSandbox(ctx, sandboxId);\n if (last.status === \"running\") return last;\n if (last.status === \"error\") {\n const reason = last.metadata?.error ?? \"unknown error\";\n throw new Error(`sandbox ${sandboxId} failed to start: ${reason}`);\n }\n if (last.status === \"stopped\") {\n throw new Error(`sandbox ${sandboxId} is stopped`);\n }\n // A suspended box never transitions to running on its own — only resumeSandbox\n // wakes it. Polling here would burn the whole timeout. Caller must resume first.\n if (last.status === \"suspended\") {\n throw new Error(`sandbox ${sandboxId} is suspended`);\n }\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(\n `sandbox ${sandboxId} not running after ${timeoutMs}ms (last status=${last?.status ?? \"unknown\"})`\n );\n}\n\nexport async function destroySandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { asOperator?: boolean }\n): Promise<{ ok: true }> {\n // WRITE (no DELETE): destruir una caja efímera es simétrico con crearla\n // (sandbox_create también es WRITE), y el host es owner-scoped — un agente\n // solo puede matar SUS propias cajas. Esto deja que un agente con token WRITE\n // (embed agt_* o key WRITE) administre sus N cajas concurrentes a placer:\n // sandbox_destroy + agent_run_destroy (que delega aquí) ya no dan 403.\n // El destroy de agentes PERSISTENTES sigue gateado con DELETE en destroyAgent().\n requireScope(ctx, \"WRITE\");\n // eb.compute: revocar las virtual keys del sandbox (fire-and-forget).\n void revokeSandboxKeys(sandboxId);\n // asOperator: solo flujos deliberados de plataforma (releasePermanent,\n // rollback de billing) pueden matar una caja Protected. El path MCP/agente\n // NUNCA pasa asOperator → un agente/leaked key recibe 403 en cajas protegidas.\n const result = await callHost<{ ok: true }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId),\n undefined,\n opts?.asOperator ?? false\n );\n // Propaga el destroy al registro Agent (worker de fleetAgent o agente persistente):\n // el router del fleetAgent NO corre la reconciliación de la UI (getAgent/listAgents),\n // así que sin esto el Agent se queda en \"running\" y routeMessage sigue\n // entregando tráfico a una caja muerta. \"lost\" es el mismo estado terminal que\n // produce probeRealStatus en un 404 — el cold path de pickOrSpawn lo excluye y\n // re-spawnea un worker limpio.\n await db.agent.updateMany({ where: { sandboxId }, data: { status: \"lost\" } }).catch(() => {});\n // Cierra el intervalo de telemetría. DESPUÉS del callHost a propósito: si el\n // destroy fue rechazado (403 en una caja protegida) la caja sigue viva, y una\n // sesión cerrada por error no se repara nunca — el reconciliador solo mira las\n // ABIERTAS. Fire-and-forget como el revokeSandboxKeys de arriba: si esto falla,\n // el reconciliador la cierra en el siguiente barrido.\n void closeSandboxSession(sandboxId, \"destroy\");\n return result;\n}\n\nexport interface FleetStats {\n totalStarted: number;\n running: number;\n /** Sum of MemMB of running/starting VMs the host knows about. */\n memUsedMb: number;\n /** Host-wide RAM cap the box admits (SANDBOX_MAX_TOTAL_MEM_MB / detected). */\n memMaxMb: number;\n}\n\n// Fleet-wide counters from the host (no user scope — server-to-server with the\n// host token). Powers the public \"started sandboxes\" metric.\nexport async function getFleetStats(): Promise<FleetStats> {\n return callHost<FleetStats>(\"GET\", \"/v1/stats\");\n}\n\n// Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on\n// persistent boxes (host returns { persistent, noop }). The remaining-lifetime\n// window is the per-plan max (Mega 4h, Tera 24h), enforced by the host via the\n// maxTtlSeconds we pass; asking for a bigger window than the plan allows → 403.\nexport async function extendSandbox(\n ctx: AuthContext,\n sandboxId: string,\n extendSeconds?: number\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const plan = PLANS[getUserPlan(ctx.user)];\n if (extendSeconds && extendSeconds > plan.maxSandboxTtlSeconds) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxTtlExceeded\",\n message: `Tu plan permite sesiones de hasta ${Math.round(plan.maxSandboxTtlSeconds / 3600)}h. Sube a Tera para sesiones de hasta 24h.`,\n requested: extendSeconds,\n max: plan.maxSandboxTtlSeconds,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n return callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300, maxTtlSeconds: plan.maxSandboxTtlSeconds },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Snapshot the sandbox to disk and free its CPU/IP. The TTL is PAUSED while\n// suspended — the host saves the remaining lifetime and restores it on resume,\n// so no extendSandbox call is needed afterward. Resume with resumeSandbox.\nexport async function suspendSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/suspend`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n // Sella el segmento RUNNING. Va por sandboxId (la sesión abierta ya sabe de\n // quién es), así no añadimos ninguna resolución de owner al path caliente.\n void markSandboxSuspended(sandboxId);\n return rec;\n}\n\n// Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes).\n// The host restores the lifetime that remained at suspend time and re-arms the\n// auto-destroy timer, returning the record with the refreshed expiresAt.\nexport async function resumeSandbox(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxRecord> {\n requireScope(ctx, \"WRITE\");\n const rec = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/resume`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n void markSandboxResumed(sandboxId);\n return rec;\n}\n\n// ─────────────── snapshot + fork (copy-on-write clone) ───────────────\n\n// A named, persisted image captured from a running box. Mirrors the host's\n// SnapshotMeta. The image can be forked into N children (see forkSandbox).\nexport interface SnapshotRecord {\n snapshotId: string;\n name?: string;\n sourceId: string;\n ownerId: string;\n template: string;\n vcpus: number;\n memMb: number;\n cpuMode?: string;\n volumes?: { name: string; mountPath: string }[];\n hasMem: boolean;\n sizeBytes: number;\n createdAt: string;\n}\n\n// Authorize a snapshot-addressed op AND resolve its owner in one shot — the\n// snapshot analogue of effectiveOwnerId, keyed by snapshotId via the\n// SandboxSnapshot row (owner / MACHINES-delegate / else 404).\nexport async function effectiveSnapshotOwnerId(\n ctx: AuthContext,\n snapshotId: string\n): Promise<string> {\n const row = await db.sandboxSnapshot.findUnique({\n where: { snapshotId },\n select: { ownerId: true },\n });\n if (!row) return ctx.user.id; // untracked → caller owns it\n if (row.ownerId === ctx.user.id) return row.ownerId;\n if (await can(ctx, row.ownerId, SCOPES.MACHINES)) return row.ownerId;\n throw new Response(\n JSON.stringify({ error: \"SnapshotNotFound\", message: \"Snapshot no encontrado.\" }),\n { status: 404, headers: { \"content-type\": \"application/json\" } }\n );\n}\n\n// Capture a named image of a RUNNING sandbox without tearing it down (pause →\n// snapshot → freeze disks → resume). The source box keeps running. Records a\n// SandboxSnapshot row for the owner's catalog; the host holds the real files.\nexport async function snapshotSandbox(\n ctx: AuthContext,\n sandboxId: string,\n opts?: { name?: string }\n): Promise<SnapshotRecord> {\n requireScope(ctx, \"WRITE\");\n const ownerId = await effectiveOwnerId(ctx, sandboxId);\n const meta = await callHost<SnapshotRecord>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/snapshot`,\n { name: opts?.name },\n ownerId\n );\n const src = await db.sandbox\n .findUnique({ where: { sandboxId }, select: { tier: true } })\n .catch(() => null);\n await db.sandboxSnapshot\n .upsert({\n where: { snapshotId: meta.snapshotId },\n create: {\n ownerId,\n snapshotId: meta.snapshotId,\n sourceSandboxId: sandboxId,\n name: opts?.name,\n template: meta.template,\n tier: src?.tier,\n vcpus: meta.vcpus,\n memMb: meta.memMb,\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n hasMem: meta.hasMem ?? true,\n },\n update: {\n sizeBytes: BigInt(Math.round(meta.sizeBytes ?? 0)),\n name: opts?.name,\n },\n })\n .catch(() => {});\n return meta;\n}\n\n// List the caller's snapshots (owner catalog, from SandboxSnapshot rows).\nexport async function listSnapshots(ctx: AuthContext): Promise<SnapshotRecord[]> {\n requireScope(ctx, \"READ\");\n const rows = await db.sandboxSnapshot.findMany({\n where: { ownerId: ctx.user.id, status: \"available\" },\n orderBy: { createdAt: \"desc\" },\n });\n return rows.map((r) => ({\n snapshotId: r.snapshotId,\n name: r.name ?? undefined,\n sourceId: r.sourceSandboxId,\n ownerId: r.ownerId,\n template: r.template ?? \"\",\n vcpus: r.vcpus ?? 0,\n memMb: r.memMb ?? 0,\n hasMem: r.hasMem,\n sizeBytes: Number(r.sizeBytes),\n createdAt: r.createdAt.toISOString(),\n }));\n}\n\n// Delete a snapshot: remove the host files + the catalog row. Owner-scoped.\nexport async function deleteSnapshot(\n ctx: AuthContext,\n snapshotId: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"DELETE\");\n const ownerId = await effectiveSnapshotOwnerId(ctx, snapshotId);\n const res = await callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/snapshot/${snapshotId}`,\n undefined,\n ownerId\n );\n await db.sandboxSnapshot.deleteMany({ where: { snapshotId } }).catch(() => {});\n return res;\n}\n\n// Fork N copy-on-write children from a snapshot (`snapshotId`) OR snapshot-then-\n// fork a live box (`sandboxId`, Morph-style branch). Children are ephemeral boxes\n// booted with fresh IPs (collision-free); the whole fan-out counts against the\n// SAME per-plan concurrency budget as createSandbox.\nexport async function forkSandbox(\n ctx: AuthContext,\n params: {\n snapshotId?: string;\n sandboxId?: string;\n count?: number;\n name?: string;\n metadata?: Record<string, string>;\n timeoutSeconds?: number;\n }\n): Promise<SandboxRecord[]> {\n requireScope(ctx, \"WRITE\");\n if (!params.snapshotId && !params.sandboxId) {\n throw new Response(\n JSON.stringify({ error: \"BadRequest\", message: \"fork requires snapshotId or sandboxId\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const plan = PLANS[getUserPlan(ctx.user)];\n const count = Math.min(Math.max(params.count ?? 1, 1), 16);\n\n // Same anti-runaway budget as createSandbox — a fork of `count` boots `count`\n // concurrent ephemeral boxes. fail-open if listing the fleet fails.\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(ctx.user.id).catch(() => ({\n machines: 0,\n agents: 0,\n }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active + count > budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Fork de ${count} excede tu límite de ${budget} cajas activas (${active} en uso). Suspende/borra algunas o sube de plan.`,\n active,\n requested: count,\n max: budget,\n }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n\n const body = {\n count,\n name: params.name,\n metadata: params.metadata,\n timeoutSeconds: params.timeoutSeconds,\n maxTtlSeconds: plan.maxSandboxTtlSeconds,\n };\n let resp: { snapshotId: string; count: number; children: SandboxRecord[] };\n if (params.snapshotId) {\n const ownerId = await effectiveSnapshotOwnerId(ctx, params.snapshotId);\n resp = await callHost(\n \"POST\",\n `/v1/snapshot/${params.snapshotId}/fork`,\n body,\n ownerId\n );\n } else {\n const ownerId = await effectiveOwnerId(ctx, params.sandboxId!);\n resp = await callHost(\"POST\", `/v1/sandbox/${params.sandboxId}/fork`, body, ownerId);\n }\n const children = resp.children ?? [];\n // fork NO pasa por createSandbox (pega directo al host), así que abre sus\n // propios intervalos o los hijos —cajas reales que consumen RAM— no se medirían.\n for (const c of children) {\n void openSandboxSession({\n ownerId: ctx.user.id,\n sandboxId: c.sandboxId,\n kind: \"fork\",\n template: c.template,\n startedAt: c.createdAt ? new Date(c.createdAt) : undefined,\n });\n }\n return children;\n}\n\n// Poll the ghostyclaw VM's /chat/ready endpoint via exec curl-from-inside.\n// We can't HTTP the VM directly from EasyBits (Fly has no route to the\n// Firecracker subnet), so we exec curl on the VM via sandbox-agent.\n// Returns true when {ready: true}, false on timeout (default 10 min covers\n// worst-case first-boot build of nanoclaw-agent image).\nasync function pollGhostyclawReady(\n ctx: AuthContext,\n sandboxId: string,\n token: string,\n maxAttempts = 60, // 60 * 10s = 10 min\n): Promise<boolean> {\n for (let i = 0; i < maxAttempts; i++) {\n await new Promise((r) => setTimeout(r, 10_000));\n try {\n const result = await execCommand(ctx, sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${token}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 5,\n });\n const parsed = JSON.parse(result.stdout.trim());\n if (parsed?.ready === true) return true;\n } catch {\n // ignore — sandbox-agent or curl may not be ready yet; retry next tick\n }\n }\n return false;\n}\n\nexport async function execCommand(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; timeoutSeconds?: number; env?: Record<string, string> }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec`,\n {\n command: params.command,\n cwd: params.cwd,\n timeoutSeconds,\n env: params.env,\n },\n await effectiveOwnerId(ctx, sandboxId),\n // El host puede tardar hasta timeoutSeconds — dar margen al fetch.\n (timeoutSeconds + 15) * 1000\n );\n}\n\n// ── Adopción de recursos (plataforma → user) ─────────────────────────────────\n// Reasigna el owner de una caja en el HOST (tag de billing/listing). Las cajas\n// efímeras de team NO tienen fila Sandbox (esa es solo para máquinas permanentes\n// facturadas), así que el ownership vive host-side; mover el tag mueve la caja al\n// cupo del user (el host lista por owner). La caja re-keyea su credencial operativa\n// aparte (ver el endpoint admin/adopt-team). Owner del header = el nuevo dueño.\nexport async function reassignSandboxOwnerHost(sandboxId: string, newOwnerId: string): Promise<void> {\n await callHost(\"PATCH\", `/v1/sandbox/${sandboxId}/owner`, { newOwner: newOwnerId }, newOwnerId);\n}\n\n// Cuenta las cajas del owner en el host (live + suspended, excluye destroyed) para\n// el gate de cupo de la adopción. El host lista por `?owner=`.\nexport async function countOwnerHostSandboxes(ownerId: string): Promise<number> {\n const list = await callHost<unknown>(\"GET\", `/v1/sandbox?owner=${encodeURIComponent(ownerId)}`, undefined, ownerId).catch(() => []);\n const arr = Array.isArray(list) ? list : Array.isArray((list as { sandboxes?: unknown[] })?.sandboxes) ? (list as { sandboxes: unknown[] }).sandboxes : [];\n return arr.filter((s) => (s as { status?: string })?.status !== \"destroyed\").length;\n}\n\nexport async function runCode(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; lang?: \"python\" | \"node\" | \"bash\"; timeoutSeconds?: number }\n): Promise<ExecResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 60, 600);\n return callHost<ExecResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-code`,\n {\n code: params.code,\n lang: params.lang ?? \"python\",\n timeoutSeconds,\n },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function writeFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; content: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ ok: true; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true; bytes: number }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/write`,\n { path: params.path, content: params.content, encoding: params.encoding ?? \"utf8\" },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function readFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; encoding?: \"utf8\" | \"base64\" }\n): Promise<{ content: string; size: number; encoding: string }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path, encoding: params.encoding ?? \"utf8\" });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/read?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function listFiles(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ entries: FileEntry[] }> {\n requireScope(ctx, \"READ\");\n const qs = new URLSearchParams({ path: params.path });\n return callHost(\n \"GET\",\n `/v1/sandbox/${sandboxId}/files/list?${qs.toString()}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function deleteFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; recursive?: boolean }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/delete`,\n { path: params.path, recursive: params.recursive ?? false },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function moveFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { from: string; to: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/move`,\n { from: params.from, to: params.to },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function mkdir(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string }\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/files/mkdir`,\n { path: params.path },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// Single-quote a string for safe interpolation into a /bin/sh command line.\n// Wraps in '…' and escapes embedded single quotes as '\\'' — the standard trick.\nfunction shQuote(s: string): string {\n return `'${s.replace(/'/g, `'\\\\''`)}'`;\n}\n\n// Surgical in-place edit: read the file, replace oldString → newString, write it\n// back. Pure app-side composition of readFile + writeFile — no host primitive\n// needed. Avoids the shell-escaping hell of patching files through exec. By\n// default replaces ALL occurrences; pass replaceAll:false to touch only the\n// first (and to fail when oldString is ambiguous). Throws if oldString is absent.\nexport async function editFile(\n ctx: AuthContext,\n sandboxId: string,\n params: { path: string; oldString: string; newString: string; replaceAll?: boolean }\n): Promise<{ ok: true; path: string; replacements: number; bytes: number }> {\n requireScope(ctx, \"WRITE\");\n if (params.oldString === params.newString) {\n throw new Error(\"oldString and newString are identical — nothing to change\");\n }\n const current = await readFile(ctx, sandboxId, { path: params.path, encoding: \"utf8\" });\n const text = current.content;\n const occurrences = text.split(params.oldString).length - 1;\n if (occurrences === 0) {\n throw new Error(`oldString not found in ${params.path}`);\n }\n if (occurrences > 1 && params.replaceAll === false) {\n throw new Error(\n `oldString found ${occurrences}× in ${params.path} — pass replaceAll:true to replace all, or give a more specific oldString`\n );\n }\n const replaceAll = params.replaceAll !== false;\n const next = replaceAll\n ? text.split(params.oldString).join(params.newString)\n : text.replace(params.oldString, params.newString);\n const replacements = replaceAll ? occurrences : 1;\n const w = await writeFile(ctx, sandboxId, { path: params.path, content: next, encoding: \"utf8\" });\n return { ok: true, path: params.path, replacements, bytes: w.bytes };\n}\n\n// Read recent journald logs from inside the VM. There is no host /logs route —\n// this runs journalctl over the existing exec primitive, so it's a convenience\n// wrapper, not new host plumbing. READ-scoped (uses execSandboxRaw to bypass the\n// WRITE gate that exec normally enforces, since reading logs is non-mutating).\n// `unit` filters to one systemd service (e.g. \"ghosty-gc-runtime\"); omit for the\n// whole journal. `follow`/streaming is intentionally NOT supported here.\nexport async function readLogs(\n ctx: AuthContext,\n sandboxId: string,\n params: { unit?: string; lines?: number; since?: string; grep?: string }\n): Promise<{ unit: string | null; command: string; output: string; exitCode: number }> {\n requireScope(ctx, \"READ\");\n const lines = Math.min(Math.max(params.lines ?? 200, 1), 5000);\n const parts = [\"journalctl\", \"--no-pager\", \"-n\", String(lines)];\n if (params.unit) parts.push(\"-u\", shQuote(params.unit));\n if (params.since) parts.push(\"--since\", shQuote(params.since));\n let command = parts.join(\" \");\n if (params.grep) command += ` | grep -- ${shQuote(params.grep)}`;\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const res = await execSandboxRaw(owner, sandboxId, command, 30);\n return { unit: params.unit ?? null, command, output: res.stdout || res.stderr, exitCode: res.exitCode };\n}\n\n// Control the service daemon running inside the VM via systemctl, plus a\n// build-then-restart \"rebuild\". Convenience over exec — no host route.\n// - status: systemctl status <unit> (READ) | running-units list if no unit\n// - restart: systemctl restart <unit> (WRITE) — unit required\n// - rebuild: run buildCommand in cwd, then restart <unit> if given (WRITE)\nexport async function runtimeControl(\n ctx: AuthContext,\n sandboxId: string,\n params: { action: \"restart\" | \"rebuild\" | \"status\"; unit?: string; buildCommand?: string; cwd?: string }\n): Promise<{ action: string; unit: string | null; output: string; exitCode: number; buildOutput?: string }> {\n const unit = params.unit ?? null;\n if (params.action === \"status\") {\n requireScope(ctx, \"READ\");\n const owner = await effectiveOwnerId(ctx, sandboxId);\n const command = unit\n ? `systemctl status ${shQuote(unit)} --no-pager -l || systemctl is-active ${shQuote(unit)}`\n : `systemctl list-units --type=service --state=running --no-pager`;\n const res = await execSandboxRaw(owner, sandboxId, command, 20);\n return { action: \"status\", unit, output: res.stdout || res.stderr, exitCode: res.exitCode };\n }\n requireScope(ctx, \"WRITE\");\n if (params.action === \"rebuild\") {\n if (!params.buildCommand) {\n throw new Error(\"buildCommand is required for action 'rebuild' (e.g. 'npm run build')\");\n }\n const prefix = params.cwd ? `cd ${shQuote(params.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.buildCommand}`,\n timeoutSeconds: 600,\n });\n let output = \"\";\n let exitCode = build.exitCode;\n if (unit && build.exitCode === 0) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n output = (r.stdout || r.stderr).trim();\n exitCode = r.exitCode;\n }\n return { action: \"rebuild\", unit, buildOutput: build.stdout || build.stderr, output, exitCode };\n }\n // restart\n if (!unit) {\n throw new Error(\"unit is required for action 'restart' (e.g. 'ghosty-gc-runtime')\");\n }\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(unit)} && systemctl is-active ${shQuote(unit)}`,\n timeoutSeconds: 60,\n });\n return { action: \"restart\", unit, output: (r.stdout || r.stderr).trim(), exitCode: r.exitCode };\n}\n\n// Atomic hotfix: apply N surgical edits, then optionally rebuild and restart the\n// service — in one call. The build is skipped-onto-restart guard: if the build\n// exits non-zero we do NOT restart (you keep the running daemon). Edits are\n// applied first and sequentially; a failing edit aborts before any build/restart.\nexport async function applyPatch(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n edits: Array<{ path: string; oldString: string; newString: string; replaceAll?: boolean }>;\n rebuild?: { buildCommand: string; cwd?: string };\n restart?: { unit: string };\n }\n): Promise<{\n applied: Array<{ path: string; replacements: number }>;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n}> {\n requireScope(ctx, \"WRITE\");\n if (!params.edits?.length) {\n throw new Error(\"edits[] must contain at least one edit\");\n }\n const applied: Array<{ path: string; replacements: number }> = [];\n for (const e of params.edits) {\n const r = await editFile(ctx, sandboxId, e);\n applied.push({ path: e.path, replacements: r.replacements });\n }\n const out: {\n applied: typeof applied;\n buildOutput?: string;\n buildExitCode?: number;\n restarted?: boolean;\n status?: string;\n } = { applied };\n if (params.rebuild) {\n const prefix = params.rebuild.cwd ? `cd ${shQuote(params.rebuild.cwd)} && ` : \"\";\n const build = await execCommand(ctx, sandboxId, {\n command: `${prefix}${params.rebuild.buildCommand}`,\n timeoutSeconds: 600,\n });\n out.buildOutput = build.stdout || build.stderr;\n out.buildExitCode = build.exitCode;\n if (build.exitCode !== 0) {\n out.restarted = false; // build failed — leave the running daemon untouched\n return out;\n }\n }\n if (params.restart) {\n const r = await execCommand(ctx, sandboxId, {\n command: `systemctl restart ${shQuote(params.restart.unit)} && systemctl is-active ${shQuote(params.restart.unit)}`,\n timeoutSeconds: 60,\n });\n out.restarted = r.exitCode === 0;\n out.status = (r.stdout || r.stderr).trim();\n }\n return out;\n}\n\nexport interface ExposedPort {\n url: string;\n host: string;\n port: number;\n}\n\n// Expose a port running inside the sandbox as a public URL\n// (https://sb-<id>-<port>.sandboxes.easybits.cloud). The unguessable sandboxId\n// is the capability. The URL is live while the sandbox is running.\nexport async function exposeSandboxPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number\n): Promise<ExposedPort> {\n requireScope(ctx, \"WRITE\");\n return callHost<ExposedPort>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose`,\n { port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport interface RawForwardResult {\n hostPort: number; // unique host port from the fleetAgent (49000-49999)\n guestPort: number; // the port the service listens on inside the VM\n protocol: \"udp\" | \"tcp\";\n ok: boolean;\n}\n\n// Expose a raw layer-4 (UDP/TCP) port into the sandbox via iptables DNAT.\n// Allocates a unique host port (49000-49999) → guest port, so multiple VMs\n// can use the same internal port (e.g. all livekit-svc VMs on UDP 7882)\n// without collision. Returns hostPort so callers can tell the service which\n// port to announce externally (e.g. LiveKit node_port for ICE candidates).\n// Capability-gated host-side: only templates that declare raw_ports may call\n// this. Torn down with the VM (fc.ClearForwards on destroy/suspend).\nexport async function exposeSandboxRawPort(\n ctx: AuthContext,\n sandboxId: string,\n port: number,\n protocol: \"udp\" | \"tcp\" = \"udp\"\n): Promise<RawForwardResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<RawForwardResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/expose-raw`,\n { port, protocol },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Custom domains (CNAME) ───────────────\n\nexport interface SandboxDomain {\n domain: string;\n port: number;\n}\n\n// DNS targets the customer points their record at. Override via env if the\n// edge IP / branded CNAME host ever change.\nconst SANDBOX_PUBLIC_IP = process.env.SANDBOX_PUBLIC_IP || \"54.38.94.14\";\nconst SANDBOX_CNAME_TARGET =\n process.env.SANDBOX_CNAME_TARGET || \"cname.sandboxes.easybits.cloud\";\n\nexport interface DomainDnsRecord {\n type: \"A\" | \"CNAME\"; // apex → A, subdomain → CNAME\n name: string; // the record name (the domain itself)\n value: string; // IP (A) or branded host (CNAME)\n note: string;\n}\n\nexport interface AddDomainResult {\n domain: string;\n port: number;\n url: string;\n cname: string; // legacy: branded CNAME target (kept for back-compat)\n dns: DomainDnsRecord; // the exact record the customer must create\n}\n\n// A domain is \"apex/root\" if it has only two labels (e.g. fancyfiles.app).\n// Apex can't use a CNAME (DNS rule), so it needs an A record. Subdomains\n// (app.cliente.com) use a CNAME. NOTE: this 2-label heuristic doesn't cover\n// multi-part TLDs like co.uk — refine with the public-suffix list if needed.\nfunction isApex(domain: string): boolean {\n return domain.split(\".\").filter(Boolean).length <= 2;\n}\n\n// Attach a custom domain to a sandbox port. Returns the exact DNS record the\n// customer must create (`dns`): A→IP for apex, CNAME→branded host for a\n// subdomain. Caddy then mints the TLS cert on-demand and serves the sandbox at\n// https://<domain>. One domain → one sandbox.\nexport async function addSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string,\n port: number\n): Promise<AddDomainResult> {\n requireScope(ctx, \"WRITE\");\n domain = domain.toLowerCase().trim();\n const r = await callHost<{ domain: string; port: number; url: string; cname: string }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain, port },\n await effectiveOwnerId(ctx, sandboxId)\n );\n const dns: DomainDnsRecord = isApex(domain)\n ? {\n type: \"A\",\n name: domain,\n value: SANDBOX_PUBLIC_IP,\n note: \"Dominio raíz (apex): crea un registro A. El apex no admite CNAME.\",\n }\n : {\n type: \"CNAME\",\n name: domain,\n value: SANDBOX_CNAME_TARGET,\n note: \"Subdominio: crea un registro CNAME.\",\n };\n return { ...r, dns };\n}\n\n// Detach a custom domain from a sandbox.\nexport async function removeSandboxDomain(\n ctx: AuthContext,\n sandboxId: string,\n domain: string\n): Promise<{ ok: boolean }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: boolean }>(\n \"DELETE\",\n `/v1/sandbox/${sandboxId}/domain`,\n { domain },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// List the custom domains attached to a sandbox (read from its metadata).\nexport async function listSandboxDomains(\n ctx: AuthContext,\n sandboxId: string\n): Promise<SandboxDomain[]> {\n requireScope(ctx, \"READ\");\n const sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${sandboxId}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n const md = sb.metadata ?? {};\n return Object.entries(md)\n .filter(([k]) => k.startsWith(\"domain:\"))\n .map(([k, v]) => ({ domain: k.slice(\"domain:\".length), port: parseInt(v, 10) }));\n}\n\nexport interface DomainVerification {\n domain: string;\n ready: boolean; // true once it resolves AND serves over TLS\n dns: { resolved: boolean; cname?: string[] };\n https: { ok: boolean; status?: number; error?: string };\n hint: string;\n}\n\n// Verify a custom domain after the customer set their CNAME: checks DNS resolves\n// and that https://<domain> serves with a valid cert (which also confirms Caddy\n// minted the on-demand cert and routing works). Lets the agent tell the user\n// \"ya quedó\" vs \"falta el CNAME / espera la propagación\".\nexport async function verifySandboxDomain(\n ctx: AuthContext,\n domain: string\n): Promise<DomainVerification> {\n requireScope(ctx, \"READ\");\n domain = domain.toLowerCase().trim();\n\n let resolved = false;\n let cname: string[] | undefined;\n try {\n resolved = (await dns.resolve4(domain).catch(() => [])).length > 0;\n cname = await dns.resolveCname(domain).catch(() => undefined);\n if (cname?.length) resolved = true;\n } catch {\n /* resolved stays false */\n }\n\n let https: DomainVerification[\"https\"] = { ok: false };\n try {\n const res = await fetch(`https://${domain}/`, {\n signal: AbortSignal.timeout(8000),\n redirect: \"manual\",\n });\n https = { ok: true, status: res.status };\n } catch (e) {\n https = { ok: false, error: e instanceof Error ? e.message : String(e) };\n }\n\n const ready = https.ok;\n const hint = ready\n ? \"El dominio ya sirve correctamente.\"\n : !resolved\n ? \"El DNS no resuelve aún: crea el CNAME hacia cname.sandboxes.easybits.cloud y espera la propagación (puede tardar minutos).\"\n : \"El DNS resuelve pero aún no sirve: el certificado TLS se emite en el primer acceso. Reintenta en ~30s y verifica que el sandbox esté running con el puerto sirviendo.\";\n\n return { domain, ready, dns: { resolved, cname }, https, hint };\n}\n\n// ─────────────── Background processes ───────────────\n\nexport interface BgStartResult {\n execId: string;\n status: string;\n}\n\nexport interface BgStatusResult {\n status: \"running\" | \"exited\";\n exitCode?: number;\n stdout: string;\n stderr: string;\n startedAt: string;\n}\n\n// Start a long-running command in the background (returns immediately with an\n// execId). Poll with execBackgroundStatus; stop with execBackgroundKill. Use for\n// servers/dev processes — pair with exposeSandboxPort to reach them.\nexport async function execBackground(\n ctx: AuthContext,\n sandboxId: string,\n params: { command: string; cwd?: string; env?: Record<string, string> }\n): Promise<BgStartResult> {\n requireScope(ctx, \"WRITE\");\n return callHost<BgStartResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background`,\n { command: params.command, cwd: params.cwd, env: params.env },\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundStatus(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<BgStatusResult> {\n requireScope(ctx, \"READ\");\n return callHost<BgStatusResult>(\n \"GET\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}`,\n undefined,\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\nexport async function execBackgroundKill(\n ctx: AuthContext,\n sandboxId: string,\n execId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/exec/background/${encodeURIComponent(execId)}/kill`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Persistent Jupyter kernel ───────────────\n\nexport interface CellResult {\n type: string; // mime, e.g. \"text/plain\", \"image/png\", \"text/html\"\n data: string;\n}\n\nexport interface RunCellResult {\n stdout: string;\n stderr: string;\n results: CellResult[];\n error?: { ename: string; evalue: string; traceback: string[] } | null;\n}\n\n// Execute a cell in the sandbox's persistent Jupyter kernel (state survives\n// across calls). Requires the `code-interpreter` template. Returns stdout/stderr,\n// rich results (incl. image/png charts), and any error.\nexport async function runCell(\n ctx: AuthContext,\n sandboxId: string,\n params: { code: string; timeoutSeconds?: number }\n): Promise<RunCellResult> {\n requireScope(ctx, \"WRITE\");\n const timeoutSeconds = Math.min(params.timeoutSeconds ?? 120, 600);\n return callHost<RunCellResult>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/run-cell`,\n { code: params.code, timeoutSeconds },\n await effectiveOwnerId(ctx, sandboxId),\n (timeoutSeconds + 15) * 1000\n );\n}\n\nexport async function kernelRestart(\n ctx: AuthContext,\n sandboxId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n return callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/kernel/restart`,\n {},\n await effectiveOwnerId(ctx, sandboxId)\n );\n}\n\n// ─────────────── Templates catalog ───────────────\n\nexport async function listTemplates(\n ctx: AuthContext,\n params: { tier?: TemplateInfo[\"tier\"] } = {}\n): Promise<TemplateInfo[]> {\n requireScope(ctx, \"READ\");\n const qs = params.tier ? `?tier=${encodeURIComponent(params.tier)}` : \"\";\n const out = await callHost<{ templates: TemplateInfo[] }>(\n \"GET\",\n `/v1/templates${qs}`,\n undefined,\n ctx.user.id\n );\n return out.templates;\n}\n\n// Una llamada agente→servicio registrada por el service-mesh (solo metadata).\nexport interface SvcUsageRecord {\n ts: string;\n caller: string;\n owner: string;\n sandbox: string;\n svc: string;\n path: string;\n status: number;\n ms: number;\n cost: string; // X-Cost-Unit, ej \"audioSeconds=6.68\"\n}\n\n// Trazas del service-mesh, owner-scoped (callHost manda X-Easybits-Owner =\n// ctx.user.id, así el host filtra a las llamadas de los agentes del tenant).\nexport async function listSvcUsage(\n ctx: AuthContext,\n params: { svc?: string } = {}\n): Promise<SvcUsageRecord[]> {\n requireScope(ctx, \"READ\");\n const qs = params.svc ? `?svc=${encodeURIComponent(params.svc)}` : \"\";\n const out = await callHost<{ usage: SvcUsageRecord[] }>(\n \"GET\",\n `/v1/usage${qs}`,\n undefined,\n ctx.user.id\n );\n return out.usage;\n}\n\n// Instancia de servicio \"dumb\" del tenant corriendo (caja whisper/piper/kokoro/\n// libsql, sufijo \"-svc\"). La UI lo usa para saber qué servicios ya están en la\n// flota y NO ofrecerlos como \"lanzar\".\nexport interface SvcInstance {\n sandboxId: string;\n template: string;\n status: string;\n name: string | null;\n}\n\nexport async function listSvcInstances(ctx: AuthContext): Promise<SvcInstance[]> {\n requireScope(ctx, \"READ\");\n // GET /v1/sandbox está owner-scopeado por X-Easybits-Owner → las cajas del tenant.\n const out = await callHost<any[]>(\"GET\", \"/v1/sandbox\", undefined, ctx.user.id);\n const arr = Array.isArray(out) ? out : [];\n return arr\n .filter(\n (s) => typeof s?.template === \"string\" && s.template.endsWith(\"-svc\") && s.status === \"running\"\n )\n .map((s) => ({ sandboxId: s.sandboxId, template: s.template, status: s.status, name: s.name ?? null }));\n}\n\n// In-process cache of the catalog, 60s TTL. Catalog only changes on a\n// sandbox-host redeploy + yaml update; rapid agent_create calls shouldn't\n// pay a network round-trip per request.\nlet templatesCache: { tpls: TemplateInfo[]; expiresAt: number } | null = null;\n\nasync function resolveTemplate(\n ctx: AuthContext,\n name: SandboxTemplate\n): Promise<TemplateInfo> {\n if (!templatesCache || Date.now() > templatesCache.expiresAt) {\n templatesCache = {\n tpls: await listTemplates(ctx),\n expiresAt: Date.now() + 60_000,\n };\n }\n const tpl = templatesCache.tpls.find((t) => t.name === name);\n if (!tpl) throw new Error(`Unknown template: ${name}`);\n return tpl;\n}\n\n// validateRequiredEnv: cruza el contrato del template con lo que llega.\n// Sigue dos reglas:\n// 1. Cada EnvSpec con `required: true` debe estar presente en `env`.\n// 2. Caso especial Goose (oneOf): si `GOOSE_PROVIDER=anthropic` exige\n// ANTHROPIC_API_KEY; si `=openai` exige OPENAI_API_KEY. (Ad-hoc por\n// ahora — hasta que el schema soporte `required_if` genérico.)\nfunction validateRequiredEnv(\n tpl: TemplateInfo,\n env: Record<string, string>\n): void {\n const missing: string[] = [];\n for (const spec of tpl.requiredEnv ?? []) {\n if (spec.required && !(env[spec.name]?.trim())) {\n missing.push(spec.name);\n }\n }\n if (tpl.name === \"goose\") {\n const provider = env.GOOSE_PROVIDER || \"anthropic\";\n const need =\n provider === \"anthropic\"\n ? \"ANTHROPIC_API_KEY\"\n : provider === \"openai\"\n ? \"OPENAI_API_KEY\"\n : null;\n if (need && !env[need]?.trim() && !missing.includes(need)) {\n missing.push(need);\n }\n }\n // chat-anthropic + openclaw accept ANTHROPIC_API_KEY OR ANTHROPIC_AUTH_TOKEN.\n // Managed mode (spawnAutonomous) injects AUTH_TOKEN when the host key\n // is OAuth (sk-ant-oat...) — strict required_env would reject this.\n if (tpl.name === \"chat-anthropic\" || tpl.name === \"openclaw\") {\n if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim()) {\n const idx = missing.indexOf(\"ANTHROPIC_API_KEY\");\n if (idx >= 0) missing.splice(idx, 1);\n }\n }\n if (missing.length > 0) {\n throw new Error(\n `Missing required env for template \"${tpl.name}\": ${missing.join(\", \")}`\n );\n }\n}\n\n// ─────────────── Persistent agent lifecycle ───────────────\n\nexport interface AgentEndpoint {\n ok: true;\n agentUrl: string;\n healthUrl: string;\n unit?: string;\n port?: number;\n}\n\nexport async function startAgent(\n ctx: AuthContext,\n sandboxId: string,\n params: {\n env: Record<string, string>;\n port?: number;\n healthPath?: string;\n timeoutSeconds?: number;\n unit?: string;\n envFile?: string;\n }\n): Promise<AgentEndpoint> {\n requireScope(ctx, \"WRITE\");\n return callHost<AgentEndpoint>(\n \"POST\",\n `/v1/sandbox/${sandboxId}/agent/start`,\n {\n env: params.env,\n port: params.port,\n healthPath: params.healthPath,\n timeoutSeconds: params.timeoutSeconds ?? 30,\n unit: params.unit,\n envFile: params.envFile,\n },\n ctx.user.id\n );\n}\n\n// Provision a runtime template (e.g. ghostyclaw) on a box that was created via\n// the MACHINE path (createPermanent), giving it the same env-injection +\n// runtime-start that the Agent path does — so a permanent Sandbox can host a\n// configured agent runtime WITHOUT a db.agent row. Waits for the VM, writes the\n// env file + starts the unit (host /agent/start), and for ghostyclaw polls\n// /chat/ready. Throws on failure (caller logs / marks status). Templates without\n// an `agent` spec are a no-op.\nexport async function provisionRuntime(\n ctx: AuthContext,\n sandboxId: string,\n template: SandboxTemplate,\n env: Record<string, string>\n): Promise<void> {\n const tpl = await resolveTemplate(ctx, template);\n if (!tpl.agent) return; // plain machine, no managed runtime to start\n // The admin API (:8787) MUST bind 0.0.0.0, not its 127.0.0.1 default — else the\n // host-side admin passthrough (pairing/CLAUDE.md) can't reach it. Force it for\n // ghostyclaw so a fresh machine is pairable without manual fixup.\n if (template === \"ghostyclaw\" && !env.NANOCLAW_ADMIN_HOST) {\n env.NANOCLAW_ADMIN_HOST = \"0.0.0.0\";\n }\n validateRequiredEnv(tpl, env);\n await waitUntilRunning(ctx, sandboxId, { timeoutMs: 60_000 });\n await startAgent(ctx, sandboxId, {\n env,\n port: tpl.agent.port,\n healthPath: tpl.agent.health_path,\n unit: tpl.agent.unit,\n envFile: tpl.agent.env_file,\n });\n if (template === \"ghostyclaw\") {\n const token = env.NANOCLAW_ADMIN_TOKEN || \"\";\n const ready = await pollGhostyclawReady(ctx, sandboxId, token);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n}\n\n// Flagship agent templates that expose an in-VM admin API (:8787) and thus\n// support the admin passthrough (WhatsApp pairing, CLAUDE.md CRUD). This is an\n// agent-runtime capability — NOT a generic machine feature. Extend as new\n// flagship agents land (rust-ghosty, etc.). A plain machine (ubuntu/node/…) has\n// no admin API and is rejected even if it somehow carries an adminToken.\nconst ADMIN_PASSTHROUGH_TEMPLATES = new Set<string>([\"ghostyclaw\"]);\n\n// Admin passthrough to a flagship agent machine's in-VM admin API (:8787) — the\n// Sandbox-surface twin of agent-admin.ts, for WhatsApp pairing (/admin/whatsapp/*)\n// + CLAUDE.md CRUD. Gated to ADMIN_PASSTHROUGH_TEMPLATES. Authz via\n// effectiveOwnerId (owner OR \"machines\" delegate → 404). Bearer = box adminToken.\nexport async function sandboxAdmin(\n ctx: AuthContext,\n sandboxId: string,\n params: { method?: string; path: string; body?: unknown }\n): Promise<unknown> {\n requireScope(ctx, \"WRITE\");\n if (!params.path || !params.path.startsWith(\"/admin/\")) {\n throw new Response(JSON.stringify({ error: \"path must start with /admin/\" }), {\n status: 400,\n headers: { \"content-type\": \"application/json\" },\n });\n }\n const ownerId = await effectiveOwnerId(ctx, sandboxId); // owner or delegate, else 404\n const row = await db.sandbox.findUnique({\n where: { sandboxId },\n select: { adminToken: true, template: true },\n });\n if (!row?.template || !ADMIN_PASSTHROUGH_TEMPLATES.has(row.template)) {\n throw new Response(\n JSON.stringify({ error: \"AdminNotSupported\", message: \"Este sandbox no es un agente insignia con admin API (pairing/CLAUDE.md no aplican).\" }),\n { status: 403, headers: { \"content-type\": \"application/json\" } }\n );\n }\n if (!row?.adminToken) {\n throw new Response(\n JSON.stringify({ error: \"NoAdminToken\", message: \"Este sandbox no tiene admin token (no es un runtime gestionado).\" }),\n { status: 400, headers: { \"content-type\": \"application/json\" } }\n );\n }\n const proxyBody = {\n port: 8787,\n path: params.path,\n method: params.method ?? \"GET\",\n headers: { Authorization: `Bearer ${row.adminToken}`, Accept: \"application/json\" },\n rawBody: params.body !== undefined ? params.body : \"\",\n };\n return callHost<unknown>(\"POST\", `/v1/sandbox/${sandboxId}/agent/message`, proxyBody, ownerId);\n}\n\n// High-level: spawn sandbox + wait running + start agent runtime + persist Agent\n// row + return public handles. Distinct from `agent_run` (Claude one-shot\n// managed) — this returns a long-lived agent reachable at agentUrl + an\n// embedToken that can authenticate browser requests without exposing the\n// owner's API key.\nexport interface CreatedAgent {\n agentId: string;\n embedToken: string;\n sandboxId: string;\n agentUrl: string;\n healthUrl: string;\n template: SandboxTemplate;\n expiresAt: Date | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\n// Templates que sirven el chat web SSE (POST /message) — los únicos a los que\n// la CLI ghosty-tui (https://github... /Users/bliss/ghosty-tui) puede hablarle.\n// Daemons (ghostyclaw/openclaw, WhatsApp) y workstations computer-* no entran:\n// mostrarles un comando de chat por terminal sería engañoso.\nconst SSE_CHAT_TEMPLATES = new Set<string>([\n \"rust-ghosty\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"cagent-ghosty\",\n \"ghosty-lite\",\n]);\n\n/** Comando listo para pegar que abre un chat de terminal contra el agente. */\nfunction tuiCommandFor(template: string, agentId: string, embedToken: string): string | undefined {\n if (!SSE_CHAT_TEMPLATES.has(template)) return undefined;\n return `ghosty-tui --agent ${agentId} --token ${embedToken}`;\n}\n\n// Templates cuyo cerebro arma el MCP `easybits` a partir de EASYBITS_API_KEY (y, en\n// Code Mode, pega a la REST API v2 con EASYBITS_BASE_URL). SIN la key el runtime NO\n// registra el server → el agente se queda sin NINGUNA tool de EasyBits y su\n// EASYBITS_TOOL_GROUP/buckets quedan inertes, aunque la app se los mande por turno.\n// Vivía duplicado en 3 ramas por template (y faltaba entero en codex-worker, que por\n// eso arrancaba mudo de tools); una sola lista evita que el próximo motor lo repita.\nconst EASYBITS_TOOLED_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n \"open-ghosty\",\n \"lang-ghosty\",\n \"rust-ghosty\",\n \"cagent-ghosty\",\n]);\n\n// Los workers de la FLOTA son always-on y su llave ES su acceso a las tools: si el\n// dueño no tiene una en el vault se MINTEA persistente con sus scopes. Los demás\n// templates se quedan en solo-vault (comportamiento previo): sin key corren con sus\n// tools locales, que es aceptable para ellos y evita minteos sorpresa.\nconst EASYBITS_MINTING_TEMPLATES = new Set<string>([\n \"claude-worker\",\n \"codex-worker\",\n \"ghosty-gc\",\n]);\n\nasync function injectEasybitsAccess(\n ctx: AuthContext,\n params: { template: SandboxTemplate; name?: string },\n env: Record<string, string>\n): Promise<void> {\n // Base de la REST API v2 para Code Mode. Se inyecta siempre (inofensivo); el grupo\n // lean se activa per-fleet-agent vía persona.env.EASYBITS_TOOL_GROUP = \"scripting\".\n if (!env.EASYBITS_BASE_URL) {\n env.EASYBITS_BASE_URL = (\n process.env.BASE_URL ||\n process.env.EASYBITS_URL ||\n process.env.SITE_URL ||\n \"https://www.easybits.cloud\"\n ).replace(/\\/$/, \"\");\n }\n if (env.EASYBITS_API_KEY) return; // el env del caller siempre gana\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) {\n env.EASYBITS_API_KEY = ebKey;\n return;\n }\n if (!EASYBITS_MINTING_TEMPLATES.has(params.template)) return;\n const minted = await createApiKey(ctx.user.id, {\n name: `${params.template}-${params.name || \"fleetAgent\"}`,\n scopes: ctx.scopes,\n });\n env.EASYBITS_API_KEY = minted.raw;\n}\n\nexport async function createAgent(\n ctx: AuthContext,\n params: {\n template: SandboxTemplate;\n env: Record<string, string>;\n name?: string;\n timeoutSeconds?: number;\n /** Archivos de conocimiento (base64) a sembrar en /data/workspace tras el boot. */\n seedFiles?: Array<{ name: string; contentBase64: string }>;\n /** Override explícito de recursos de la VM (fleetAgent worker sizing). */\n memoryMb?: number;\n vcpus?: number;\n // Red de seguridad de siesta: con esto, el timer de `timeoutSeconds` del host\n // SUSPENDE (snapshot) en vez de DESTRUIR. Lo usan los workers de flota, cuyo\n // apagado normal lo hace el reaper propio de easybits — si ese latido se para\n // (deploy, restart, health check caído), esto es lo único que devuelve la RAM.\n suspendOnIdle?: boolean;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n\n // Generate embedToken upfront — also serves as OPENCLAW_GATEWAY_TOKEN for\n // the openclaw runtime, so easybits can reuse it as Bearer when proxying\n // /v1/chat/completions without persisting a second per-agent secret.\n const embedToken = \"agt_\" + randomBytes(32).toString(\"hex\");\n const env = { ...params.env };\n // Zona horaria del negocio (CDMX) por default. Sin esto la VM hereda la TZ del\n // host (UTC en el bare-metal) y el cerebro razona con la hora equivocada. Origen\n // canónico: cubre todos los callers (MCP agent_create, POST /api/v2/agents, panel).\n // El caller puede sobreescribir pasando TZ explícito. Requiere tzdata en el rootfs\n // para runtimes Rust/Go (Node trae ICU); ver templates/*/Dockerfile.\n if (!env.TZ) env.TZ = \"America/Mexico_City\";\n if (params.template === \"openclaw\") {\n env.OPENCLAW_GATEWAY_TOKEN = embedToken;\n }\n if (params.template === \"ghostyclaw\") {\n env.NANOCLAW_ADMIN_TOKEN = embedToken;\n }\n // ghosty-lite / open-ghosty / lang-ghosty / rust-ghosty serve /admin/whatsapp/*\n // gated by ADMIN_TOKEN; the UI's admin proxy sends the embedToken as Bearer, so match.\n if (\n params.template === \"ghosty-lite\" ||\n params.template === \"open-ghosty\" ||\n params.template === \"lang-ghosty\" ||\n params.template === \"rust-ghosty\" ||\n params.template === \"ghosty-gc\" ||\n params.template === \"claude-worker\" ||\n params.template === \"cagent-ghosty\" ||\n params.template === \"computer-ghosty\" ||\n params.template === \"computer-ghosty-gemini\" ||\n params.template === \"livekit-svc\"\n ) {\n env.ADMIN_TOKEN = embedToken;\n }\n // claude-worker (cerebro Claude Agent SDK, tarifa plana con OAuth Max del DUEÑO,\n // no proxy medido). El OAuth llega por fleetAgent.persona.env (CLAUDE_CODE_OAUTH_TOKEN);\n // si no viene, se resuelve del vault del dueño. GOTCHA: el Claude CLI corre como\n // root dentro de la VM → exige IS_SANDBOX=1 o sale exit 1.\n if (params.template === \"claude-worker\") {\n if (!env.IS_SANDBOX) env.IS_SANDBOX = \"1\";\n if (!env.CLAUDE_CODE_OAUTH_TOKEN) {\n const oauth = await getSecretValue(ctx.user.id, \"CLAUDE_CODE_OAUTH_TOKEN\").catch(() => null);\n if (oauth) env.CLAUDE_CODE_OAUTH_TOKEN = oauth;\n }\n // EASYBITS_BASE_URL + EASYBITS_API_KEY los resuelve injectEasybitsAccess (abajo,\n // común a todos los templates con tools de EasyBits).\n // FORMMY_SECRET_KEY: hasta ahora sólo llegaba al env del PROCESO HIJO del MCP de\n // formmy, no al del worker — así que un script del agente (Code Mode / una skill)\n // no podía registrar en el CRM aunque el conector estuviera conectado. Se inyecta\n // sólo si el dueño ya la tiene en su vault (nunca se mintea desde acá).\n if (!env.FORMMY_SECRET_KEY) {\n const fk = await getSecretValue(ctx.user.id, \"FORMMY_SECRET_KEY\").catch(() => null);\n if (fk) {\n env.FORMMY_SECRET_KEY = fk;\n if (!env.FORMMY_API_URL) env.FORMMY_API_URL = \"https://formmy.app\";\n }\n }\n }\n // livekit-svc (self-hosted recording studio): the LiveKit SFU and the box's\n // own token minting share ONE key pair, both internal to the VM. Generate it\n // so the studio is one-click — the user never pastes LiveKit creds. Caller\n // env wins (e.g. to pin a known pair). ADMIN_TOKEN above gates /admin/recording*.\n if (params.template === \"livekit-svc\") {\n if (!env.LK_API_KEY) env.LK_API_KEY = \"lk_\" + randomBytes(8).toString(\"hex\");\n if (!env.LK_API_SECRET) env.LK_API_SECRET = randomBytes(32).toString(\"hex\");\n // Para que el botón \"Detener\" de la sala suba la grabación a los Files del\n // dueño (permanente). El box notifica acá con su ADMIN_TOKEN (= embedToken).\n if (!env.EASYBITS_INGEST_URL) env.EASYBITS_INGEST_URL = \"https://www.easybits.cloud/api/v2/studio/ingest\";\n }\n // (open-ghosty / lang-ghosty / rust-ghosty / cagent-ghosty también arman el MCP de\n // easybits desde EASYBITS_API_KEY — lo resuelve injectEasybitsAccess más abajo.)\n // cagent-ghosty (cerebro Docker cagent) elige modelo por prioridad de key (start script):\n // Claude (ANTHROPIC) > GPT-5 (OPENAI) > DeepSeek. La cred Anthropic managed sale del\n // SANDBOX_HOST_ANTHROPIC_KEY del host (igual que ghosty/openclaw) — el dueño no pega nada;\n // si trae su propia key en `env` (ghosty-studio la resolvió del vault), esa gana.\n // NO necesita DEEPSEEK_RUNTIME_TOKEN (no hay auth interna Node↔brain; cagent es loopback).\n if (params.template === \"cagent-ghosty\") {\n // DEFAULT = DeepSeek V4 Pro (barato). PREMIUM OPT-IN (Claude, con razonamiento, más\n // caro): descomenta para inyectar la API key Anthropic managed (CAGENT_ANTHROPIC_API_KEY,\n // sk-ant-api real — NO usar SANDBOX_HOST_ANTHROPIC_KEY que es OAuth y cagent no lo habla).\n // if (!env.ANTHROPIC_API_KEY && process.env.CAGENT_ANTHROPIC_API_KEY) {\n // env.ANTHROPIC_API_KEY = process.env.CAGENT_ANTHROPIC_API_KEY;\n // }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // rust-ghosty (cerebro CodeWhale) corre opinionated con DeepSeek. Dos secretos los pone\n // el backend para que se lance de un clic:\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(CodeWhale serve --http).\n // Box-only, se auto-genera (como embedToken/OPENCLAW_GATEWAY_TOKEN) — nunca se pide.\n // - DEEPSEEK_API_KEY: del vault de secrets del user (db.secret), igual que EASYBITS_API_KEY.\n if (params.template === \"rust-ghosty\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n if (!env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n // Proxy del revendedor — si el usuario tiene DEEPSEEK_API_BASE en sus secrets,\n // CodeWhale usará esa URL en vez de api.deepseek.com.\n if (!env.DEEPSEEK_API_BASE) {\n const dsBase = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_BASE\")\n .catch(() => null);\n if (dsBase) env.DEEPSEEK_API_BASE = dsBase;\n }\n }\n // ghosty-gc (cerebro ghostycode) reemplaza a rust-ghosty: el LLM va por el PROXY MEDIDO\n // de EasyBits (provider \"easybits\" en ~/.ghosty/config.toml), así el gasto cae en el plan\n // del dueño (llmTokensUsed) y el agente entra al embudo. Por eso EASYBITS_API_KEY es\n // OBLIGATORIA y es a la vez la llave del LLM y del MCP. Si el dueño no tiene una en el\n // vault, minteamos una PERSISTENTE (el agente es always-on) con sus scopes → cobra a su plan.\n // - DEEPSEEK_RUNTIME_TOKEN: auth interna Node(server.js)↔Rust(ghosty serve --http). Box-only.\n // - DEEPSEEK_API_KEY (opcional, BYOK): si el dueño la tiene, puede cambiar a provider\n // deepseek (off-meter, su cuenta); el default sigue siendo easybits (medido).\n if (params.template === \"ghosty-gc\") {\n if (!env.DEEPSEEK_RUNTIME_TOKEN) {\n env.DEEPSEEK_RUNTIME_TOKEN = \"dsr_\" + randomBytes(32).toString(\"hex\");\n }\n // EASYBITS_API_KEY (obligatoria acá: es a la vez la llave del LLM medido y la del\n // MCP) la resuelve/mintea injectEasybitsAccess más abajo.\n // Motor LLM (form): \"easybits\" (medido) NO inyecta la DeepSeek key → ghosty-gc-start\n // cae al proxy medido. Default / \"deepseek\" → inyecta la key del vault (off-meter,\n // gana en ghosty-gc-start por la precedencia BYOK) si el dueño la tiene.\n if (env.GHOSTY_LLM !== \"easybits\" && !env.DEEPSEEK_API_KEY) {\n const dsKey = await getSecretValue(ctx.user.id, \"DEEPSEEK_API_KEY\").catch(() => null);\n if (dsKey) env.DEEPSEEK_API_KEY = dsKey;\n }\n }\n // codex-worker (motor Codex / OpenAI): el CLI headless usa OPENAI_API_KEY (doc\n // oficial; el launcher hace `codex login --with-api-key` con ella porque el\n // app-server NO la lee del entorno). Se resuelve del vault por su nombre canónico,\n // igual que la inyección DeepSeek. CODEX_MODEL llega por persona.env (modelEnv).\n if (params.template === \"codex-worker\" && !env.OPENAI_API_KEY) {\n const k = await getSecretValue(ctx.user.id, \"OPENAI_API_KEY\").catch(() => null);\n if (k) env.OPENAI_API_KEY = k;\n }\n // Acceso a las tools de EasyBits — UNA sola vez, para todos los templates que las\n // consumen. Va después de las ramas por template para que el env que ellas fijen\n // (o el del caller) siga ganando.\n if (EASYBITS_TOOLED_TEMPLATES.has(params.template)) {\n await injectEasybitsAccess(ctx, params, env);\n }\n // NOTA: la GOOGLE key (visión Gemini) NO se inyecta aquí — las credenciales de provider\n // del user viven en ghosty-studio (provider-credentials), no en el `db.secret` de easybits.\n // ghosty-studio la pasa en `env` al spawnear (intent create-agent).\n\n // 1. Resolve template + validate the env contract before spawning anything.\n const tpl = await resolveTemplate(ctx, params.template);\n validateRequiredEnv(tpl, env);\n\n // 2. Spawn microVM (returns immediately with status=\"starting\"; boot is async\n // inside sandbox-host). We only block on this call because we need the\n // sandboxId to insert the Agent row.\n const sb = await createSandbox(ctx, {\n template: params.template,\n timeoutSeconds: params.timeoutSeconds,\n name: params.name,\n memoryMb: params.memoryMb,\n vcpus: params.vcpus,\n suspendOnIdle: params.suspendOnIdle,\n // Telemetría: toda caja con runtime de agente entra como \"embed\". spawnVm la\n // re-etiqueta a \"worker\" con su back-fill cuando es una VM de flota — no se\n // puede decidir aquí porque el mismo template (claude-worker) sirve a los dos.\n kind: \"embed\",\n });\n\n // 3. Insert Agent row IMMEDIATELY with status=\"building\" so the UI can\n // render the card and poll. Runtime bring-up (waitUntilRunning →\n // startAgent → ACP handshake) happens in background; on completion it\n // updates the row to status=\"running\" with final agentUrl.\n const protocol = tpl.agent?.protocol ?? \"sse\";\n const port = tpl.agent?.port ?? 3000;\n const messagePath = tpl.agent?.message_path ?? \"/message\";\n const expiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const provisionalAgentUrl = `sandbox://${sb.sandboxId}:${port}`;\n const row = await db.agent.create({\n data: {\n ownerId: ctx.user.id,\n sandboxId: sb.sandboxId,\n agentUrl: provisionalAgentUrl,\n template: params.template,\n embedToken,\n name: params.name,\n status: \"building\",\n expiresAt,\n protocol,\n port,\n unit: tpl.agent?.unit ?? \"chat-runtime\",\n messagePath,\n },\n });\n\n // 4. Async bring-up. Fire-and-forget; the UI polls Agent.status to know\n // when it transitions building → running (or error). \"running\" means\n // the runtime is FULLY ready to accept messages — not just that the\n // systemd unit started. For ghostyclaw the readiness check polls the\n // /chat/ready endpoint inside the VM (Docker up + agent image built);\n // for other templates we trust startAgent's exit code.\n void (async () => {\n try {\n await waitUntilRunning(ctx, sb.sandboxId, { timeoutMs: 30_000 });\n // eb.compute: inyecta una ComputeKey como OPENAI_API_KEY para que el\n // código del agente llame al LLM managed sin traer su propia key.\n // Acotado a harnesses de EJECUCIÓN DE CÓDIGO — los chat runtimes\n // (chat-openai/ghostyclaw/openclaw) tienen su propia auth de LLM y no\n // deben ser redirigidos al gateway. BYOK gana (skip si ya hay key).\n if (COMPUTE_AUTOINJECT_TEMPLATES.has(params.template) && !env.OPENAI_API_KEY) {\n try {\n env.OPENAI_API_KEY = await mintComputeKey(ctx.user.id, sb.sandboxId);\n if (!env.OPENAI_BASE_URL) env.OPENAI_BASE_URL = COMPUTE_BASE_URL;\n } catch (e) {\n console.error(`eb.compute key mint failed for ${sb.sandboxId}:`, e);\n }\n }\n const ep = await startAgent(ctx, sb.sandboxId, {\n env,\n port: tpl.agent?.port,\n healthPath: tpl.agent?.health_path,\n unit: tpl.agent?.unit,\n envFile: tpl.agent?.env_file,\n });\n // Sembrar archivos de conocimiento en el workspace (drop del form). La VM ya está\n // running y el runtime creó /data/workspace. Best-effort: un archivo que falle no\n // aborta el spawn. El agente (CodeWhale) los consulta con sus tools.\n if (params.seedFiles?.length) {\n for (const f of params.seedFiles) {\n const safe =\n (f.name || \"archivo\").replace(/[/\\\\]/g, \"_\").replace(/^\\.+/, \"\").slice(0, 120) ||\n \"archivo\";\n try {\n await writeFile(ctx, sb.sandboxId, {\n path: `/data/workspace/${safe}`,\n content: f.contentBase64,\n encoding: \"base64\",\n });\n } catch (e) {\n console.error(`seed file \"${safe}\" failed for agent ${row.id}:`, e);\n }\n }\n }\n let acpSessionId: string | null = null;\n let acpTransportSessionId: string | null = null;\n if (protocol === \"acp\") {\n const handshake = await runAcpHandshake(sb.sandboxId, ctx.user.id, port, messagePath);\n acpSessionId = handshake.acpSessionId;\n acpTransportSessionId = handshake.acpTransportSessionId;\n }\n // Ghostyclaw-specific readiness: poll /chat/ready until docker+agent\n // image are both up (up to 10 min, covers worst-case first-boot agent\n // image build). UI input stays disabled while status==\"building\".\n if (params.template === \"ghostyclaw\") {\n const ready = await pollGhostyclawReady(ctx, sb.sandboxId, embedToken);\n if (!ready) {\n throw new Error(\"ghostyclaw not ready after 10min (agent image build likely failed)\");\n }\n }\n // Desktop templates: expón :6080 (websockify/noVNC) y guarda la URL\n // pública. Best-effort — si falla, el agente sigue running sin desktop.\n let desktopUrl: string | null = null;\n if (DESKTOP_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 6080);\n desktopUrl = `${exposed.url}/vnc.html?autoconnect=true&resize=remote`;\n } catch (e) {\n console.error(`expose 6080 (desktop) failed for ${sb.sandboxId}:`, e);\n }\n }\n // Terminal web (ttyd→tmux en :7681). ttyd corre sin basic-auth (el iframe no\n // puede pasar credenciales en la URL → 401); el subdominio capability es la\n // auth, igual que el noVNC del escritorio.\n let terminalUrl: string | null = null;\n if (TERMINAL_TEMPLATES.has(params.template)) {\n try {\n const exposed = await exposeSandboxPort(ctx, sb.sandboxId, 7681);\n terminalUrl = exposed.url;\n } catch (e) {\n console.error(`expose 7681 (terminal) failed for ${sb.sandboxId}:`, e);\n }\n }\n await db.agent.update({\n where: { id: row.id },\n data: {\n status: \"running\",\n agentUrl: ep.agentUrl,\n acpSessionId,\n acpTransportSessionId,\n desktopUrl,\n terminalUrl,\n },\n });\n } catch (e) {\n console.error(`async bringup failed for agent ${row.id}:`, e);\n await db.agent.update({\n where: { id: row.id },\n data: { status: \"error\" },\n }).catch(() => {});\n }\n })();\n\n return {\n agentId: row.id,\n embedToken,\n sandboxId: sb.sandboxId,\n template: params.template,\n agentUrl: provisionalAgentUrl,\n healthUrl: \"\",\n expiresAt,\n tuiCommand: tuiCommandFor(params.template, row.id, embedToken),\n };\n}\n\n// Managed-mode autonomous agents. Each brand maps to a default mascot name\n// + system prompt. Backed by chat-anthropic runtime + host-managed\n// credentials (SANDBOX_HOST_ANTHROPIC_KEY) — caller doesn't pass keys.\nconst MANAGED_MODEL = \"claude-haiku-4-5\";\n\n// BRAND_DEFAULTS: cada brand mapea a un template + nombre de mascota +\n// system prompt + builder de env.\n//\n// - ghosty → ghostyclaw runtime (long-lived nanoclaw daemon + admin-api).\n// - openclaw → openclaw runtime (gateway HTTP).\n// - goose-managed → goose runtime (ACP). Prompt no se inyecta como env;\n// queda como Goose default por ahora (custom systemPrompt está en backlog).\ntype BrandConfig = {\n template: SandboxTemplate;\n name: string;\n prompt: string;\n envBuilder: (hostKey: string, isOAuth: boolean) => Record<string, string>;\n};\n\n// OpenClaw runtime expects ANTHROPIC_API_KEY como provider key + un\n// OPENCLAW_GATEWAY_TOKEN para auth de la HTTP API en :18789.\n// El gateway token NO lo generamos acá — createAgent inyecta el embedToken\n// como OPENCLAW_GATEWAY_TOKEN antes de startAgent, así easybits puede\n// reusarlo como Bearer al proxear /v1/chat/completions sin guardar un\n// segundo secreto.\n//\n// openclaw: env vars per-provider los inyecta spawnAutonomous (depende del\n// PROVIDER seleccionado). Acá retornamos vacío — solo el wrapper\n// start-runtime.sh consume los envs específicos para escribir auth-profiles.\nconst openclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({});\n\n// ghostyclaw runtime (nanoclaw daemon + admin-api). Solo bindea host/port —\n// ANTHROPIC_API_KEY/AUTH_TOKEN los inyecta spawnAutonomous abajo desde el\n// providerKey, y NANOCLAW_ADMIN_TOKEN lo inyecta createAgent reusando el\n// embedToken (mismo patrón que OPENCLAW_GATEWAY_TOKEN).\nconst ghostyclawEnv = (\n _hostKey: string,\n _isOAuth: boolean,\n): Record<string, string> => ({\n NANOCLAW_ADMIN_HOST: \"0.0.0.0\",\n NANOCLAW_ADMIN_PORT: \"8787\",\n});\n\n// Capacidades comunes del runtime ghostyclaw (chromium pre-instalado +\n// endpoint de upload para que el agente entregue imágenes inline en el chat).\n// Se appendea al system prompt de cualquier brand que corra sobre ghostyclaw.\n//\n// IMPORTANTE: este string entra como env var SYSTEM_PROMPT al daemon vía\n// systemd EnvironmentFile, que NO acepta valores multilínea — sandbox-host\n// rechaza con 400 si hay \\n o \\r. Mantener en una sola línea, sin saltos.\n//\n// NOTA sobre por qué NO usamos base64 inline: un screenshot típico (200KB)\n// es ~270KB en base64 ≈ 70k tokens, excede el budget de output del modelo.\n// Por eso el flujo correcto es upload → URL pública → markdown ![](url).\nconst GHOSTYCLAW_CAPABILITIES =\n \" Entorno: chromium en /usr/bin/chromium (úsalo headless para screenshots \" +\n \"y scraping). Para mostrar imágenes en el chat web sigue este flujo con \" +\n \"los tools ya disponibles: 1) genera la imagen en /tmp/<nombre>.png; \" +\n \"2) llama mcp__easybits__upload_file con { fileName, contentType: \" +\n \"\\\"image/png\\\", size: <bytes-del-archivo>, access: \\\"public\\\" } — devuelve \" +\n \"{ file: { url }, putUrl }; 3) sube los bytes con curl -X PUT --data-binary \" +\n \"@/tmp/<nombre>.png \\\"$putUrl\\\"; 4) responde con markdown ![descripción]\" +\n \"(file.url) — el widget renderiza la imagen inline. NUNCA uses base64 \" +\n \"inline (excede el budget de tokens). Si el canal es WhatsApp o Telegram \" +\n \"(chatJid no empieza con web@), usa send_message con image_path en lugar \" +\n \"del flow de upload.\";\n\nconst BRAND_DEFAULTS: Record<string, BrandConfig> = {\n ghosty: {\n template: \"ghostyclaw\",\n name: \"Ghosty\",\n prompt:\n \"Eres Ghosty, el agente oficial de WhatsApp de la marca Ghosty, \" +\n \"de la empresa Formmy. Sé útil, breve y directo. Usas inteligencia \" +\n \"fría e ironía. Habla en el idioma del usuario. Si no sabes algo, dilo.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n nanoclaw: {\n // Andy persona corriendo encima del mismo runtime ghostyclaw que ghosty;\n // se diferencian solo por SYSTEM_PROMPT que spawnAutonomous inyecta.\n template: \"ghostyclaw\",\n name: \"Andy\",\n prompt:\n \"Eres Andy, el asistente de Nanoclaw para Slack y Microsoft Teams. \" +\n \"Conoces el flujo de canales corporativos y respondes en tono profesional \" +\n \"pero cercano. Sé conciso. Habla en el idioma del usuario.\" +\n GHOSTYCLAW_CAPABILITIES,\n envBuilder: ghostyclawEnv,\n },\n openclaw: {\n template: \"openclaw\",\n name: \"Molty\",\n prompt:\n \"Eres Molty, una langosta espacial. Asistente personal AI estilo \" +\n \"OpenClaw — multi-plataforma, OSS-first, sin filtros corporativos. \" +\n \"Sé útil, ingenioso y directo. Habla en el idioma del usuario.\",\n envBuilder: openclawEnv,\n },\n \"ghosty-lite\": {\n // Caja-agente ligera (Anthropic Agent SDK + tools + WhatsApp Baileys, 1\n // proceso Node). Managed: la cred Anthropic sale del vault del user o del\n // SANDBOX_HOST_ANTHROPIC_KEY (igual que ghosty) — sin form. El runtime\n // normaliza oauth→CLAUDE_CODE_OAUTH_TOKEN para el Agent SDK.\n template: \"ghosty-lite\",\n name: \"Ghosty Lite\",\n prompt:\n \"Eres Ghosty, agente de WhatsApp de Formmy. Útil, breve y directo, con \" +\n \"inteligencia fría e ironía. Habla en el idioma del usuario. Tienes tools \" +\n \"(bash, archivos, web) en tu propia caja aislada y herramientas de EasyBits. \" +\n \"Úsalas cuando ayuden; no inventes resultados.\",\n envBuilder: () => ({}),\n },\n \"goose-managed\": {\n template: \"goose\",\n name: \"Goose\",\n prompt: \"\", // Goose handles its own system prompt internally.\n envBuilder: (hostKey, isOAuth) => ({\n GOOSE_PROVIDER: \"anthropic\",\n GOOSE_MODEL: MANAGED_MODEL,\n // Goose accepts both OAuth and plain API keys under ANTHROPIC_API_KEY.\n ANTHROPIC_API_KEY: hostKey,\n ...(isOAuth ? {} : {}),\n }),\n },\n};\n\nfunction hostManagedAnthropicEnv(): Record<string, string> {\n const hostKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!hostKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n // OAuth tokens use Bearer (ANTHROPIC_AUTH_TOKEN); plain keys use x-api-key.\n const isOAuth = hostKey.startsWith(\"sk-ant-oat\");\n return isOAuth\n ? { ANTHROPIC_AUTH_TOKEN: hostKey }\n : { ANTHROPIC_API_KEY: hostKey };\n}\n\nexport type AutonomousBrand =\n | \"ghosty\"\n | \"ghosty-lite\"\n | \"nanoclaw\"\n | \"openclaw\"\n | \"goose-managed\";\n\nexport async function spawnAutonomous(\n ctx: AuthContext,\n params: {\n brand: AutonomousBrand;\n name?: string;\n systemPrompt?: string;\n /** \"provider/model\", e.g. \"anthropic/claude-haiku-4-5\". Defaults to MANAGED_MODEL on anthropic. */\n model?: string;\n /** User-provided plaintext API key for the selected provider (BYOK).\n Overrides host-managed key. Required for non-anthropic providers\n since only SANDBOX_HOST_ANTHROPIC_KEY exists in Fly secrets. */\n providerKey?: string;\n /** \"api_key\" → routea a *_API_KEY env (header x-api-key); \"oauth\" →\n routea a *_AUTH_TOKEN env (Bearer). Default detecta por prefix:\n sk-ant-oat → oauth, todo lo demás → api_key. */\n providerKeyKind?: \"api_key\" | \"oauth\";\n timeoutSeconds?: number;\n }\n): Promise<CreatedAgent> {\n requireScope(ctx, \"WRITE\");\n const cfg = BRAND_DEFAULTS[params.brand];\n if (!cfg) {\n throw new Error(`Unknown autonomous brand: ${params.brand}`);\n }\n\n // Resolve provider + model. openclaw expects \"provider/model\"; chat-anthropic\n // pre-supposes anthropic.\n const [reqProvider, reqModel] = (params.model ?? `anthropic/${MANAGED_MODEL}`).split(\"/\", 2);\n const provider = reqProvider || \"anthropic\";\n const model = reqModel || MANAGED_MODEL;\n\n // Auth resolution: BYOK takes precedence; fall back to host-managed key\n // (only anthropic supported today). Throw clear error otherwise.\n let providerKey = params.providerKey?.trim();\n let isOAuth = params.providerKeyKind === \"oauth\";\n if (!providerKey) {\n if (provider !== \"anthropic\") {\n throw new Error(\n `Provider \"${provider}\" requires the user to save their API key in /app/settings/credentials. Only anthropic has a host-managed fallback.`\n );\n }\n providerKey = process.env.SANDBOX_HOST_ANTHROPIC_KEY;\n if (!providerKey) {\n throw new Error(\n \"Managed mode unavailable: SANDBOX_HOST_ANTHROPIC_KEY not configured.\"\n );\n }\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n } else if (params.providerKeyKind === undefined && provider === \"anthropic\") {\n // Backward-compat: si el caller no pasó kind explícito y el key tiene\n // prefix sk-ant-oat, lo tratamos como oauth.\n isOAuth = providerKey.startsWith(\"sk-ant-oat\");\n }\n\n // Build env: provider-specific *_API_KEY/*_AUTH_TOKEN + brand-shared envs.\n // The runtime wrapper (start-runtime.sh) reads these and writes the right\n // auth-profiles.json entry: AUTH_TOKEN env → type:\"token\", API_KEY → \"api_key\".\n const env: Record<string, string> = cfg.envBuilder(providerKey, isOAuth);\n if (provider === \"anthropic\") {\n if (isOAuth) {\n env.ANTHROPIC_AUTH_TOKEN = providerKey;\n env.ANTHROPIC_API_KEY = providerKey; // some openclaw paths still read API_KEY\n } else {\n env.ANTHROPIC_API_KEY = providerKey;\n }\n }\n if (provider === \"openai\") {\n if (isOAuth) {\n env.OPENAI_AUTH_TOKEN = providerKey;\n } else {\n env.OPENAI_API_KEY = providerKey;\n }\n }\n if (provider === \"google\") env.GEMINI_API_KEY = providerKey;\n if (provider === \"deepseek\") env.DEEPSEEK_API_KEY = providerKey;\n if (provider === \"openrouter\") env.OPENROUTER_API_KEY = providerKey;\n\n if (\n cfg.template === \"openclaw\" ||\n cfg.template === \"ghostyclaw\" ||\n cfg.template === \"ghosty-lite\"\n ) {\n env.SYSTEM_PROMPT = params.systemPrompt ?? cfg.prompt;\n }\n // ghosty-lite corre el Agent SDK (loop agéntico con tools). Haiku es débil\n // para multi-tool/planeación, así que default a Sonnet (igual que el managed\n // agent default), salvo que el caller pida un modelo explícito.\n if (cfg.template === \"ghosty-lite\") {\n env.ANTHROPIC_MODEL = reqModel || \"claude-sonnet-4-6\";\n // Inyecta la EasyBits key del user (de sus secrets) para que el MCP de\n // easybits cargue sus 31 tools de negocio. Si no la tiene guardada, el\n // agente igual corre con las tools nativas (bash/archivos/web).\n const ebKey = await getSecretValue(ctx.user.id, \"EASYBITS_API_KEY\").catch(() => null);\n if (ebKey) env.EASYBITS_API_KEY = ebKey;\n }\n\n if (cfg.template === \"openclaw\") {\n env.PROVIDER = provider;\n env.MODEL = model;\n }\n // ghostyclaw: el daemon nanoclaw decide modelo/proveedor al spawnear el\n // agent container (no via env), así que no inyectamos MODEL/PROVIDER aquí.\n\n return createAgent(ctx, {\n template: cfg.template,\n name: params.name ?? cfg.name,\n timeoutSeconds: params.timeoutSeconds,\n env,\n });\n}\n\n// Back-compat: spawnGhosty stays as a thin wrapper.\nexport async function spawnGhosty(\n ctx: AuthContext,\n params: { name?: string; systemPrompt?: string; timeoutSeconds?: number } = {}\n): Promise<CreatedAgent> {\n return spawnAutonomous(ctx, { brand: \"ghosty\", ...params });\n}\n\n// ─────────────── Agent registry (Mongo-backed) ───────────────\n\nexport interface AgentRecord {\n agentId: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n // Runtime metadata snapshot (Prisma defaults if missing).\n protocol: string;\n port: number;\n unit: string;\n messagePath: string;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n // URL pública del escritorio noVNC (templates desktop-*); null para el resto.\n desktopUrl: string | null;\n // URL pública de la terminal ttyd→tmux con basic-auth inline (computer-ghosty); null para el resto.\n terminalUrl: string | null;\n /** Comando copy-paste para chatear desde la terminal con ghosty-tui (solo templates de chat web SSE). */\n tuiCommand?: string;\n}\n\nfunction toAgentRecord(row: {\n id: string;\n ownerId: string;\n sandboxId: string;\n agentUrl: string;\n template: string;\n embedToken: string;\n name: string | null;\n status: string;\n createdAt: Date;\n expiresAt: Date | null;\n protocol: string | null;\n port: number | null;\n unit: string | null;\n messagePath: string | null;\n acpSessionId: string | null;\n acpTransportSessionId: string | null;\n desktopUrl?: string | null;\n terminalUrl?: string | null;\n}): AgentRecord {\n return {\n agentId: row.id,\n ownerId: row.ownerId,\n sandboxId: row.sandboxId,\n agentUrl: row.agentUrl,\n template: row.template,\n embedToken: row.embedToken,\n name: row.name,\n status: row.status,\n createdAt: row.createdAt,\n expiresAt: row.expiresAt,\n protocol: row.protocol ?? \"sse\",\n port: row.port ?? 3000,\n unit: row.unit ?? \"chat-runtime\",\n messagePath: row.messagePath ?? \"/message\",\n acpSessionId: row.acpSessionId,\n acpTransportSessionId: row.acpTransportSessionId,\n desktopUrl: row.desktopUrl ?? null,\n terminalUrl: row.terminalUrl ?? null,\n tuiCommand: tuiCommandFor(row.template, row.id, row.embedToken),\n };\n}\n\n// Cross-account access a un agente: dueño O delegado con scope `agents`. Un solo\n// punto para todos los ops de operación (ver/suspender/reanudar/mensaje/admin).\n// `destroyAgent` NO lo usa (queda owner-only, destructivo).\nasync function agentAccess(ctx: AuthContext, ownerId: string): Promise<boolean> {\n return ownerId === ctx.user.id || (await can(ctx, ownerId, SCOPES.AGENTS));\n}\n\n// Owner or `agents`-delegate — looks up an agent by id and confirms access.\nexport async function getAgent(ctx: AuthContext, agentId: string): Promise<AgentRecord> {\n requireScope(ctx, \"READ\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n // Self-healing: si el cached status difiere del estado REAL del sandbox,\n // reconciliamos. Cubre dos casos:\n // 1. Mongo dice \"error\"/\"building\" pero la VM ya está ready → marca running\n // 2. Mongo dice \"running\" pero la VM fue destruida (sandbox-host restart) → marca lost\n // Sólo lo hacemos en estados transitorios o cuando running puede mentir —\n // para \"lost\" ya estamos en estado terminal, no vale la pena re-probe.\n if (row.status === \"building\" || row.status === \"error\" || row.status === \"running\") {\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent.update({ where: { id: row.id }, data: { status: real } });\n return toAgentRecord({ ...row, status: real });\n }\n }\n return toAgentRecord(row);\n}\n\n// Probe REAL state of the sandbox + runtime, independent of Mongo's cached status.\n// Returns the status the UI should show; null if probe inconclusive (keep cached).\nasync function probeRealStatus(\n ctx: AuthContext,\n agent: { sandboxId: string; template: string; embedToken: string }\n): Promise<\"running\" | \"building\" | \"lost\" | \"error\" | null> {\n // 1. Sandbox exists at all?\n let sb: SandboxRecord;\n try {\n sb = await callHost<SandboxRecord>(\n \"GET\",\n `/v1/sandbox/${agent.sandboxId}`,\n undefined,\n ctx.user.id,\n );\n } catch (e) {\n // 404 → host doesn't know this sandbox → lost (zombie row)\n if (e instanceof Error && /not found|404/i.test(e.message)) return \"lost\";\n return null; // network blip — don't change status\n }\n if (sb.status === \"lost\") return \"lost\";\n if (sb.status === \"error\") return \"error\";\n if (sb.status === \"starting\") return \"building\";\n\n // 2. Ghostyclaw-specific: probe /chat/ready inside the VM via exec.\n // For other templates we trust sb.status === \"running\" as ready.\n if (agent.template !== \"ghostyclaw\") return \"running\";\n try {\n const result = await execCommand(ctx, agent.sandboxId, {\n command: `curl -fsS -H \"Authorization: Bearer ${agent.embedToken}\" http://127.0.0.1:8787/chat/ready 2>/dev/null`,\n timeoutSeconds: 3,\n });\n const parsed = JSON.parse(result.stdout.trim());\n return parsed?.ready === true ? \"running\" : \"building\";\n } catch {\n return \"building\"; // probe failed; admin-api not up yet\n }\n}\n\nexport async function listAgents(ctx: AuthContext): Promise<AgentRecord[]> {\n requireScope(ctx, \"READ\");\n // Flota propia + flotas delegadas con scope `agents` (operador cross-account).\n const owners = [ctx.user.id, ...(await delegatedAccountIds(ctx, SCOPES.AGENTS))];\n const rows = await db.agent.findMany({\n where: { ownerId: { in: owners } },\n orderBy: { createdAt: \"desc\" },\n });\n // Self-healing del listado (mismo patrón que getAgent): reconciliamos el\n // status cacheado con el estado REAL del sandbox para los estados que pueden\n // mentir (running/building/error). Sin esto, una VM destruida por fuera (ej.\n // sandbox_destroy del MCP) se queda en \"running\" para siempre y la UI la\n // muestra VIVA. Probe selectivo + en paralelo para que la latencia no escale\n // con la flota: los terminales (\"lost\"/\"stopped\") no se re-prueban.\n const RECONCILE = new Set([\"running\", \"building\", \"error\"]);\n const reconciled = await Promise.all(\n rows.map(async (row) => {\n if (!RECONCILE.has(row.status)) return toAgentRecord(row);\n const real = await probeRealStatus(ctx, row).catch(() => null);\n if (real && real !== row.status) {\n await db.agent\n .update({ where: { id: row.id }, data: { status: real } })\n .catch(() => undefined);\n return toAgentRecord({ ...row, status: real });\n }\n return toAgentRecord(row);\n }),\n );\n return reconciled;\n}\n\nexport async function destroyAgent(ctx: AuthContext, agentId: string): Promise<{ ok: true }> {\n requireScope(ctx, \"DELETE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n try {\n await destroySandbox(ctx, row.sandboxId);\n } catch {\n // sandbox may already be gone (TTL expired) — proceed to row delete\n }\n await db.agent.delete({ where: { id: agentId } });\n return { ok: true };\n}\n\n// extendAgent: empuja expiresAt hacia adelante para keep-alive desde la UI.\n// Llama al sandbox-host (que reagenda su timer interno) y refleja la nueva\n// expiresAt en el Agent row. extendSeconds default = 300 (5min); el host\n// clampa a [30, 3600] y limita a un máximo de 3600s desde ahora.\nexport async function extendAgent(\n ctx: AuthContext,\n agentId: string,\n extendSeconds?: number\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot extend`);\n }\n const sb = await callHost<SandboxRecord>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/extend`,\n { extendSeconds: extendSeconds ?? 300 },\n ctx.user.id\n );\n const newExpiresAt = sb.expiresAt ? new Date(sb.expiresAt) : null;\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { expiresAt: newExpiresAt },\n });\n return toAgentRecord(updated);\n}\n\n// suspendAgent: pausa la microVM preservando agentId/embedToken/sandboxId.\n// Llama al sandbox-host (snapshot a disco + kill firecracker process) y\n// refleja status=\"suspended\" en la fila del Agent. Resume con resumeAgent.\nexport async function suspendAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"suspended\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"running\") {\n throw new Error(`agent is ${row.status}; cannot suspend`);\n }\n // Host call por el DUEÑO real (row.ownerId), no ctx.user.id — un delegado debe\n // pegarle al box de brenda, no al suyo. (Owner case: son iguales.)\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/suspend`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"suspended\" },\n });\n return toAgentRecord(updated);\n}\n\n// resumeAgent: revive un agente suspendido (carga snapshot, reinicia el\n// firecracker process, mismo TAP/IP/MAC/rootfs/volumes). agentId intacto.\nexport async function resumeAgent(\n ctx: AuthContext,\n agentId: string\n): Promise<AgentRecord> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n if (row.status === \"running\") {\n return toAgentRecord(row);\n }\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot resume`);\n }\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n const updated = await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\" },\n });\n return toAgentRecord(updated);\n}\n\n// wakeAgentForMessage: revive un agente embed antes de abrir el stream del\n// mensaje. El endpoint /api/v2/agents/:id/message NO despertaba VMs suspendidas\n// (las dejaba el reaper de idle-suspend, A3) — sin esto el primer mensaje tras\n// el idle fallaba. Idempotente y tolerante a estados intermedios:\n// - \"running\" → bump lastMessageAt y sigue.\n// - \"suspended\" → check de cap del plan + resume del host + status=\"running\".\n// - \"building\"/\"starting\" → espera a que la VM termine de bootear.\n// - otro (lost/error) → throw claro (para agentes denik usamos suspend-only,\n// así que este caso no debería ocurrir).\n// A diferencia de resumeAgent NO exige ownership por ctx (el embed ya autenticó\n// vía embedToken); opera por la fila del Agent. El resume del host se rutea con\n// el ownerId (callHost resuelve el box desde Agent.host del dueño).\nexport async function wakeAgentForMessage(agentId: string): Promise<void> {\n let row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n\n // Espera de boot si la VM aún está levantando (cold create reciente).\n if (row.status === \"building\" || row.status === \"starting\") {\n for (let i = 0; i < 30 && (row.status === \"building\" || row.status === \"starting\"); i++) {\n await new Promise((r) => setTimeout(r, 1000));\n row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row) throw new Error(\"agent not found\");\n }\n }\n\n if (row.status === \"running\") {\n await db.agent\n .update({ where: { id: agentId }, data: { lastMessageAt: new Date() } })\n .catch(() => {});\n return;\n }\n\n if (row.status !== \"suspended\") {\n throw new Error(`agent is ${row.status}; cannot wake for message`);\n }\n\n // Cap del plan ANTES de resumir: el resume del host NO pasa por createSandbox,\n // así que sin este check una avalancha de primeros-mensajes podría sobre-suscribir\n // la RAM del box. Mismo denominador que createSandbox/spawnVm (plan + add-ons).\n // fail-open: si no podemos resolver el conteo, dejamos pasar el resume.\n const owner = await db.user.findUnique({ where: { id: row.ownerId } });\n if (owner) {\n try {\n const plan = PLANS[getUserPlan(owner)];\n const ctx: AuthContext = { user: owner, scopes: [\"WRITE\"] };\n const list = await listSandboxes(ctx).catch(() => [] as SandboxRecord[]);\n const active = (Array.isArray(list) ? list : []).filter(\n (s) => s.status === \"running\" || s.status === \"starting\"\n ).length;\n const { getReservedCapacity } = await import(\"./sandboxReservations\");\n const reserved = await getReservedCapacity(owner.id).catch(() => ({ machines: 0, agents: 0 }));\n const budget = plan.concurrentSandboxes + reserved.machines;\n if (active >= budget) {\n throw new Response(\n JSON.stringify({\n error: \"SandboxLimitReached\",\n message: `Límite de ${budget} agentes activos alcanzado; intenta de nuevo en un momento.`,\n active,\n max: budget,\n }),\n { status: 503, headers: { \"content-type\": \"application/json\", \"Retry-After\": \"10\" } }\n );\n }\n } catch (e) {\n if (e instanceof Response) throw e; // cap hit → propaga\n // cualquier otro fallo del check = fail-open\n }\n }\n\n await callHost<{ ok: true }>(\n \"POST\",\n `/v1/sandbox/${row.sandboxId}/resume`,\n {},\n row.ownerId\n );\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"running\", lastMessageAt: new Date() },\n });\n}\n\n// markAgentLost: caller (UI loader) ya detectó que el sandbox subyacente\n// no responde (probe HTTP falló). Persiste el estado en Mongo así otros\n// consumidores no ven datos engañosos. Truncate expiresAt a now() para\n// que los countdowns de UI no mientan.\nexport async function markAgentLost(\n ctx: AuthContext,\n agentId: string\n): Promise<{ ok: true }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: agentId } });\n if (!row || row.ownerId !== ctx.user.id) {\n throw new Error(\"agent not found\");\n }\n await db.agent.update({\n where: { id: agentId },\n data: { status: \"lost\", expiresAt: new Date() },\n });\n return { ok: true };\n}\n\n// resolveAgentByEmbedToken: read-only path for embed auth in apiAuth helper.\n// Returns the agent if the token matches; null otherwise. Does NOT enforce\n// scope — caller decides what the embed token is allowed to do.\nexport async function findAgentByEmbedToken(token: string): Promise<AgentRecord | null> {\n if (!token.startsWith(\"agt_\")) return null;\n const row = await db.agent.findUnique({ where: { embedToken: token } });\n return row ? toAgentRecord(row) : null;\n}\n\n// openAgentChunkStream: high-level. Resuelve el agent record, dispatcha\n// por protocol y devuelve un ReadableStream UNIFORME con eventos\n// {type:\"chunk\"|\"done\"|\"error\"} sin importar si el upstream es chat-runtime\n// SSE o Goose ACP. El embed widget consume esto sin saber qué protocolo\n// está atrás.\n//\n// Used by /api/v2/agents/:id/message (route público) y por messageAgent\n// (sync MCP tool).\nexport async function openAgentChunkStream(\n agent: Pick<\n AgentRecord,\n | \"agentId\"\n | \"ownerId\"\n | \"sandboxId\"\n | \"protocol\"\n | \"port\"\n | \"messagePath\"\n | \"acpSessionId\"\n | \"acpTransportSessionId\"\n | \"embedToken\"\n | \"template\"\n >,\n body: {\n content: string;\n sessionId?: string;\n denikApiKey?: string;\n engineApiKey?: string;\n appendSystemPrompt?: string;\n mcpServers?: Record<string, unknown>;\n disabledBuiltins?: string[];\n toolGroup?: string;\n turnEnv?: Record<string, string>;\n }\n): Promise<ReadableStream<Uint8Array>> {\n const protocol = agent.protocol ?? \"sse\";\n if (protocol === \"sse\") {\n // Pass-through: chat-runtime ya emite {type:\"token\"|\"done\"} format.\n // Translate \"token\" → \"chunk\" so downstream consumers ven el mismo\n // shape unificado.\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n content: body.content,\n sessionId: body.sessionId,\n // Per-message denik org key (fleetAgent/Nik): scope del MCP denik por turno.\n denikApiKey: body.denikApiKey,\n // Per-message credencial del motor del tenant (BYOK).\n engineApiKey: body.engineApiKey,\n // Per-message personalización por-org (capa 3, append).\n appendSystemPrompt: body.appendSystemPrompt,\n // Per-message MCP custom servers (resueltos por grupo en fleetAgentOperations).\n mcpServers: body.mcpServers,\n // Per-message builtins apagados por grupo (ej. [\"easybits\"]).\n disabledBuiltins: body.disabledBuiltins,\n // Per-message tool group (capacidades per-número).\n toolGroup: body.toolGroup,\n // Per-turn env for code-mode capabilities (e.g. ELEVENLABS_API_KEY).\n turnEnv: body.turnEnv,\n port: agent.port ?? undefined,\n path: agent.messagePath ?? undefined,\n });\n return mapSSETokenToChunk(upstream.stream);\n }\n if (protocol === \"acp\") {\n if (!agent.acpSessionId) {\n throw new Error(\"ACP agent missing sessionId — handshake not completed\");\n }\n if (!agent.acpTransportSessionId) {\n throw new Error(\"ACP agent missing transport session id\");\n }\n const promptId = Date.now() & 0x7fffffff;\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 3284,\n path: agent.messagePath ?? \"/acp\",\n headers: { ...ACP_HEADERS, \"Acp-Session-Id\": agent.acpTransportSessionId },\n rawBody: {\n jsonrpc: \"2.0\",\n id: promptId,\n method: \"session/prompt\",\n params: {\n sessionId: agent.acpSessionId,\n prompt: [{ type: \"text\", text: body.content }],\n },\n },\n });\n return transformAcpStream(upstream.stream, promptId);\n }\n if (protocol === \"http\") {\n // Ghostyclaw expone /chat con shape simple {content, sessionId} y emite\n // SSE en formato unificado {type:\"chunk\"|\"done\"|\"error\"} directamente —\n // NO necesita el mapper de OpenAI deltas. Path separado del openclaw\n // gateway que sí es OpenAI-compatible.\n if (agent.template === \"ghostyclaw\") {\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 8787,\n path: agent.messagePath ?? \"/chat\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n content: body.content,\n sessionId: body.sessionId,\n },\n });\n // Passing the raw fetch ReadableStream straight to the Response body\n // doesn't keep the upstream open through Fly's proxy — the response\n // closes with 0 bytes received. Wrap in an explicit ReadableStream\n // with a getReader pump (same pattern as mapSSETokenToChunk /\n // mapOpenAIDeltaToChunk / transformAcpStream below) so each chunk is\n // enqueued + flushed individually. Pass-through 1:1, no event mapping\n // needed because /chat already emits {type:\"chunk\"|\"done\"|\"error\"}.\n return passThroughSSEStream(upstream.stream);\n }\n // OpenClaw gateway expone /v1/chat/completions compatible con OpenAI.\n // POST con stream:true → SSE con OpenAI delta chunks. Bearer token =\n // embedToken (createAgent lo inyecta como OPENCLAW_GATEWAY_TOKEN).\n const upstream = await openAgentMessageStream(agent.sandboxId, agent.ownerId, {\n port: agent.port ?? 18789,\n path: agent.messagePath ?? \"/v1/chat/completions\",\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${agent.embedToken}`,\n \"Content-Type\": \"application/json\",\n Accept: \"text/event-stream\",\n },\n rawBody: {\n // OpenClaw accepts `openclaw` (default agent) or `openclaw/<agentId>`\n // — the literal `<template>/default` form returns 400 \"Invalid model\"\n // in current upstream (v2026.5.7). Use the bare template name to\n // route to the configured default agent.\n model: agent.template,\n messages: [{ role: \"user\", content: body.content }],\n stream: true,\n user: body.sessionId ?? \"default\",\n },\n });\n return mapOpenAIDeltaToChunk(upstream.stream);\n }\n throw new Error(`Unsupported agent protocol: ${protocol}`);\n}\n\n// Translate OpenAI-compatible SSE chunks (`data: {choices:[{delta:{content}}]}`)\n// to unified {type:\"chunk\",value} stream. Stops on `data: [DONE]` → emits\n// {type:\"done\"}. Network errors → {type:\"error\",message}.\nfunction mapOpenAIDeltaToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n const emit = (evt: unknown) =>\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\\n\\n`));\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const line of event.split(\"\\n\")) {\n if (!line.startsWith(\"data: \")) continue;\n const payload = line.slice(6);\n if (payload === \"[DONE]\") {\n emit({ type: \"done\" });\n continue;\n }\n try {\n const parsed = JSON.parse(payload) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const text = parsed.choices?.[0]?.delta?.content ?? \"\";\n if (text) emit({ type: \"chunk\", value: text });\n } catch {\n // ignore malformed payloads\n }\n }\n }\n }\n } catch (e) {\n emit({ type: \"error\", message: e instanceof Error ? e.message : String(e) });\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// Translate chat-runtime SSE events {type:\"token\",value} → unified {type:\"chunk\",value}.\n// Pass-through {type:\"done\"} and {type:\"error\"} verbatim.\n// Byte-level pass-through wrap of an upstream fetch body. Required for\n// ghostyclaw /chat because returning the raw upstream.stream straight to\n// a Response() doesn't survive Fly's proxy — the connection closes with\n// no bytes flushed. Same pattern as the other transformers below, just\n// without event transformation.\nfunction passThroughSSEStream(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n controller.enqueue(value);\n }\n } catch (err) {\n const encoder = new TextEncoder();\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: err instanceof Error ? err.message : String(err) })}\\n\\n`\n )\n );\n } finally {\n controller.close();\n }\n },\n });\n}\n\nfunction mapSSETokenToChunk(\n upstream: ReadableStream<Uint8Array>\n): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n let buffer = \"\";\n return new ReadableStream({\n async start(controller) {\n const reader = upstream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"token\" && typeof evt.value === \"string\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"chunk\", value: evt.value })}\\n\\n`\n )\n );\n } else if (evt.type === \"tool\" && typeof (evt as { name?: string }).name === \"string\") {\n // Tool que el agente empezó a usar → checklist en vivo (passthrough).\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"tool\", name: (evt as { name?: string }).name })}\\n\\n`\n )\n );\n } else if (evt.type === \"done\") {\n controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`));\n } else if (evt.type === \"error\") {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", message: evt.message ?? \"unknown\" })}\\n\\n`\n )\n );\n }\n }\n }\n }\n } finally {\n controller.close();\n }\n },\n });\n}\n\n// messageAgent: sync wrapper sobre openAgentChunkStream. Para MCP callers\n// (non-streaming). Real-time streaming va por /api/v2/agents/:id/message.\nexport async function messageAgent(\n ctx: AuthContext,\n params: { agentId: string; content: string; sessionId?: string }\n): Promise<{ content: string; tokens: number }> {\n requireScope(ctx, \"WRITE\");\n const row = await db.agent.findUnique({ where: { id: params.agentId } });\n if (!row || !(await agentAccess(ctx, row.ownerId))) {\n throw new Error(\"agent not found\");\n }\n const stream = await openAgentChunkStream(toAgentRecord(row), {\n content: params.content,\n sessionId: params.sessionId,\n });\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let assembled = \"\";\n let tokens = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const event = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 2);\n for (const evt of parseSSEDataLines(event) as Array<{\n type?: string;\n value?: string;\n message?: string;\n }>) {\n if (evt.type === \"chunk\" && typeof evt.value === \"string\") {\n assembled += evt.value;\n tokens++;\n } else if (evt.type === \"error\") {\n throw new Error(`agent stream error: ${evt.message}`);\n }\n }\n }\n }\n return { content: assembled, tokens };\n}\n"}}}}