Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions server/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
11 changes: 8 additions & 3 deletions server/src/agent/tools/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 2 additions & 3 deletions server/src/agent/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions server/src/demos/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down
67 changes: 65 additions & 2 deletions server/src/demos/aislamiento.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof ensureRunner>[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<typeof ensureRunner>[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);
}
Expand Down
13 changes: 13 additions & 0 deletions server/src/demos/turno-cortado.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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",
});
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 55 additions & 7 deletions server/src/engine/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,52 @@ export async function isDockerAvailable(): Promise<boolean> {
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<void> {
export async function ensureImage(repoRoot?: string): Promise<void> {
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`);
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) };
Expand Down
Loading
Loading