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.
1 line
3.8 MiB
Plaintext
1 line
3.8 MiB
Plaintext
{"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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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 .\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"}}}} |