#!/bin/sh # claude — entrar al agente en esta caja. El CLI es glibc y la caja es musl: corre enjaulado # (ADR 0015), con el repo, el store, el lab, la memoria y las llaves concedidos y nada más. # # ── UNA JAULA POR PERSONA, Y SIN PRIVILEGIO ───────────────────────────────────────────────────── # `root` usa la instancia `claude`; cada usuario usa `claude-`. No es purismo: la instancia # concede el HOME de quien entra —su `.claude`, o sea sus credenciales y su memoria— y una sola # instancia obligaría a compartirlo. Medido: con la instancia de root, `sergio` muere en # `bwrap: Can't mkdir /root/.claude: Permission denied`. # # ⚠ Y NO se eleva con `doas`/`sudo`: en esta caja **el setuid no toma** («doas: Operation not # permitted», con `NoNewPrivs: 0` y `/` sin `nosuid` — queda como deuda entender por qué). No hace # falta: `qorpa run` levanta la jaula con user namespaces y anda **sin privilegio**, siempre que el # directorio de la instancia sea del usuario. # # ⚠ Y LOS BUILDS **SÍ CORREN DENTRO DE LA JAULA** — medido el 2026-09-18 por el agente de adentro, # y corrige lo que decía este comentario. Dos recetas construidas y SELLADAS desde la jaula, con el # hash forzado para que no hubiera caché: # · `anew` (Go, estática) — fetch → `go mod vendor` → compile → sealed, 6,8 s # · `lz4` (C, estática) — `make` + `make install` escribiendo `/out` → sealed, 4,4 s # Con `root = false` el bwrap anidado se levanta y `/proc` monta. Lo que NO se probó es una receta # cuyo `install` fije DUEÑOS: ahí sí muerde que el anidado no pueda mapear el uid 0, porque `sergio` # no tiene rango en `/etc/subuid` (sólo lo tiene `root`) y `newuidmap`/`newgidmap` no son setuid. # # ⚠ SE INTENTÓ CERRARLO Y **NO ALCANZÓ** (2026-09-18). Se hicieron las dos cosas obvias: # · rango para `sergio` en `/etc/subuid` y `/etc/subgid` (165536:65536); # · `newuidmap`/`newgidmap` en setuid root **y además** con capacidades (`cap_setuid=ep`). # El error se MOVIÓ —ya no dice «no hay rango para sergio»— pero sigue sin mapear: # # ⚠ sin mapeo por rango (newuidmap: open of uid_map failed: Permission denied) # # Lo que quedó descartado, medido: **el setuid sí eleva en esta caja** —sonda en C compilada con el # `cc` nuevo: `uid=1001 euid=0`—, `/` no está `nosuid`, y el `uid_map` del proceso es suyo # (`-rw-r--r-- sergio:sergio`). O sea que no es ni el rango que faltaba ni el bit que no tomaba. # ⚠ Y un detalle que cuesta un rato si no se sabe: **`chown` BORRA el bit setuid**, así que hay que # chownear primero y poner el bit después; la primera sonda salió sin bit por ese orden. # La causa de fondo queda SIN DETERMINAR, y se dice así en vez de inventarla. # # Mientras tanto el camino que sí anda es pedírselo al ANFITRIÓN por ssh: # # ssh -p 22022 root@127.0.0.1 'cd /opt/takana && flock -o work/.farm-build.lock takana build ' # # ⚠ Y UNA JAULA QUE TE MAPEA A ROOT ROMPE AL PROPIO AGENTE: con `root = true` el CLI se ve uid 0 y # **rechaza `--dangerously-skip-permissions`** («cannot be used with root/sudo privileges»). Por eso # las instancias de PERSONA van con `root = false` (queda uid 1001 adentro) y con `/store` en `rw`, # que es lo que la de `root` ya tenía y a la de `sergio` le faltaba: sin eso el agente lee el store # y no puede sellar nada. set -eu # ⚠ `/usr/bin/claude` ES UN ENLACE A ESTE FICHERO, y lo es por algo (2026-09-18): antes era una # COPIA, así que arreglar el guion en el repo no cambiaba nada para quien escribe `claude` — el # usuario reportó dos veces «sigue abriendo en /opt/takana» con el arreglo ya pusheado y tirado en la # caja. Un instalado que es copia DERIVA en silencio y sólo se nota por el síntoma viejo. # # ln -sf /opt/takana/scripts/servidor/claude-caja.sh /usr/bin/claude _yo="$(id -un)" _hogar="${HOME:-/root}" _base="claude" [ "$_yo" != "root" ] && _base="claude-$_yo" # ── VARIAS SESIONES A LA VEZ ──────────────────────────────────────────────────────────────────── # El kernel niega DOS overlays sobre el mismo `upper` —la capa mutable se corrompería— y eso no se # discute. Pero «un solo Claude» era empaquetado nuestro, no del kernel: instancias distintas tienen # `upper` distintos y conviven. Así que si la de siempre está ocupada, se usa la siguiente. # # ⚠ La instancia nueva NO se aprovisiona: se CLONA la capa ya hecha. Con un mapa de un solo id # —lo que hay hoy, ver §6.55— `pacman` no puede chownear su descarga y el `provision` muere en # «failed to chown temporary download directory». Copiar el `upper` cuesta ~1 s y 169 M y no depende # de eso. Cuando el mapeo por rango funcione, `takana qorpa provision` vuelve a ser el camino. BASEDIR=${BASEDIR:-/var/lib/hammer/qorpa/instances} _libre() { ! pgrep -f "[q]orpa run $1 " >/dev/null 2>&1; } _inst="$_base"; _n=2 while ! _libre "$_inst"; do _inst="$_base-$_n"; _n=$((_n+1)) # ⚠ ACÁ HABÍA UN TOPE DURO DE 10 Y SE FUE (2026-09-25, a pedido del operador). # # Lo que decía el comentario viejo sigue siendo cierto: el kernel sólo prohíbe DOS overlays # sobre el mismo `upper`, y de eso ya se encarga el bucle clonando la capa siguiente. El techo # real es la RAM. Lo que estaba mal era el `exit 1`: un número elegido a dedo que le decía «no» # con la máquina a medio usar. # # Y contaba TODAS las jaulas del usuario sin decir CUÁLES. Medido el 25-sep, que es la queja que # lo tumbó: el operador tenía 4 tabs de un proyecto abiertos y 6 sesiones de OTRO proyecto que no # estaba mirando, y el mensaje sólo sabía decir «hay 10 — cerrá alguna». Él contó su grupo de # tabs, el script contaba global, y ninguno de los dos estaba equivocado. Contar sin mostrar qué # se cuenta es lo que hacía que el tope se leyera como capricho: no había forma de saber que las # otras seis existían, ni que se llevaban 1,9 G y tenían el swap al 70 %. # # Ahora avisa UNA vez, con la evidencia medida y con el proyecto de cada jaula, y DEJA PASAR. # El modo de falla de pasarse es el OOM (INFORME-CAIDA-2026-08-17, la caída fue con ocho # adentro), y decidirlo con los números a la vista es del operador, no de esta línea. if [ "$_n" -gt 11 ] && [ -z "${_avisado:-}" ]; then _avisado=1 { echo "claude: van $((_n - 2)) jaulas de $_base abiertas. No te freno — mirá la máquina y decidí:" awk '/^MemAvailable:/{a=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf " RAM libre %d M · swap %d/%d M (%d%%) · ~310 M por sesión ⇒ entran ~%d más\n", a/1024, (st-sf)/1024, st/1024, (st ? 100*(st-sf)/st : 0), a/1024/310}' /proc/meminfo echo " las que ya están (jaula · proyecto):" pgrep -af "[q]orpa run $_base" 2>/dev/null | while read -r _pid _resto; do _hijo=$(pgrep -P "$_pid" 2>/dev/null | head -1) printf " %-22s %s\n" \ "$(printf '%s' "$_resto" | awk '{print $4}')" \ "$(readlink "/proc/${_hijo:-0}/cwd" 2>/dev/null || echo '?')" done } >&2 fi # Corte de seguridad, NO política: si `pgrep` contestara «ocupada» para siempre —un `/proc` # inconsistente, un nombre que casa de más— sin el `exit 1` de antes esto era un bucle infinito. # 64 está tan por encima de cualquier uso real que sólo lo alcanza una falla. [ "$_n" -gt 65 ] && { echo "claude: 64 jaulas de $_base y todas ocupadas — eso es una falla de conteo, no uso. Mirá \`pgrep -af \"qorpa run $_base\"\`." >&2; exit 1; } done if [ ! -d "$BASEDIR/$_inst" ]; then echo "claude: abro una sesión más ($_inst) — clonando la capa, ~1 s" >&2 # ⚠ SIN RETROREFERENCIAS: acá había un `sed` con `\1` y el fichero terminó guardando el # BYTE 0x01 en su lugar, así que el sha salía como el carácter de control y `qorpa create` # moría con «ninguna imagen empieza por "\u{1}"». `awk` parte por comillas y no tiene # nada que una herramienta pueda reinterpretar. _sha=$(awk -F\" '/^base *=/ {sub(/^sha256:/,"",$2); print $2; exit}' "$BASEDIR/$_base/instance.toml") [ -n "$_sha" ] || { echo "claude: no pude leer el sha de la imagen en $BASEDIR/$_base/instance.toml" >&2; exit 1; } takana qorpa create "$_inst" --base "$_sha" >/dev/null || { echo "claude: no pude crear $_inst" >&2; exit 1; } cp "$BASEDIR/$_base/instance.toml" "$BASEDIR/$_inst/instance.toml" rm -rf "$BASEDIR/$_inst/upper" cp -a "$BASEDIR/$_base/upper" "$BASEDIR/$_inst/upper" mkdir -p "$BASEDIR/$_inst/work" fi # El `cd` no es comodidad: la memoria del agente se indexa POR RUTA DE TRABAJO. Desde `/` —que es # donde `qorpa run` deja el shell— la sesión usa el proyecto `-` y el agente empieza sin saber nada # aunque su memoria esté en disco. # # ⚠ **Y ABRE DONDE ESTÁS PARADO, no en un sitio fijo** (2026-09-18). Antes el destino era # `/opt/takana` cableado, así que `cd /work/sergio/takana && claude` abría… `/opt/takana`: el agente # contestaba sobre OTRO árbol —el del root— y encima con la memoria de ese proyecto. Se reportó como # «entro al repo y claude sigue en /opt/takana», y era literal. Ahora el defecto es `$PWD`; el # cableado queda sólo para cuando no hay `PWD` utilizable. # # `PROY` sigue existiendo para abrirlo sobre otro árbol a propósito: # # PROY=/work/sergio/tawasuyu claude # ⚠ El `shift 2` no es adorno: sin él, el HOME y el proyecto que se pasan como posicionales se # cuelan COMO ARGUMENTOS del agente. Medido: la primera versión abrió la sesión con el prompt # «/home/sergio», y el agente contestó —con razón— que eso es una ruta, no un comando. # ⚠ El `cd` de adentro FALLA RUIDOSAMENTE: la jaula sólo monta los directorios concedidos, así que # desde una ruta no concedida el `cd` no encuentra nada. Antes eso se tragaba con `&&` y el agente # arrancaba en `/` sin decir por qué; ahora lo dice y sale. _proy="${PROY:-$PWD}" [ -d "$_proy" ] || _proy=/opt/takana # ── LO QUE SHUMA DECLARA Y LA JAULA BORRABA ───────────────────────────────────────────────────── # `bwrap` entra con `--clearenv` (ADR 0015: el ambiente también es una concesión), así que TODO lo # que el agente de shuma puso en el PTY se perdía en la frontera. Medido el 2026-09-28 sobre las # tres sesiones vivas: **7 variables adentro**, y ninguna de las del renderer. # # La que más cuesta es `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, que `shuma_config::agente:: # vars_del_renderer` pone en CADA PTY del agente justamente para que no pase esto: sin ella el CLI # se va a **pantalla alterna**, y ahí el cliente no puede hacer dos cosas —las dos reportadas como # defecto el 28-sep—: # · el reconocimiento en vuelo del cromo de claude no se dispara ni una vez ⇒ «me sale plano»; # · la cosecha del historial **saltea el alt-screen a propósito** (`TuiSession::ingerir`, # `if screen.alternate_screen() { return }`) ⇒ al volver a la pestaña sólo está la pantalla # viva, «sólo poquito output, lo último escrito». # O sea: el arreglo estaba puesto del lado del agente desde el 2026-09-04 y esta línea lo anulaba. # # `COLORTERM` va por lo mismo (sin él el CLI degrada a 256 colores) y `SHUMA_SESSION` porque es lo # que le deja a un hook enlazar a ESTA sesión y no a otra. # # Viaja como UN posicional con tokens `K=V` separados por espacios, y se expande **sin comillas** a # propósito: `env` recibe un argumento por token. Ninguno de estos valores lleva espacios (un `1`, # `truecolor`, un ULID); el día que haga falta uno que sí, esto pide otra forma de pasarlo. _lleva= for _v in CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY COLORTERM SHUMA_SESSION; do eval "_val=\${$_v:-}" [ -n "$_val" ] && _lleva="$_lleva $_v=$_val" done exec takana qorpa run "$_inst" -- sh -c \ 'hogar="$1"; proy="$2"; yo="$3"; lleva="$4"; shift 4 cd "$proy" 2>/dev/null || { echo "claude: adentro de la jaula no existe \"$proy\" — ¿está concedido ese directorio en la instancia? (ver grants.dirs)" >&2; exit 1; } # `/work//bin` trae el toolchain del CORPUS (make, python3, rustc, cargo, takana…): # musl estáticos del store, que corren dentro de la imagen glibc. Lo arma jaula-herramientas.sh. # ⚠ EL PATH DEL LAB, que ningún doc decía y muerde en el primer build (2026-09-18, reportado por # el agente de adentro): `takana build` invoca `go` y `zig` por nombre, y en la imagen de la jaula # no están. Muere con `spawn go mod vendor: No such file or directory`, que no menciona el PATH. # Viven en el lab, que ya está concedido. # shellcheck disable=SC2086 # `$lleva` se parte en tokens K=V a propósito — ver arriba exec env HOME="$hogar" TERM="${TERM:-xterm-256color}" \ PATH="/work/$yo/bin:/work/dev-fs/tools/go/bin:/work/dev-fs/tools/zig:$PATH" \ $lleva \ /work/claude-cli/2.1.240 "$@"' \ _ "$_hogar" "$_proy" "$_yo" "$_lleva" "$@"