Construí claudebox porque dejar a Claude Code suelto en mi host me daba dolores en el pecho. Funcionó. Le creció una API HTTP, un endpoint compatible con OpenAI, un servidor MCP, un bot de Telegram y un scheduler de cron. Meses de trabajo, todo útil.
Luego apareció pi-coding-agent y quise exactamente lo mismo para él. Luego OpenAI sacó el CLI de Codex y lo quise una tercera vez.
Y ahí fue donde me senté y miré de verdad lo que iba a hacer: copiar y pegar un servidor FastAPI, un bot de Telegram con workspaces por chat, un scheduler de cron con directorios de historial y un servidor MCP en un segundo repo. Luego en un tercero. Tres copias de las mismas novecientas líneas, separándose en el instante en que arreglo un bug en una y me olvido de las otras. Tres sitios que parchear cuando Telegram cambia una regla de renderizado. Tres suites de tests probando lógica idéntica contra agentes distintos.
A la mierda. Nada de esa fontanería es específico de Claude. Nada es específico de pi. Todo se reduce a «coge un prompt, tira de un CLI, atrapa lo que vuelve, dáselo a quien lo pidió». El agente es un detalle. Las superficies son el producto.
Así que las arranqué y las metí en una imagen base. Eso es aicodebox.
El Problema de Todo Proyecto de «Pon Un Agente En La Red»
Cada una de estas herramientas comete el mismo error, la mía incluida, la primera vez: suelda el transporte al cerebro.
Quieres tu agente alcanzable por HTTP, así que escribes un servidor HTTP que sabe invocar ese binario concreto, parsear ese formato de salida concreto y manejar ese conjunto de flags concreto. Seis meses después hay un agente mejor, y tú eres dueño de un montón de infraestructura que solo habla con el viejo.
Los síntomas son siempre los mismos:
- Actualizaciones a base de fork y carnicería. ¿Quieres las mismas superficies para otro agente? Haces fork del repo, buscas cada sitio donde está hardcodeado el nombre del binario viejo y esperas haberlos pillado todos.
- Arreglos de bugs que divergen. El renderizador de markdown de Telegram tiene un bug. Lo arreglas en un repo. Los otros dos se quedan con el bug un mes, hasta que te acuerdas.
- Superficies inconsistentes. Una caja tiene runs async, otra no. Una tiene un endpoint de OpenAI, otra tiene la mitad. Nada compone porque nada se pone de acuerdo en una forma de cable.
- Lock-in con el agente por accidente. No porque nadie decidiera encerrarse, sino porque la fontanería creció alrededor de las manías de un binario y se petrificó ahí.
El arreglo no es un wrapper mejor. Es admitir que el wrapper no debería saber qué está envolviendo.
Cómo Funciona Esta Mierda De Verdad
aicodebox es una imagen Docker base. No le haces fork. Le haces FROM.
FROM psyb0t/aicodebox
RUN npm install -g @earendil-works/[email protected]
COPY mypkg /opt/mypkg
RUN pip3 install --break-system-packages /opt/mypkg
ENV AICODEBOX_ADAPTER=mypkg.adapter:MyAdapter
AICODEBOX_AGENT_BINARY=piEsa es toda la integración. La línea de npm install es incidental, es solo cómo se distribuye pi-coding-agent. Podría ser igual de bien un apt-get install, un binario copiado de una etapa de build de Go, o cualquier otra cosa que deje un ejecutable en el PATH; mira cómo acaba instalándose el agente más abajo. La base es dueña de todas las superficies de red. Tu adaptador traduce «ejecuta este prompt» a lo que espere el CLI de tu agente. Un agente nuevo aterriza en una tarde, y aterriza con una API HTTP, un endpoint compatible con OpenAI, un servidor MCP, un bot de Telegram y un scheduler de cron ya enganchados, porque esas ya no son tuyas de escribir.
Lo que hay de verdad en la imagen:
- El sistema operativo: Ubuntu 24.04, un usuario
aicodecon UID 1000, sudo sin contraseña y pertenencia al grupo docker. - Los runtimes: Node.js 24 LTS (la mayoría de agentes se distribuyen como paquetes npm), Python 3.14, y Docker CE con buildx y compose, para cuando tu agente decida que necesita parir sus propios containers.
- El paquete:
aicodebox, el contrato de adaptador más cuatro despachadores de modos. Python puro, cero efectos secundarios hasta que arrancas un modo de verdad. - El estado: los overrides por chat y el historial de cron viven bajo
$HOME/.aicodebox/. Hazle bind-mount si quieres que sobreviva al container. El paquete en sí no guarda nada.
El Contrato de Adaptador Son Dos Métodos
Esta es la parte de la que sí estoy orgulloso, porque es pequeña. Todo pasa por una sola interfaz, y su superficie obligatoria son dos métodos.
from aicodebox.adapters.base import AgentAdapter, RunRequest, RunResult, StreamEvent
class MyAdapter(AgentAdapter):
name = "my-agent"
available_models = ["fast", "smart"]
available_thinking_levels = ["off", "low", "high"]
def build_argv(self, req: RunRequest) -> list[str]:
argv = ["my-agent", "-p", req.prompt]
if req.model: argv += ["--model", req.model]
if req.workspace: argv += ["--cwd", req.workspace]
return argv
def parse_output(self, stdout: str, req: RunRequest) -> RunResult:
return RunResult(text=stdout.strip(), raw_stdout="", raw_stderr="", exit_code=0)build_argv dice cómo invocar la cosa. parse_output dice cómo leer lo que volvió. Ese es el contrato. Todo lo demás, validate, build_env, translate_auth, post_validate_json, parse_events, parse_stream_event, es opcional, con valores por defecto que hacen lo aburrido y correcto.
parse_stream_event merece una mención: el valor por defecto es «un delta por línea de stdout», que va bien para un binario que solo escupe prosa. Sobreescríbelo solo si tu agente emite un flujo estructurado de eventos JSON y quieres granularidad por token o por herramienta en el streaming de OpenAI. La mayoría de adaptadores no se molestarán.
El adaptador se resuelve en la primera llamada y se cachea para toda la vida del proceso. Cada modo tira del mismo, así que lo que build_argv sepa conducir es exactamente lo que queda expuesto por HTTP, MCP, Telegram y cron. Cero trabajo de integración por modo. Lo escribes una vez, te llevas cinco formas de entrar.
Cuatro Modos, Pones Un Flag
Los modos son variables de entorno. Pones el flag, el entrypoint arranca ese modo. Sin flag, sin modo, el container simplemente ejecuta tu agente en interactivo como una shell normal.
Los modos de primer plano (API, Telegram, Cron) se excluyen mutuamente, con una excepción deliberada: Telegram y Cron comparten un proceso, porque el cron corre en hilo dentro del bot de Telegram. La API gana si la pones junto a otra cosa. MCP es independiente y convive con cualquiera de ellos.
El modo API
AICODEBOX_API_MODE=1 arranca FastAPI en :8080.
POST /run # sync run
POST /run {"async": true} # returns {runId, status} immediately
GET /run/result?runId=X # poll an async run
DELETE /run/{id} # kill an in-flight run
GET|PUT|DELETE /files/{path} # workspace file CRUD
POST /v1/chat/completions # OpenAI-compatible
GET /v1/models # model list from the adapter
POST /mcp # MCP, when AICODEBOX_MCP_MODE=1Un solo flag dicta la forma de la respuesta. POST /run sin jsonSchema te da la versión flaca: {runId, workspace, exitCode, text}. Solo la prosa. Pasa un jsonSchema y te llevas toda la superficie de diagnóstico, text, json, events, sessionId, usage, attempts. Por debajo el agente se invoca en modo json-verbose, su salida decodificada y validada contra tu esquema.
Ese fue todo el diseño durante un tiempo: esquema significa superficie completa, sin esquema significa flaca, dos formas de cable y un flag. Aguantó hasta que dejó de aguantar. Querer el flujo crudo de eventos sin querer también un esquema es un deseo real, y la forma vieja te obligaba a inventarte un esquema para conseguirlo. Así que desde la v0.14.6 hay un segundo dial, eventMode, y es el arreglo honesto en vez de un flag que doble el primero a escondidas: auto mantiene el comportamiento viejo, none tira los eventos por completo, y full te devuelve los registros del proveedor intactos en un sobre estable {sequence, attempt, backend, eventType, event}. La retención de eventos es ahora independiente de si pediste salida estructurada. Si un reintento de esquema quema tres intentos, te llevas los registros de los tres, que es justo cuando los quieres de verdad.
Los reintentos son honestos sobre lo que cuestan. Ante un fallo de parseo o validación, el wrapper vuelve a promptear hasta tres veces (JSON_RETRY_MAX = 3) con la salida mala anterior y el error concreto. Si fallan los tres, parseError y jsonRetries sustituyen a json, pero text, events, sessionId, usage y attempts vuelven igualmente, porque un run estructurado que ha fallado es exactamente cuando necesitas los diagnósticos.
Y usage es la suma de todos los intentos, no el último. Tu proveedor te factura por intento; reportar solo el intento final sería una mentira. attempts lleva el desglose por intento para que puedas pintar «el reintento 2 de 3 costó esto» o refacturarlo.
El truco de reintento que más me gusta. Las peticiones con esquema que no nombran un workspace reciben uno efímero por petición bajo /tmp/aicodebox/<uuid>/, limpiado en un finally. Como la sesión persiste, los reintentos mandan un prompt correctivo mínimo, el error, una directiva, el esquema, en vez de reproducir todo tu input original. En un prompt grande eso es aproximadamente cien veces menos coste de input por reintento. Si sí le pasas tu propio workspace, cae de vuelta a reintentos con sesión nueva que reformulan la tarea: seguro en cualquier workspace, solo que más caro.
El endpoint compatible con OpenAI
POST /v1/chat/completions, con y sin streaming. Apunta LiteLLM, Open WebUI o cualquier SDK de OpenAI a él y tu agente de código aparece como un modelo.
La salida estructurada va por el campo estándar, response_format con {"type":"json_object"} para permisivo o {"type":"json_schema", ...} para salidas estructuradas de verdad. Hay una cabecera x-aicodebox-json-schema como recurso para clientes que no pueden poner el campo del body; el body gana si están los dos. Reintentos agotados devuelve 422. El proceso del agente reventando devuelve 500 con el código de salida y stderr en detail.
Llamada a herramientas ejecutada por el cliente. Manda un array tools estándar y la caja se comporta como un modelo normal de function calling: responde con tool_calls y finish_reason: "tool_calls", tu cliente ejecuta la herramienta y devuelve el resultado role: "tool", el bucle sigue. Sin estado, reenvías todo el historial en cada vuelta, exactamente como OpenAI. tool_choice admite auto / none / required / una función con nombre.
Y tools compone con response_format. Un turno de llamada a herramienta devuelve llamadas a herramientas y deliberadamente no se valida contra el esquema; el turno de respuesta final del modelo sí se valida contra tu esquema con reintentos y vuelve como JSON canónico. Así que un flujo agéntico con varias herramientas puede acabar igualmente en una respuesta estructurada, que es justo lo que quieres cuando metes esto en un pipeline.
Una reserva honesta: el streaming con tools o response_format es SSE con buffer. La respuesta completa se calcula y luego se reproduce como un flujo de eventos de una sola tacada, chunk de rol al abrir, un delta, chunk de cierre, [DONE]. Es un flujo válido, solo que no es incremental por token. El chat normal sigue haciendo streaming como debe.
El modo Telegram
AICODEBOX_TELEGRAM_MODE=1 más un token de bot. Entra texto, ocurre un run del agente, la respuesta vuelve troceada y renderizada de Markdown al dialecto HTML de Telegram. Las subidas, documentos, fotos, vídeo, voz, aterrizan en el workspace de ese chat. El agente empuja ficheros de vuelta emitiendo [SEND_FILE: relative/path] en su salida.
Overrides por chat para /model, /effort, /system_prompt y /append_system_prompt, persistidos a disco. /cancel mata el run en vuelo, /reload relee el yaml, /config vuelca la config combinada, /fetch trae un fichero del workspace, /status lista los chats ocupados.
El detalle que lo hace usable en el día a día: responder a un mensaje que disparó el cron inyecta la instrucción y el resultado de ese job en el contexto, así que tu pregunta de seguimiento sí tiene sentido para el agente, en vez de llegarle sin la menor idea de qué le estás hablando.
El modo Cron
Horarios croniter de seis campos, workspace por job, notificación de Telegram opcional.
jobs:
- name: morning-report
schedule: "0 0 9 * * *"
instruction: |
Summarize yesterday's git activity in {workspace}.
workspace: shared
telegram_chat_id: -100123
model: claude-sonnetCada run recibe su propio directorio de historial, meta.json, stdout.log, stderr.log, result.txt, y telegram.json si notificó. Luego el prompt del run siguiente recibe una pista que apunta a ese directorio.
Ese último detalle es pequeño y cambia lo que pueden ser estos jobs. El agente puede leer su propia salida pasada sin que tú construyas nada de esa fontanería. «Qué cambió desde ayer», «esto ha regresado», «compara con la semana pasada», todo funciona porque el historial está en disco y al agente se le ha dicho dónde mirar.
El modo MCP
AICODEBOX_MCP_MODE=1. En modo API se monta en /mcp en el mismo puerto, sin proceso extra. Bajo Telegram, cron o passthrough normal corre como un uvicorn sidecar en AICODEBOX_MCP_MODE_PORT (por defecto 8081).
Cinco herramientas: run_prompt, list_files, read_file, write_file, delete_file. Apunta Claude Desktop, Cursor u otro agente a él y tu agente de código se convierte en una herramienta que otros agentes pueden llamar.
La auth es AICODEBOX_MCP_MODE_TOKEN, su propio bearer, sin recurso al token de la API. Eso es deliberado. MCP es una superficie separada con exposición separada, y aceptar en silencio el bearer de la API ahí significaría darle a cada cliente de la API una herramienta de ejecución de agente para la que nunca estuvo autorizado. Hay un parámetro de query ?apiToken= para clientes que no pueden poner cabeceras.
Configuración, Con Una Convención De Verdad
Todo son variables de entorno, y el nombrado sigue una sola regla: <MODE>_MODE es el flag de encendido, <MODE>_MODE_<KNOB> es su configuración, y lo que no está ligado a un modo va pelado.
AICODEBOX_ADAPTER # required — pkg.module:Class
AICODEBOX_AGENT_BINARY # required — the CLI binary name
AICODEBOX_WORKSPACE # default /workspace
AICODEBOX_AVAILABLE_MODELS # required for API mode
AICODEBOX_API_MODE # 0/1 + _PORT, _TOKEN
AICODEBOX_TELEGRAM_MODE # 0/1 + _TOKEN, _CONFIG, _OVERRIDES
AICODEBOX_CRON_MODE # 0/1 + _FILE
AICODEBOX_MCP_MODE # 0/1 + _PORT, _TOKENLa lista de modelos se resuelve en dos pasos: AICODEBOX_AVAILABLE_MODELS gana si está puesta, si no cae de vuelta a lo que declare tu adaptador en su variable de clase available_models. Así que la variable de entorno no es estrictamente obligatoria, es el override. Declara la lista en el adaptador y no tendrás que ponerla nunca.
Lo que sí es obligatorio es que una de las dos produzca algo. Si las dos vuelven vacías, el modo API se niega a arrancar, loguea el motivo y sale con código distinto de cero en vez de levantarse roto. Esa es la decisión correcta: /v1/models necesita una lista real y no hay recurso seguro, porque el nombre del adaptador no es un nombre de modelo. Mejor petar a gritos en el arranque que servir una lista de modelos basura que rompe el cliente de alguien tres capas más abajo.
Dos imágenes, y los hijos dejaron de reconstruir el mismo toolchain
La base se distribuye ahora en dos variantes. psyb0t/aicodebox:latest es la mínima descrita arriba, y psyb0t/aicodebox:latest-full (etiquetada por versión como v0.15.0-full) carga el toolchain de desarrollo compartido: Go, Node, Python, editores, diagnósticos, clientes de bases de datos, herramientas de ops.
Ese corte existe por un problema de duplicación que había reaparecido en silencio. Cada imagen hija que quería un entorno de desarrollo de verdad instalaba el mismo Go, el mismo Node, el mismo Python, el mismo psql y redis-cli, desde su propio Dockerfile y sus propios lockfiles. Tres copias del toolchain, separándose exactamente igual que se separaban antes las tres copias del servidor HTTP. La misma enfermedad, una capa más abajo.
Así que se mudó a la base. Los hijos lo heredan partiendo de la variante full en vez de construirlo ellos mismos, y la limpieza no fue sutil: codexbox borró unas 15.800 líneas de sus propios lockfiles de toolchain, el Dockerfile.full de claudebox perdió 173 líneas. make build-full, make build-all y make test-full-image cubren la variante, y CI publica primero la imagen mínima y luego construye la full encima.
La Familia
Tres imágenes corren sobre esta base ahora mismo, todas fijadas de momento a psyb0t/aicodebox:v0.15.1:
- claudebox: Claude Code. Su v2.0.0 fue un rebase completo sobre esta base; ahora aporta
ClaudecodeAdaptery hereda todas las superficies. - pibox: pi-coding-agent, apuntado al LLM que te dé la gana. Este es el hijo de referencia: usa la base tal cual y añade
PiAdapter. - codexbox: el CLI de Codex de OpenAI, vía
CodexAdapter.
Cómo acaba instalándose el agente de verdad
Las tres resulta que instalan su agente con npm install -g, pero no leas eso como la receta, es solo que claude-code, pi y codex se distribuyen todos como globales de npm. La base no tiene ninguna opinión sobre cómo llegó ahí tu binario.
El contrato real son dos cosas: el binario nombrado por AICODEBOX_AGENT_BINARY tiene que existir en el PATH, y tu paquete de adaptador tiene que ser importable. Ya está. Lo que te lleve hasta ahí es asunto tuyo:
- npm:
RUN npm install -g @vendor/[email protected]. Node 24 LTS ya está en la base, que es exactamente por lo que los tres hijos existentes tomaron esta ruta. - apt: es Ubuntu 24.04 con sudo y un apt que funciona.
RUN apt-get update && apt-get install -y your-agentvale perfectamente si alguien realmente distribuye un deb. - pip / uv: Python 3.14 está ahí, y uv también, fijado por digest. Así es como cada hijo instala ya su adaptador:
uv pip install --system --break-system-packages --no-deps /opt/yourpkg. - Un binario compilado: Go, Rust, lo que sea. Ojo, la base no lleva un toolchain de Go ni de Rust, así que no esperes que
go installfuncione de primeras. O te traes el toolchain en tu propia capa, o haces lo sensato y usasCOPY --from=desde una etapa de build para que el compilador no aterrice nunca en la imagen de runtime:
FROM golang:1.26 AS build
RUN go install github.com/someone/[email protected]
FROM psyb0t/aicodebox
COPY --from=build /go/bin/some-agent /usr/local/bin/some-agent
COPY myadapter /opt/myadapter
RUN uv pip install --system --break-system-packages --no-deps /opt/myadapter
ENV AICODEBOX_ADAPTER=myadapter.adapter:MyAdapter
AICODEBOX_AGENT_BINARY=some-agentLas mismas cinco superficies al otro lado. La base nunca se entera de en qué lenguaje se escribió tu agente, y ese es todo el sentido, lo único que hace es tirar de un nombre del PATH y entregarle los bytes a tu parse_output.
pibox es el mínimo honesto. npm install del agente, uv pip install del paquete de adaptador, poner exactamente tres variables de entorno, AICODEBOX_ADAPTER, AICODEBOX_AGENT_BINARY y una etiqueta PIBOX_IMAGE_VARIANT, y darle un entrypoint pequeño y con marca que aliasa PIBOX_* a AICODEBOX_*. Esa es toda la imagen hija. Por algo es la implementación de referencia.
Las otras dos son más grandes, y esa es la parte interesante. claudebox pone cinco variables de entorno, codexbox cuatro, y ambas llevan un script de lanzamiento extra encima del adaptador. No porque la base tenga fugas, sino porque cada agente arrastra su propio problema de estado del que una base agnóstica al agente no tiene permitido enterarse:
- claudebox pone
CLAUDE_CONFIG_DIRpara que Claude Code escriba.claude.jsony sus credenciales en el bind mount en vez de en un$HOMEsin montar, porque si no repites el selector de tema y el login en cada recreación del container. Solo claudebox sabe que la carga es Claude Code, así que solo claudebox puede poner eso. - codexbox pone
CODEX_HOMEpor la misma razón (una clave de API o un token OAuth de suscripción de ChatGPT que tiene que sobrevivir a una recreación) y tiene que hacerlemkdirychownen tiempo de build, porque codex peta al arrancar siCODEX_HOMEapunta a un directorio que no existe ya. - Ambas apuntan
AICODEBOX_AGENT_BINARYa un script de lanzamiento en vez de al binario del agente directamente. El de claudebox restaura los valores interactivos por defecto que la base agnóstica al agente dejó caer deliberadamente:--continuecon un plan B,--permission-mode bypassPermissions, y la inyección always-skills vía--append-system-prompt. Los modos de servidor se saltan el lanzador por completo y construyen argv a través del adaptador.
Esa es la forma real de esta abstracción, y prefiero describirla con exactitud que fingir que cada hijo son cuatro líneas. La base es dueña de todo lo que no es específico del agente. Lo que queda en cada hijo es exactamente lo que sí lo es: el adaptador que sabe que este binario quiere -p mientras aquel quiere --prompt, que uno emite stream-json mientras otro imprime texto plano, más la manía de persistencia de configuración que te imponga ese agente en concreto.
Todo lo demás, la API, el shim de OpenAI con sus reintentos de esquema y su llamada a herramientas, el bot de Telegram, el scheduler de cron con su historial, el servidor MCP, se hereda. Arreglas un bug en la base, le pones tag, subes el pin en tres hijos, listo. Que es todo el sentido, y la razón por la que claudebox v2 existe siquiera.
Cómo Lanzar Una
docker run --rm -p 8080:8080
-e AICODEBOX_API_MODE=1
-e AICODEBOX_API_MODE_TOKEN=$(openssl rand -hex 16)
-e AICODEBOX_AVAILABLE_MODELS=fast,smart
-v "$PWD/workspace:/workspace"
your/child-image:latestLuego le hablas como si fuera OpenAI, o le pegas a /run directamente, o apuntas un cliente MCP a ella, o no tocas HTTP en absoluto y dejas que el cron la dispare a las nueve de la mañana.
El bucle de reinicio del container que no parecía absolutamente nada
El mejor bug que ha producido esta cosa, porque cada señal que te daba era mentira.
Síntoma: en modo api el container reinicia periódicamente. Sin crash, sin OOM, sin stack trace. El código de salida es 0. El bucle se correlaciona con la actividad de peticiones y no con la memoria ni el uptime, lo que te manda a mirar completamente al sitio equivocado.
Lo que pasaba de verdad: el subproceso del agente compartía el grupo de procesos de lo que hubiera lanzado el servidor. En modo api el servidor es el PID 1. Así que cuando el CLI del agente, o cualquier herramienta que el propio agente hubiera parido, entregaba un SIGTERM o un SIGINT a ese grupo compartido, uvicorn lo pillaba también y se apagaba. Limpiamente. Deliberadamente. Salida 0, trabajo hecho.
Y no lo veías nunca, porque uvicorn.run(..., log_config=None) suprime sus propias líneas de apagado. Un apagado pulcro, silencioso y absolutamente correcto, que la política de reinicio del container deshacía después, una y otra vez.
El arreglo es un flag en aicodebox/shared/runner.py, start_new_session=True al parir el agente, tanto en la ruta síncrona run() como en la de streaming run_stream(). Sesión propia, grupo de procesos propio, las señales dejan de viajar hacia arriba.
Los tests de regresión espían ambas primitivas de spawn y comprueban que el flag se pasa, así que quitarlo de cualquiera de los dos sitios rompe el build en vez de reintroducir en silencio un bucle que nadie puede leer en los logs.
Fija la v0.14.5, no la v0.14.4. El mismo arreglo, pero pyproject.toml llevaba parado en 0.14.0 desde la v0.14.1 hasta la v0.14.3 mientras el Makefile deriva de él la etiqueta de la imagen, y la v0.14.5 es la republicación donde el fichero y la etiqueta por fin se ponen de acuerdo.
En Resumen
El agente que usas hoy no es el agente que usarás dentro de un año. No es pesimismo, es simplemente el ritmo de lanzamientos, este espacio se reescribe cada pocos meses, y cualquier cosa que construyas fuertemente acoplada a un CLI es andamiaje con fecha de caducidad.
Así que no te acoples. Mete las superficies en una imagen base, mete el agente detrás de un adaptador de dos métodos, y cuando aterrice el siguiente escribes cuarenta líneas en vez de forkear novecientas. Ya lo he hecho tres veces. La tercera me llevó una tarde.
github.com/psyb0t/docker-aicodebox
Bajo licencia WTFPL, porque una imagen base que existe específicamente para que no tengas que forkearla sería una cosa estúpida sobre la que poner una licencia restrictiva.