From e5ac68c1c7034c5d29e875e9a3fdc77995e0792b Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Sun, 9 Aug 2026 19:35:08 -0600 Subject: [PATCH] El agente puede escribir en la sala cuando Multi corre como root El contenedor entraba siempre como el usuario `node` de la imagen, sin importar de quien fueran los archivos. En un servidor Multi corre como root, el workspace montado es de root, y adentro `npm install` moria con EACCES. Lo peor no era el error: el agente no se detenia. Al ver que no podia instalar nada, improvisaba y entregaba otra cosa (armo una landing "sin dependencias" para esquivarlo), asi que el sintoma era trabajo a medias, no una falla. No se veia en desarrollo porque ahi el usuario suele ser uid 1000, el mismo de la imagen. Sale en cuanto Multi corre como root. Ahora el contenedor entra con el uid/gid del proceso, y con un HOME dentro del workspace: sin eso npm seguia muriendo al escribir su cache en /home/node, que tampoco le pertenece. Ese HOME va al .gitignore del motor para que no entre a los commits ni al .zip. Los argumentos de `docker run` se unifican en una lista: estaban duplicados para el reintento, y un arreglo puesto solo en la primera copia revivia el bug en el segundo intento. Co-Authored-By: Claude Opus 5 --- server/src/engine/container.ts | 81 ++++++++++++++++++++++------------ server/src/engine/workspace.ts | 5 +++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/server/src/engine/container.ts b/server/src/engine/container.ts index 27c1549..a87c265 100644 --- a/server/src/engine/container.ts +++ b/server/src/engine/container.ts @@ -105,40 +105,65 @@ export async function startContainer( // Un contenedor parado con la config vieja no sirve: se rehace. if (existing !== null) await removeContainer(name); + // 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`. + const args = [ + "run", + "-d", + "--name", name, + // El proyecto vive en el host; adentro se ve como /work. + "-v", `${workspaceDir}:/work`, + "--workdir", "/work", + /** + * El contenedor corre como el MISMO usuario que el server de Multi. + * + * El workspace es una carpeta del host montada adentro, y sus archivos + * son de quien corre Multi. Si adentro se es otro usuario, el proyecto se + * ve pero no se puede escribir: `npm install` falla con EACCES y el agente + * cree que el stack no se puede instalar. En vez de parar, improvisa (lo + * vimos armar una landing "sin dependencias" para esquivarlo), así que el + * síntoma no es un error sino trabajo entregado a medias. + * + * No se ve en desarrollo porque ahí tu usuario suele ser uid 1000, el + * mismo que trae la imagen. Sale en cuanto Multi corre como root, que es + * lo normal en un servidor. + */ + "--user", `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`, + /** + * Y con un HOME que ese usuario sí posea. + * + * La imagen trae HOME=/home/node, de `node`. Al entrar como otro usuario + * esa carpeta es ajena, y npm escribe ahí su caché y su config antes de + * 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. + */ + "--env", "HOME=/work/.multi-home", + "--env", "npm_config_cache=/work/.multi-home/.npm", + // Puerto 0 = que Docker elija uno libre del host. Evita colisiones entre salas. + "-p", `0:${devPort}`, + "--memory", MEMORY_LIMIT, + "--cpus", CPU_LIMIT, + // Sin privilegios extra y sin poder ganarlos (un sudo dentro no escala). + "--security-opt", "no-new-privileges", + // Si el server de Multi muere, el contenedor no se queda huérfano para siempre. + "--label", "multi.room=" + roomId, + IMAGE_TAG, + ]; + try { - await execFileP("docker", [ - "run", - "-d", - "--name", name, - // El proyecto vive en el host; adentro se ve como /work. - "-v", `${workspaceDir}:/work`, - "--workdir", "/work", - // Puerto 0 = que Docker elija uno libre del host. Evita colisiones entre salas. - "-p", `0:${devPort}`, - "--memory", MEMORY_LIMIT, - "--cpus", CPU_LIMIT, - // Sin privilegios extra y sin poder ganarlos (un sudo dentro no escala). - "--security-opt", "no-new-privileges", - // Si el server de Multi muere, el contenedor no se queda huérfano para siempre. - "--label", "multi.room=" + roomId, - IMAGE_TAG, - ], { timeout: 60_000 }); + await execFileP("docker", args, { timeout: 60_000 }); } 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", [ - "run", "-d", "--name", name, - "-v", `${workspaceDir}:/work`, - "--workdir", "/work", - "-p", `0:${devPort}`, - "--memory", MEMORY_LIMIT, - "--cpus", CPU_LIMIT, - "--security-opt", "no-new-privileges", - "--label", "multi.room=" + roomId, - IMAGE_TAG, - ], { timeout: 60_000 }); + await execFileP("docker", args, { timeout: 60_000 }); } return { roomId, name, publishedPort: await readPublishedPort(name, devPort) }; diff --git a/server/src/engine/workspace.ts b/server/src/engine/workspace.ts index bdf748a..9d40adf 100644 --- a/server/src/engine/workspace.ts +++ b/server/src/engine/workspace.ts @@ -83,6 +83,11 @@ 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/", + "", ]; await writeFile(path, lineas.join("\n"), "utf8"); }