From cc41505695db6a0340d5d36e7ffb266cd68608be Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Thu, 27 Aug 2026 11:01:50 -0600 Subject: [PATCH] El HOME del contenedor sale del proyecto de la sala Co-Authored-By: Claude Opus 5 --- server/src/demos/aislamiento.ts | 27 +++++++++++++++++++++++++-- server/src/engine/container.ts | 30 ++++++++++++++++++++++++------ server/src/engine/workspace.ts | 24 +++++++++++++++++++----- server/src/rooms.ts | 6 +++++- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/server/src/demos/aislamiento.ts b/server/src/demos/aislamiento.ts index d43737f..fe185fa 100644 --- a/server/src/demos/aislamiento.ts +++ b/server/src/demos/aislamiento.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { homedir } from "node:os"; import { createWorkspace } from "../engine/workspace.js"; @@ -106,7 +106,30 @@ async function main() { const git = await dentro.exec("git --version", OPTS); check("tiene git", git.stdout.includes("git version"), git.stdout.trim()); - console.log("\n6. Al cerrar la sala, el contenedor desaparece"); + console.log("\n6. El HOME de la sala vive FUERA del proyecto, y se puede escribir"); + { + // El check que importa: si `.home` no existiera antes del + // `docker run`, Docker lo habría creado como root y esto fallaría con + // EACCES. Y ese fallo no se ve como error: el agente lo esquiva y entrega + // trabajo a medias (pasó, ver el comentario de container.ts). + const escribe = await dentro.exec("touch \"$HOME/prueba\" && echo ok", OPTS); + check("se puede escribir en el HOME", escribe.stdout.trim() === "ok", escribe.stderr.trim()); + + const donde = await dentro.exec("echo $HOME", OPTS); + check("y NO está dentro del proyecto", !donde.stdout.includes("/work"), donde.stdout.trim()); + + // Y por lo tanto el workspace queda vacío para el agente: solo `.git`, que + // los generadores de proyecto ignoran. Con cualquier otra cosa ahí dentro se + // niegan a correr y el agente pierde media docena de comandos rodeándolo. + const enLaRaiz = await readdir(ws.dir); + check( + "el proyecto arranca vacío", + enLaRaiz.filter((f) => f !== ".git" && f !== "saludo.txt").length === 0, + enLaRaiz.join(" "), + ); + } + + console.log("\n7. Al cerrar la sala, el contenedor desaparece"); await stopContainer(roomId); const muerto = await dentro.exec("echo sigue vivo", OPTS).catch(() => null); check( diff --git a/server/src/engine/container.ts b/server/src/engine/container.ts index a85d46b..59772b8 100644 --- a/server/src/engine/container.ts +++ b/server/src/engine/container.ts @@ -1,7 +1,9 @@ import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; import { KeyedMutex } from "./keyed-mutex.js"; +import { homeDir } from "./workspace.js"; const execFileP = promisify(execFile); @@ -25,6 +27,9 @@ const execFileP = promisify(execFile); export const IMAGE_TAG = "multi-room:latest"; const CONTAINER_PREFIX = "multi-room-"; +/** Dónde se ve el HOME de la sala desde dentro del contenedor. */ +const HOME_INTERNO = "/home/multi"; + /** * Un arranque a la vez por sala. Crear un contenedor no es atómico (se consulta * el estado y luego se corre `docker run`), así que dos llamadas simultáneas @@ -105,6 +110,12 @@ export async function startContainer( // Un contenedor parado con la config vieja no sirve: se rehace. if (existing !== null) await removeContainer(name); + // El HOME, antes del `docker run` y desde Node: así queda con el dueño del + // proceso de Multi, que es el mismo usuario con el que corre el contenedor. + // Si se dejara para que lo cree Docker, saldría de root y npm no podría + // escribir en él. + await mkdir(homeDir(workspaceDir), { recursive: true }); + // Los argumentos van en UNA sola lista porque abajo hay un reintento: con // dos copias, cualquier arreglo que se hiciera aquí y no allá revivía en el // segundo intento. Ya pasó con `--user`. @@ -114,6 +125,8 @@ export async function startContainer( "--name", name, // El proyecto vive en el host; adentro se ve como /work. "-v", `${workspaceDir}:/work`, + // Y su HOME al lado, montado aparte. Ver `homeDir` para el porqué. + "-v", `${homeDir(workspaceDir)}:${HOME_INTERNO}`, "--workdir", "/work", /** * El contenedor corre como el MISMO usuario que el server de Multi. @@ -138,13 +151,18 @@ export async function startContainer( * instalar nada: sin esto, arreglar el dueño del workspace no alcanzaba y * `npm install` seguía muriendo, ahora por el caché. * - * Va dentro del propio workspace porque es lo único que con certeza le - * pertenece a quien corre Multi. Queda en `.multi-home`, con punto: los - * `.gitignore` que escriben los agentes suelen ignorar los ocultos, y de - * todos modos el del motor ya excluye lo pesado. + * Vive FUERA del workspace, en un hermano que Multi crea antes de arrancar + * esto (ver arriba). Estuvo dentro y había que sacarlo: mientras hubiera + * algo ahí, los generadores de proyecto se negaban a correr sobre una + * carpeta con archivos. + * + * Que Multi lo cree ANTES es lo que hace que funcione: un destino de + * montaje que no existe lo crea el demonio de Docker, y como root. El + * usuario del contenedor no podría escribir ahí y volveríamos al EACCES de + * arriba, que no se ve como error sino como trabajo a medias. */ - "--env", "HOME=/work/.multi-home", - "--env", "npm_config_cache=/work/.multi-home/.npm", + "--env", `HOME=${HOME_INTERNO}`, + "--env", `npm_config_cache=${HOME_INTERNO}/.npm`, // Puerto 0 = que Docker elija uno libre del host. Evita colisiones entre salas. "-p", `0:${devPort}`, "--memory", MEMORY_LIMIT, diff --git a/server/src/engine/workspace.ts b/server/src/engine/workspace.ts index 6a8567b..a622ab7 100644 --- a/server/src/engine/workspace.ts +++ b/server/src/engine/workspace.ts @@ -15,6 +15,25 @@ export interface Workspace { dir: string; } +/** + * El HOME del contenedor de una sala, hermano de su workspace. + * + * Ahí van la caché de npm y la config de las herramientas que corren adentro + * (wrangler, por ejemplo). Fuera del workspace por dos razones: + * + * - El workspace tiene que estar VACÍO cuando el agente llega, o los generadores + * de proyecto se plantan a preguntar si continúan sobre un directorio con + * archivos, nadie les contesta y se cancelan. Le costaba al agente hasta doce + * comandos rodearlo, distinto cada vez. + * - Y una caché de npm no es parte del proyecto de nadie. Ahí dentro viajaba en + * lo que la gente se descarga. + * + * Mismo patrón que `.adjuntos` y `.turns.json`. + */ +export function homeDir(workspaceDir: string): string { + return `${workspaceDir}.home`; +} + /** * Crea el workspace de una sala: una carpeta vacía con git. * @@ -101,11 +120,6 @@ async function ensureGitignore(dir: string): Promise { "# commit del turno.", "*.tmp-*", "", - "# El HOME del contenedor de la sala (container.ts). Es cache de npm, no", - "# proyecto: sin esto entra a los commits y viaja en el .zip que la gente", - "# se descarga.", - ".multi-home/", - "", "# La base de datos del proyecto. Los datos de aquí son de prueba: quien se", "# lleva la app la quiere publicar con su base vacía, no con lo que se tecleó", "# probando. Y en el historial estorban: es un binario que cambia entero en", diff --git a/server/src/rooms.ts b/server/src/rooms.ts index c9b28f2..4157030 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; -import { createWorkspace, type Workspace } from "./engine/workspace.js"; +import { createWorkspace, homeDir, type Workspace } from "./engine/workspace.js"; import { startPreview, detectLaunch, type Preview } from "./engine/preview.js"; import { isDockerAvailable, @@ -476,6 +476,10 @@ export async function deleteRoom(id: string): Promise { await rm(dir, { recursive: true, force: true }).catch(() => {}); await rm(`${dir}.adjuntos`, { recursive: true, force: true }).catch(() => {}); await rm(`${dir}.turns.json`, { force: true }).catch(() => {}); + // El HOME del contenedor, con la caché de npm dentro. Antes vivía dentro del + // workspace y se iba con él; ahora hay que borrarlo a mano o cada sala que + // alguien borre deja cientos de megas en disco para siempre. + await rm(homeDir(dir), { recursive: true, force: true }).catch(() => {}); await rm(`${dir}.bookmarks.json`, { force: true }).catch(() => {}); await (await getStorage()).deleteRoom(id);