From 3624d3185a9a58eef83cf5e3b875c21454046d51 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 9 Sep 2026 22:25:47 -0600 Subject: [PATCH 1/3] Una nota sobre la salida de red del contenedor Sale del primer experimento: un participante con permiso para intentar romperlo instalo Arch Linux dentro de su sala, XFCE encima, y lo expuso con un tunel de ngrok. La regla del puerto unico cubre lo que ENTRA; un tunel marca hacia afuera, asi que nunca le aplico. Se anota con el porque de que el fix no sea una linea en el prompt, y con lo que si cierra el hueco, para que no se vuelva a discutir desde cero. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L1nFf8JaKeGedJ6RaZQYZ8 --- ROADMAP.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 5df4b8c..c84c92d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,6 +320,37 @@ has its commit. --- +## 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: From e16e26d3531fc24d8db63137702570eebdf2c7ad Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 9 Sep 2026 22:26:08 -0600 Subject: [PATCH 2/3] Una sala que no se puede aislar ya no ejecuta nada El 7 de septiembre la imagen de las salas desaparecio del servidor con el proceso corriendo. `ensureImage` solo corria al arrancar, asi que nadie volvio a mirar: cada `docker run` fallo, `ensureRunner` se trago el error y cayo al runner local. Las 62 salas de un experimento con 11 personas ejecutaron los comandos del agente en la maquina del server, y solo se dijo en un console.error que nadie lee. Uno de los participantes acabo con un Arch Linux y un escritorio corriendo en el VPS. El fallo de diez segundos no fue el problema, lo fue la decision de seguir. Degradar de "aislado" a "sin aislar" no es como degradar de "con preview" a "sin preview": cambia quien puede tocar que, y eso lo decide una persona, no un catch. Eran cuatro caminos distintos al host, no uno: - `ensureRunner` caia al runner local cuando el contenedor fallaba. Ahora lanza. El local queda detras de MULTI_SIN_AISLAMIENTO=1, que grita en el arranque igual que MULTI_TEST_MOCK, o de que no haya Docker en la maquina. - La imagen solo se comprobaba al arrancar. Ahora se revisa antes de cada contenedor, con el resultado recordado para no pagar el `docker images` cada vez, y OLVIDADO en cuanto un `docker run` falla. Con eso solo, lo del domingo se habria curado en el segundo intento sin que nadie se enterara. - El `runner` era opcional en ToolContext, asi que bash caia al host si algun llamador se olvidaba de pasarlo. Ahora es obligatorio y el compilador es la guardia: quien no tenga contenedor escribe `localRunner` con las manos. - El dev server decidia por `container.publishedPort`, una condicion distinta a la del runner. Cuando divergian, el proyecto arrancaba en el host mientras el agente creia estar encerrado. Ahora `Runner.isolated` manda en los dos. Y el error llega a la sala, no solo al log. Quien esta adentro lee que no se puede ejecutar codigo, que su trabajo sigue guardado y a quien avisarle; el log se queda con la causa y el comando para diagnosticar. El turno ni se abre, para no dejar un commit vacio en la linea de tiempo. La demo de aislamiento cubre ahora el caso que nadie cubria: Docker existe y el contenedor falla. Antes usaba `localRunner` directo, asi que este bug no tenia por donde salir. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L1nFf8JaKeGedJ6RaZQYZ8 --- server/src/agent/loop.ts | 4 +- server/src/agent/tools/base.ts | 11 +++- server/src/agent/tools/bash.ts | 5 +- server/src/demos/agent.ts | 3 + server/src/demos/aislamiento.ts | 67 +++++++++++++++++++++- server/src/demos/turno-cortado.ts | 13 +++++ server/src/engine/container.ts | 62 +++++++++++++++++--- server/src/engine/runner.ts | 21 ++++++- server/src/index.ts | 95 ++++++++++++++++++++++++++++--- server/src/rooms.ts | 69 ++++++++++++++++++++-- 10 files changed, 316 insertions(+), 34 deletions(-) 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; } From e5a3fc85855b566013a22d1e6c1a75ef7e9ed618 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Thu, 10 Sep 2026 19:28:36 -0600 Subject: [PATCH 3/3] Los ids de sala se pueden adivinar El id ES el control de acceso, y es legible a proposito para poder dictarlo por telefono. Pero legible tambien es adivinable: ocho adjetivos por ocho sustantivos por noventa numeros son 5760 combinaciones, y en el servidor ya hay 162 salas. Uno de cada treinta y cinco intentos cae en una sala de alguien. Con proyectos de clase es una curiosidad. Deja de serlo en cuanto alguien ponga credenciales de verdad en el panel de Variables, que es justo para lo que existe. La salida no son UUIDs, que romperian lo que los ids legibles protegen. Un id mas largo conserva la forma y mueve el espacio lo suficiente. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L1nFf8JaKeGedJ6RaZQYZ8 --- ROADMAP.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c84c92d..82f94f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,6 +320,30 @@ 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