diff --git a/ROADMAP.md b/ROADMAP.md index 5df4b8c..82f94f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,6 +320,61 @@ has its commit. --- +## Room ids are guessable + +A room's id is its whole access control: whoever has the link is in. That's +deliberate, and the ids are readable on purpose (`chido-fiesta-61`, not a UUID) +so you can say one out loud over the phone. + +But readable also means guessable. Eight adjectives by eight nouns by ninety +numbers is 5,760 possible ids, and after two sessions with students there are 162 +rooms on the server. That's roughly one hit every thirty-five tries, which is +guessable by hand, never mind with a script. Typing an id you didn't get from +anyone drops you straight into someone else's room, with their chat, their +preview and their project, and they see you arrive. + +For classroom projects that's a curiosity. It stops being one the moment someone +puts real credentials in the Variables panel, which is exactly what the panel is +for. + +The fix isn't UUIDs: dictating one over the phone is the thing the readable ids +were protecting. A longer id keeps the shape (three words instead of two, or a +wider vocabulary) and moves the space far enough out that guessing stops paying. +Rooms that already exist keep their ids. + +--- + +## Outbound network from a room's container + +A room's container publishes exactly one port, the dev server's. That covers what +comes IN, and nothing that goes OUT. + +Found on 2026-09-07, during the first experiment, by a participant who had been +asked to try and break it: he installed Arch Linux inside his room's container, +then XFCE, Firefox and VLC, and exposed the whole desktop through an ngrok tunnel. +An ngrok tunnel doesn't come in, it dials out, so the single-port rule never +applies to it. Eight hours of session, and about 15 dollars of API spend that +looked suspicious until the logs explained it. + +So anyone with a room can host whatever they want on the host machine, on its +bandwidth and its IP. Today the blast radius is small: the tunnel dies with the +container, and idle rooms already sleep after 30 minutes. On a public service it +stops being small, because the one answering to the provider is whoever hosts +Multi. + +The fix is not a line in the system prompt. A prompt is a suggestion, not a +control: ask for cloudflared instead of ngrok, or build the tunnel by hand from +bash, and the rule is gone. What holds is the network itself: default-deny +egress with an allowlist for the package registries and whatever the app actually +needs, plus blocking cloud metadata endpoints and internal ranges. The cost is +that the allowlist has to be right, or `npm install` breaks. + +And it isn't solved by moving to a managed sandbox (Modal, Daytona, E2B): they +also allow outbound traffic by default. What that buys is that abuse stops being +your legal problem, which is worth something but is a different thing. + +--- + ## Hosting Multi runs locally today. To actually host it, still missing: diff --git a/server/src/agent/loop.ts b/server/src/agent/loop.ts index 68f1b6d..d79e20b 100644 --- a/server/src/agent/loop.ts +++ b/server/src/agent/loop.ts @@ -258,8 +258,8 @@ export async function runAgent(opts: { /** Avisos de espera de lock (para mostrar "esperando a X" — dos relojes). */ onWaitStart?: (info: { path: string; holder?: string }) => void; onWaitEnd?: () => void; - /** Dónde corren los comandos de bash. Sin esto, corren en la máquina del server. */ - runner?: ToolContext["runner"]; + /** Dónde corren los comandos de bash. Obligatorio: ver ToolContext. */ + runner: ToolContext["runner"]; /** * El historial tal como va, para que sobreviva si el turno LANZA. * diff --git a/server/src/agent/tools/base.ts b/server/src/agent/tools/base.ts index 9e7ae10..1d3151b 100644 --- a/server/src/agent/tools/base.ts +++ b/server/src/agent/tools/base.ts @@ -9,10 +9,15 @@ export interface ToolContext { /** Raíz del workspace de la sala. Ninguna tool puede salir de aquí. */ workspaceDir: string; /** - * Dónde se ejecutan los comandos de bash (contenedor de la sala o, sin Docker, - * la máquina del server). Si falta, bash corre local — es lo que usan los demos. + * Dónde se ejecutan los comandos de bash: el contenedor de la sala o, cuando + * no hay aislamiento, la máquina del server. + * + * Obligatorio a propósito. Antes era opcional y bash caía al runner local + * cuando faltaba, así que un olvido en cualquier llamador nuevo abría un + * camino silencioso al host. Quien no tenga contenedor (los demos) tiene que + * escribir `localRunner` con las manos, y eso se ve en un diff. */ - runner?: import("../../engine/runner.js").Runner; + runner: import("../../engine/runner.js").Runner; /** Emite un evento observable (ej. file:changed). Opcional (CLI no lo usa). */ emit?: (event: ToolEvent) => void; /** Quién está usando las tools. Para el CAS ("lo tocó Agente-1") y los locks. */ diff --git a/server/src/agent/tools/bash.ts b/server/src/agent/tools/bash.ts index 642a195..8aba1fe 100644 --- a/server/src/agent/tools/bash.ts +++ b/server/src/agent/tools/bash.ts @@ -1,5 +1,4 @@ import { type Tool, ToolError, reqString } from "./base.js"; -import { localRunner } from "../../engine/runner.js"; const DEFAULT_TIMEOUT_MS = 120_000; const MAX_OUTPUT = 30_000; // truncar salidas enormes para no reventar el contexto @@ -10,7 +9,7 @@ const MAX_OUTPUT = 30_000; // truncar salidas enormes para no reventar el contex * (eso va por edit_file, que es preciso y observable). * * Dónde corre lo decide el `runner` del contexto: normalmente el contenedor de - * la sala; sin Docker, la máquina del server. Aquí no se distingue — de eso se + * la sala; sin Docker, la máquina del server. Aquí no se distingue, de eso se * trata la interfaz. */ export const bashTool: Tool = { @@ -33,7 +32,7 @@ export const bashTool: Tool = { ctx.emit?.({ type: "tool:bash", command }); - const runner = ctx.runner ?? localRunner(ctx.workspaceDir); + const runner = ctx.runner; let result; try { diff --git a/server/src/demos/agent.ts b/server/src/demos/agent.ts index 255dca3..09c8927 100644 --- a/server/src/demos/agent.ts +++ b/server/src/demos/agent.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { existsSync } from "node:fs"; import { runAgent } from "../agent/loop.js"; +import { localRunner } from "../engine/runner.js"; import { AnthropicProvider } from "../agent/providers/anthropic.js"; import { MockProvider } from "../agent/providers/mock.js"; import { WORKSPACES_ROOT } from "../engine/workspace.js"; @@ -100,6 +101,8 @@ async function main() { const result = await runAgent({ provider, workspaceDir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(workspaceDir), messages: [], userMessage: prompt, callbacks: { diff --git a/server/src/demos/aislamiento.ts b/server/src/demos/aislamiento.ts index fe185fa..77098d1 100644 --- a/server/src/demos/aislamiento.ts +++ b/server/src/demos/aislamiento.ts @@ -9,16 +9,20 @@ import { startContainer, stopContainer, } from "../engine/container.js"; -import { containerRunner, localRunner } from "../engine/runner.js"; +import { containerRunner, localRunner, NoHayAislamiento } from "../engine/runner.js"; /** * Demo Fase 7b: verifica que el agente NO puede salirse de su sala. * Uso: npm run demo:aislamiento * * El punto: las tools de archivos ya validan la ruta (safePath), pero bash no - * puede — a un shell le das cwd, que dice dónde EMPIEZA, no hasta dónde LLEGA. + * puede, a un shell le das cwd, que dice dónde EMPIEZA, no hasta dónde LLEGA. * Esta demo prueba las dos cosas: que sin contenedor bash SÍ se sale (por eso * existe la fase), y que con contenedor ya no. + * + * Las secciones 8 y 9 cubren lo que pasa cuando el contenedor no se puede + * crear. Antes la sala caía al runner local sin decirle a nadie, y así 62 salas + * de un experimento corrieron en la máquina del server durante dos días. */ let pass = 0; @@ -138,6 +142,65 @@ async function main() { muerto ? `code ${muerto.code}` : "", ); + /** + * El caso que nadie cubría, y que costó 62 salas sin aislar. + * + * Las secciones de arriba prueban que el contenedor encierra. Esta prueba lo + * otro: qué pasa cuando NO se puede crear. Antes se caía al runner local en + * silencio, así que el agente seguía trabajando, pero en la máquina del + * server. + * + * Para forzar el fallo, un id de sala que Docker rechaza como nombre de + * contenedor: `docker run` truena de inmediato y sin tocar la imagen (borrarla + * haría que la demo tarde minutos en reconstruirla). No se usa + * MULTI_ROOM_MEMORY porque el límite se lee al importar el módulo, así que + * cambiarlo aquí no haría nada y la prueba pasaría por la razón equivocada. + */ + console.log("\n8. Si el contenedor no se puede crear, la sala NO ejecuta nada"); + { + const { ensureRunner } = await import("../rooms.js"); + + const sala = { + id: "Sala Con Espacios", + workspace: await createWorkspace("demo-aislamiento-falla", { clean: true }), + } as never as Parameters[0]; + + let lanzo: unknown = null; + try { + await ensureRunner(sala); + } catch (err) { + lanzo = err; + } + + check( + "lanza en vez de degradar", + lanzo instanceof NoHayAislamiento, + lanzo ? `lanzó ${lanzo}` : "no lanzó nada", + ); + check( + "no se queda con un runner sin aislar", + (sala as { runner?: unknown }).runner === undefined, + "quedó un runner cacheado", + ); + } + + console.log("\n9. Con MULTI_SIN_AISLAMIENTO=1 sí corre local, porque alguien lo pidió"); + { + const { ensureRunner } = await import("../rooms.js"); + process.env.MULTI_SIN_AISLAMIENTO = "1"; + + const sala = { + id: "demo-aislamiento-explicito", + workspace: await createWorkspace("demo-aislamiento-explicito", { clean: true }), + } as never as Parameters[0]; + + const runner = await ensureRunner(sala); + check("devuelve un runner", runner !== undefined); + check("y NO está aislado", runner?.isolated === false); + + delete process.env.MULTI_SIN_AISLAMIENTO; + } + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); process.exit(fail > 0 ? 1 : 0); } diff --git a/server/src/demos/turno-cortado.ts b/server/src/demos/turno-cortado.ts index f0f2673..3a0d35e 100644 --- a/server/src/demos/turno-cortado.ts +++ b/server/src/demos/turno-cortado.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runAgent } from "../agent/loop.js"; +import { localRunner } from "../engine/runner.js"; import type { Message, ModelProvider, StreamEvent } from "../agent/providers/types.js"; /** @@ -122,6 +123,8 @@ async function main() { await runAgent({ provider, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: [], userMessage: "haz el nivel 1", onProgreso: (msgs) => { @@ -157,6 +160,8 @@ async function main() { await runAgent({ provider: p1, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: [], userMessage: "haz el nivel 1", onProgreso: (m) => { @@ -174,6 +179,8 @@ async function main() { const r = await runAgent({ provider: p2, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: rescatado, userMessage: "continua", }); @@ -193,6 +200,8 @@ async function main() { await runAgent({ provider, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: [], userMessage: "haz algo", onProgreso: (m) => { @@ -219,6 +228,8 @@ async function main() { const r = await runAgent({ provider, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: [], userMessage: "haz algo", onProgreso: (m) => { @@ -246,6 +257,8 @@ async function main() { const r = await runAgent({ provider, workspaceDir: dir, + // Sin contenedor a propósito: es una demo, y el runner se pide explícito. + runner: localRunner(dir), messages: [], userMessage: "haz algo", signal: ac.signal, diff --git a/server/src/engine/container.ts b/server/src/engine/container.ts index 59772b8..fb6283c 100644 --- a/server/src/engine/container.ts +++ b/server/src/engine/container.ts @@ -69,17 +69,52 @@ export async function isDockerAvailable(): Promise { return dockerAvailable; } +/** + * La raíz del repo, para saber dónde está el Dockerfile. + * + * Se recuerda en el arranque en vez de viajar por parámetro hasta + * `startContainer`: la necesita el reintento de la imagen, que ocurre tres + * capas más abajo de quien la conoce. + */ +let repoRootRecordado: string | null = null; + +export function recordarRepoRoot(dir: string): void { + repoRootRecordado = dir; +} + +/** + * Ya se comprobó que la imagen existe. + * + * Se recuerda porque preguntar cuesta unos 50ms y la respuesta casi siempre es + * que sí. Pero se OLVIDA en cuanto un `docker run` falla (ver abajo), y ahí está + * todo el asunto: la imagen puede desaparecer con el server corriendo. Un + * `docker image prune` se lleva la etiqueta y deja las capas, así que el + * siguiente build sale entero de caché en segundos. + * + * Comprobarla solo al arrancar no alcanza cuando el proceso vive días. + */ +let imagenVerificada = false; + /** Construye la imagen de las salas si todavía no existe. Idempotente. */ -export async function ensureImage(repoRoot: string): Promise { +export async function ensureImage(repoRoot?: string): Promise { + if (imagenVerificada) return; + const { stdout } = await execFileP("docker", ["images", "-q", IMAGE_TAG]); - if (stdout.trim().length > 0) return; + if (stdout.trim().length > 0) { + imagenVerificada = true; + return; + } + + const raiz = repoRoot ?? repoRootRecordado; + if (!raiz) throw new Error("no sé dónde está el Dockerfile de las salas"); - console.log(`[docker] construyendo la imagen ${IMAGE_TAG} (solo la primera vez)…`); + console.log(`[docker] construyendo la imagen ${IMAGE_TAG}…`); await execFileP( "docker", - ["build", "-t", IMAGE_TAG, "-f", join(repoRoot, "docker", "room.Dockerfile"), repoRoot], + ["build", "-t", IMAGE_TAG, "-f", join(raiz, "docker", "room.Dockerfile"), raiz], { timeout: 600_000, maxBuffer: 10 * 1024 * 1024 }, ); + imagenVerificada = true; console.log(`[docker] imagen lista`); } @@ -107,6 +142,11 @@ export async function startContainer( if (existing === "running") { return { roomId, name, publishedPort: await readPublishedPort(name, devPort) }; } + + // Antes de cada contenedor y no solo al arrancar el server: la imagen puede + // haberse ido mientras el proceso vivía. Casi siempre es un `docker images` + // de 50ms, porque el resultado se recuerda. + await ensureImage(); // Un contenedor parado con la config vieja no sirve: se rehace. if (existing !== null) await removeContainer(name); @@ -179,9 +219,17 @@ export async function startContainer( } catch (err) { // Cinturón por si el nombre quedó tomado de todos modos (un contenedor // que Docker seguía borrando, por ejemplo): se limpia y se reintenta una vez. - if (!String(err).includes("already in use")) throw err; - await removeContainer(name); - await execFileP("docker", args, { timeout: 60_000 }); + if (String(err).includes("already in use")) { + await removeContainer(name); + await execFileP("docker", args, { timeout: 60_000 }); + } else { + // Cualquier otro fallo pone en duda la imagen, así que la próxima sala + // vuelve a comprobarla. Es lo que cura solo el caso que motivó todo + // esto: la imagen desaparece, la primera sala falla, y la siguiente la + // reconstruye sin que nadie tenga que enterarse. + imagenVerificada = false; + throw err; + } } return { roomId, name, publishedPort: await readPublishedPort(name, devPort) }; diff --git a/server/src/engine/runner.ts b/server/src/engine/runner.ts index 58b65e3..7ca8480 100644 --- a/server/src/engine/runner.ts +++ b/server/src/engine/runner.ts @@ -8,6 +8,16 @@ import { execInContainer, type ExecResult } from "./container.js"; * o directo en la máquina (cuando no hay Docker). La tool de bash no sabe cuál * le tocó — solo pide "corre esto". */ +/** + * No se pudo aislar la sala, así que no va a ejecutar nada. + * + * Es un tipo propio y no un Error cualquiera porque arriba hay que distinguirlo + * de "falló el proyecto": al agente y a quien está en la sala se les dice cosas + * muy distintas. Ir por el tipo y no por el texto deja cambiar los mensajes sin + * romper esa traducción. + */ +export class NoHayAislamiento extends Error {} + export interface Runner { readonly isolated: boolean; exec( @@ -36,9 +46,16 @@ export function containerRunner(roomId: string): Runner { * El de respaldo: el comando corre en la máquina donde vive el server. * * `cwd` acota dónde EMPIEZA el comando, no hasta dónde llega: un `cd ..` sale - * del workspace. No es un descuido de esta función — es el límite de lo que se + * del workspace. No es un descuido de esta función, es el límite de lo que se * puede hacer sin ayuda del sistema operativo, y la razón de que exista el - * contenedor. Se usa solo cuando no hay Docker, y el server lo avisa al arrancar. + * contenedor. + * + * Solo se llega aquí por dos caminos, los dos deliberados: que no haya Docker en + * la máquina, o que alguien haya puesto MULTI_SIN_AISLAMIENTO=1. Nunca porque + * algo falló. Antes sí: cuando `docker run` tronaba, la sala caía aquí sola y lo + * decía en un `console.error` que nadie lee. En un experimento con 11 personas + * eso significó 62 salas ejecutando como root en el servidor, y a nadie le + * constó hasta dos días después. */ export function localRunner(workspaceDir: string): Runner { return { diff --git a/server/src/index.ts b/server/src/index.ts index fb212f8..5f6c487 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -90,7 +90,13 @@ import { import { getHistory, setBookmark } from "./engine/history.js"; import { buildApiMap } from "./engine/api-map.js"; import { handlePreviewRequest, handlePreviewUpgrade } from "./engine/proxy.js"; -import { isDockerAvailable, ensureImage, sweepOrphanContainers } from "./engine/container.js"; +import { + isDockerAvailable, + ensureImage, + recordarRepoRoot, + sweepOrphanContainers, +} from "./engine/container.js"; +import { NoHayAislamiento } from "./engine/runner.js"; import { runAgent } from "./agent/loop.js"; import type { ModelProvider, Message, ContentBlock } from "./agent/providers/types.js"; import { createDevMock } from "./agent/providers/mock-scenarios.js"; @@ -748,7 +754,12 @@ async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise): string { * Que no arranque NO es error: la sala vacía es el estado normal al empezar. */ async function notifyPreviewWhenReady(room: Room): Promise { - const url = await maybeStartPreview(room, (etapa) => { - // Por dónde va el arranque. Sin esto la sala muestra "está vacía" mientras - // el proyecto SÍ existe y se está levantando, que es mentira y se siente - // como una pantalla muerta. - io.to(room.id).emit("preview:arrancando", { etapa }); - }); + let url: string | null; + try { + url = await maybeStartPreview(room, (etapa) => { + // Por dónde va el arranque. Sin esto la sala muestra "está vacía" mientras + // el proyecto SÍ existe y se está levantando, que es mentira y se siente + // como una pantalla muerta. + io.to(room.id).emit("preview:arrancando", { etapa }); + }); + } catch (err) { + if (!(err instanceof NoHayAislamiento)) throw err; + // Se dice una vez, aquí, y no cada vez que alguien entra: quien esté en la + // sala necesita saber por qué no hay preview, y es lo mismo que le va a + // pasar si le pide algo al agente. + systemMsg(room, AVISO_SIN_AISLAMIENTO, "#d95d63"); + io.to(room.id).emit("preview:sin-arranque"); + return; + } + if (url) { io.to(room.id).emit("preview:ready", { previewUrl: url }); return; @@ -1653,6 +1694,23 @@ process.on("SIGTERM", () => void shutdown("SIGTERM")); * de la máquina debe estar tomando a sabiendas, no descubrir después. */ async function setupIsolation(): Promise { + // Quien puso la variable ya sabe lo que hace, pero se le recuerda: es la única + // forma de correr sin aislamiento teniendo Docker, y no debería estar puesta + // en una máquina con gente entrando a las salas. + if (process.env.MULTI_SIN_AISLAMIENTO === "1") { + console.warn( + [ + "", + " *** SIN AISLAMIENTO A PROPOSITO (MULTI_SIN_AISLAMIENTO=1) ***", + " Los comandos del agente corren en ESTA máquina, con acceso a todo lo", + " que alcance el usuario que arrancó Multi. Cualquiera que entre a una", + " sala le puede dar órdenes a ese agente.", + "", + ].join("\n"), + ); + return; + } + if (!(await isDockerAvailable())) { console.warn( [ @@ -1670,6 +1728,9 @@ async function setupIsolation(): Promise { const barridos = await sweepOrphanContainers(); if (barridos > 0) console.log(`[docker] ${barridos} contenedor(es) de una corrida anterior, borrados`); + // Para que `startContainer` pueda rehacer la imagen si desaparece con el + // server ya corriendo, que es justo lo que pasó y nadie notó. + recordarRepoRoot(join(process.cwd(), "..")); await ensureImage(join(process.cwd(), "..")); } @@ -1741,7 +1802,23 @@ async function loadEnv(): Promise { * el problema es su cuenta, su key, o que el servicio se cayó — y sin saberlo no * puede hacer nada. */ +/** + * Lo que ve la gente de la sala cuando no se pudo aislar. + * + * No dice "Docker" ni "contenedor": esas palabras no significan nada para quien + * está adentro, que puede no ser programador. Dice qué no se puede hacer, qué SI + * sigue guardado (para que nadie crea que perdió su trabajo) y de quién es el + * problema. El detalle técnico va al log del server. + */ +const AVISO_SIN_AISLAMIENTO = + "no puedo ejecutar código en esta sala ahora mismo, falta la caja donde corre. " + + "Pueden seguir platicando, y todo lo que ya hicieron sigue guardado. " + + "Quien administra este Multi tiene que revisarlo."; + function explicarFalla(err: unknown): string { + // Por tipo y no por texto: así el mensaje se puede reescribir sin romper esto. + if (err instanceof NoHayAislamiento) return AVISO_SIN_AISLAMIENTO; + const texto = String(err); // Antes que el de créditos: este mensaje TAMBIÉN habla de saldo, y si cae en diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 38fc695..2a6d668 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -8,9 +8,10 @@ import { isDockerAvailable, startContainer, stopContainer, + IMAGE_TAG, type Container, } from "./engine/container.js"; -import { containerRunner, localRunner, type Runner } from "./engine/runner.js"; +import { containerRunner, localRunner, NoHayAislamiento, type Runner } from "./engine/runner.js"; import type { Message } from "./agent/providers/types.js"; import { AgentRegistry } from "./engine/agents.js"; import { KeyedMutex } from "./engine/keyed-mutex.js"; @@ -291,6 +292,10 @@ async function bootPreview(room: Room): Promise { await maybeStartPreview(room); } catch (err) { + // Sin aislamiento el error ya se explicó al detalle en `ensureRunner`, y + // aquí nadie está esperando: esto corre en segundo plano al despertar la + // sala. Quien entre lo va a ver al pedirle algo al agente. + if (err instanceof NoHayAislamiento) return; console.error(`[sala ${room.id}] falló el arranque:`, err); } } @@ -372,6 +377,20 @@ async function arrancarPreview( } onEtapa?.("servidor"); + + /** + * Si el runner está aislado, el dev server TIENE que ir por el contenedor. + * + * Son dos decisiones sobre el mismo hecho tomadas con datos distintos: el + * runner mira si `startContainer` funcionó, y esto miraba + * `container.publishedPort`, que puede venir nulo con un contenedor vivo si + * `readPublishedPort` falla. Cuando divergían, el proyecto arrancaba en la + * máquina del server mientras el agente creía estar encerrado. + */ + if (runner.isolated && !room.container?.publishedPort) { + throw new NoHayAislamiento(`la sala ${room.id} no tiene puerto publicado`); + } + room.preview = room.container?.publishedPort ? await startPreview(room.workspace, launch, { roomId: room.id, @@ -384,6 +403,12 @@ async function arrancarPreview( console.log(`[sala ${room.id}] preview listo en ${room.preview.url}`); return room.preview.url; } catch (err) { + // Un fallo de aislamiento se propaga en vez de volverse `null`: arriba, + // `null` significa "la sala sigue vacía", que es el estado normal al + // empezar. Devolverlo aquí dejaba a la gente mirando una sala en blanco + // como si no hubiera proyecto, cuando el proyecto está y lo que falta es + // dónde correrlo. + if (err instanceof NoHayAislamiento) throw err; console.error(`[sala ${room.id}] falló el preview:`, err); return null; } finally { @@ -402,12 +427,21 @@ const INTERNAL_DEV_PORT = 5173; /** * El runner de la sala: dónde corren los comandos del agente. * - * Con Docker, arranca (o reusa) el contenedor de la sala. Sin Docker, cae al - * runner local — el server ya avisó al arrancar que no hay aislamiento. + * Con Docker, arranca (o reusa) el contenedor de la sala, y si no puede, LANZA. + * Sin Docker, o con MULTI_SIN_AISLAMIENTO=1, cae al runner local, que son los + * dos únicos caminos por los que se ejecuta fuera de un contenedor, y los dos + * los eligió alguien. El arranque del server ya gritó en ambos casos. */ export async function ensureRunner(room: Room): Promise { if (room.runner) return room.runner; + // Se lee aquí y no como constante del módulo para que una demo pueda probar + // los dos caminos en el mismo proceso. + if (process.env.MULTI_SIN_AISLAMIENTO === "1") { + room.runner = localRunner(room.workspace.dir); + return room.runner; + } + if (await isDockerAvailable()) { try { room.container = await startContainer(room.id, room.workspace.dir, INTERNAL_DEV_PORT); @@ -415,12 +449,35 @@ export async function ensureRunner(room: Room): Promise { console.log(`[sala ${room.id}] contenedor listo (${room.container.name})`); return room.runner; } catch (err) { - // Que Docker exista pero falle NO debe dejar la sala muerta: se avisa - // fuerte y se sigue sin aislamiento, igual que si no estuviera instalado. - console.error(`[sala ${room.id}] no se pudo crear el contenedor, sigue SIN aislar:`, err); + /** + * Aquí antes se caía al runner local, y esa fue la peor decisión del + * proyecto hasta ahora. + * + * El razonamiento parecía bueno: que Docker falle no debería dejar la sala + * muerta. Pero "seguir" significaba ejecutar los comandos del agente en la + * máquina del server, como el usuario que arrancó Multi, y eso solo se + * decía en un `console.error`. Un 7 de septiembre la imagen de las salas + * desapareció del servidor, y 62 salas de un experimento con 11 personas + * corrieron sin aislar durante dos días sin que nadie se enterara. + * + * Degradar de "aislado" a "sin aislar" no es lo mismo que degradar de + * "con preview" a "sin preview". Lo primero cambia quién puede tocar qué, + * y eso no se hace sin que alguien lo decida. + * + * No se cachea el fallo: el siguiente intento vuelve a probar, y como + * `startContainer` reconstruye la imagen si hace falta, una sala puede + * curarse sola. + */ + console.error( + `[sala ${room.id}] SIN CONTENEDOR: no se pudo aislar, la sala NO va a ejecutar código.\n` + + ` causa: ${err}\n` + + ` revisa: docker images ${IMAGE_TAG} | docker info`, + ); + throw new NoHayAislamiento(`la sala ${room.id} no se pudo aislar`); } } + // Sin Docker en la máquina. El arranque ya lo gritó. room.runner = localRunner(room.workspace.dir); return room.runner; }