Variables de entorno: quién valida qué
El repo conoce muchas variables y casi ninguna es suya. Esta guía dice a quién pertenece
cada una, qué se valida y dónde, qué lee cada harness al arrancar y qué se conecta fuera
de
.env. La regla corta: nada bloquea la instalación ni el doctor; cada valor lo
valida el código que lo lee, con un error que lo nombra.
La lista de variables con su alcance vive en cli/lib/variables-manifest.ts;
la decisión, en .context/ADR/ADR-0005-validation-scope.md. Esta página es la
guía para personas: si alguna vez no coinciden, manda el manifest.
Los tres alcances
Cada variable del manifest lleva un scope. El alcance decide quién tiene
derecho a exigirla y, por tanto, cómo se reporta su ausencia.
| Alcance | Qué es | Ejemplos | Si falta |
|---|---|---|---|
Framework (core) |
Lo que lee el boilerplate. Casi todas están detrás de un interruptor: la sincronía con Xray, el host de Jira, el portal privado de reportes. |
TEST_ENV (tiene default), AUTO_SYNC,
ATLASSIAN_EMAIL y ATLASSIAN_API_TOKEN (cuando el host de
Jira está en .agents/project.yaml), XRAY_* (cuando
AUTO_SYNC=true)
|
Un aviso del doctor mientras el interruptor esté encendido. El código detrás del interruptor falla nombrándola. |
| Tooling |
Herramientas que consiguen su credencial en otro sitio. En .env solo
quedan los secretos que usa CI.
|
SLACK_WEBHOOK_URL, PORTAL_* y R2_* (portal
privado, solo en GitHub Actions); SLACK_MCP_* (el bot de Slack, solo si
el proyecto lo usa). La búsqueda web, Postman y los logins de acli o
resend no son variables de este archivo.
|
Una fila informativa. Nunca un bloqueo. |
Proyecto bajo prueba (project) |
La aplicación que pruebas: su login, su base de datos, su API. El boilerplate las
declara como ejemplos tipados para que un .env copiado de la plantilla
valide; renómbralas o bórralas al adaptar el framework.
|
LOCAL_USER_EMAIL y STAGING_USER_PASSWORD (nombres de
ejemplo), DBHUB_*, API_BASE_URL,
OPENAPI_SPEC_PATH
|
Una fila informativa. El consumidor que la lee falla nombrándola: un login vacío lo
dice config.testUser en la primera línea de la corrida, no la app.
|
Los nombres de los interruptores también están en el manifest (featureGate): el
host de Atlassian en el yaml, AUTO_SYNC, la pareja AUTO_SYNC más
TMS_PROVIDER=xray, y la presencia de PORTAL_URL.
Qué valida varlock y dónde
varlock lee dos archivos versionados que declaran nombres, tipos y sensibilidad, nunca
valores (un tercero, opcional, conecta un gestor de secretos: ver
Gestores de secretos). Los valores siguen en .env.
| Archivo | Quién lo escribe | Qué declara |
|---|---|---|
.env.core.schema |
Generado desde el manifest con bun run vars:schema; lo sincroniza
bun sync. No se edita a mano.
|
Las tres secciones, una por alcance. Lo único que varlock se niega a cargar sin
valor es TEST_ENV, y tiene default. Todo lo demás es opcional y tipado.
|
.env.schema |
Tuyo. Se entrega una vez y el updater no lo vuelve a tocar. | Importa el core y añade las variables de tu proyecto. Ahí puedes endurecer un item que el core declara opcional: la declaración del proyecto gana. |
# en .env.schema: exigir la pareja de staging en TU proyecto
# @required=forEnv(staging) @type=email
STAGING_USER_EMAIL=
bunx varlock load --agent # valida .env con los valores enmascarados
bun run vars:schema:check # el core está al día, todo secreto es @sensitive y el par carga
varlock corta un valor sin comillas en el primer #:
PASSWORD=pass#word llega como pass. Pon entre comillas cualquier
valor que tenga #: PASSWORD="pass#word".
@currentEnv resuelve TEST_ENV al inicio y varlock rechaza una
segunda declaración del mismo item. Por eso el core conserva su enum y el proyecto no la
redeclara; el valor válido lo decide config/variables.ts, que falla nombrando
los entornos que sí conoce.
Dónde corre cada validación: bun run vars:schema:check en el pre-commit y en
repo:check valida el repo, no tu .env. También falla si una
variable con nombre de secreto (TOKEN, SECRET,
PASSWORD, API_KEY...) no lleva @sensitive:
varlock load --agent solo enmascara lo marcado, y el resto lo imprime tal cual.
varlock load en el pre-push solo avisa. La acción de varlock en
build.yml valida el esquema contra el job y pasa sin secretos, así que un PR
desde un fork también pasa. bun run test:env:check comprueba la forma: que
TEST_ENV sea un entorno declarado y que exista la pareja del TMS cuando
AUTO_SYNC=true.
Qué lee cada harness
Un harness arranca sus servidores MCP antes de que corra cualquier hook, y una app de
escritorio o un worker lanzado de forma nativa no trae variables en su entorno. Por eso los
tres harnesses leen igual: cada MCP que necesita valores de .env arranca con el
mismo loader delante, en .mcp.json, opencode.jsonc y
.codex/config.toml.
bunx -p varlock@<pin> varlock run --no-redact-stdout --inject vars --filter A,B -- <server>
| Pieza | Qué hace |
|---|---|
--filter A,B |
El server recibe solo esas variables: una clave del esquema heredada que no está en
la lista se quita, y la validación mira solo la lista. Es también la lista de
dependencias que bun run agents:compat:check compara entre los tres
hosts
|
--inject vars |
Variables sueltas, sin el blob __VARLOCK_ENV |
--no-redact-stdout |
No toca la salida: el server habla JSON-RPC por stdio y varlock, por defecto, redacta lo que sale por una tubería |
bunx -p varlock@<pin> |
La versión exacta de la devDependency: basta con bun install una vez,
sin el binario suelto
|
El loader lee el esquema y .env / .env.local (o el gestor de
secretos que nombre el esquema) desde la raíz del proyecto, no depende de cómo abriste el
harness y no deja ninguna copia en texto plano. Si falta una variable, ningún harness falla
al arrancar: el server muere en su primera llamada autenticada. Un valor que no pasa el
esquema frena solo al server que lo filtra. Para ver si un MCP conecta, usa
/mcp dentro de la sesión de Claude Code u opencode mcp list.
Después de tocar .env, reinicia el agente. Si una versión anterior de
bun run harness:env dejó copias en el bloque env de
.claude/settings.local.json o en .auth/opencode/, el comando de
ahora las retira y setup:doctor las reporta. En Windows es igual: abres
claude, opencode o codex (o su app de escritorio) y
cada MCP lee .env por su cuenta. Ningún secreto se exporta a tu shell: los
scripts de Bun (bun run jira:*, bun run api:login,
bun xray) leen .env con su propio autoload y acli usa
su login guardado.
Validar en el punto de uso
El instalador ofrece credenciales y nunca las exige. El updater marca como requerida solo una variable de framework. El doctor no devuelve error por ninguna credencial. Lo que sí pasa es que el código que necesita un valor falla con un mensaje que nombra la variable, el archivo que la declara y qué hacer si tu app no tiene esa cosa.
- El login de la app
-
config.testUserenconfig/variables.tses un getter. Los proyectosui-setupyapi-setupde Playwright lo leen al arrancar, así que una pareja vacía es la primera línea de la corrida. Si tu app no tiene login, quita esos dos proyectos deplaywright.config.tsy nadie vuelve a leerlo. - El entorno
-
Un
TEST_ENVqueenvDataMapno declara falla nombrando los entornos válidos, en lugar de romper más abajo con unundefined. - Jira y Xray
-
Cada script de sincronía y el CLI de Xray fallan por nombre cuando les falta la
credencial. La sincronía de resultados se valida solo cuando
AUTO_SYNC=true: en CI corta la corrida, en local avisa. - El portal privado de reportes
-
scripts/ci/publish-allure-portal.tsexige por nombre cada variable del portal (PORTAL_*yR2_*) solo cuando el workflow encuentraPORTAL_URL; sin ella publica en GitHub Pages.
MCPs locales y MCPs a nivel del harness
El principio: un server remoto cuyo único contenido del lado del proyecto era una API key es
asunto del harness, no del repo. Solo los servers locales (stdio) viven en
.mcp.json, opencode.jsonc y .codex/config.toml. El
navegador no es un MCP: toda la automatización de navegador pasa por
/playwright-cli.
| Server | Dónde vive | Por qué |
|---|---|---|
Los servers locales: los que declara .mcp.json (con sus espejos en
opencode.jsonc y .codex/config.toml)
|
En el proyecto. Las variables que lee cada uno, y su alcance, están en
cli/lib/variables-manifest.ts
|
Corren en tu máquina y leen lo tuyo: la base de datos y la API de tu app, por
ejemplo, con variables de alcance proyecto como DBHUB_* u
OPENAPI_SPEC_PATH. Uno sin clave no tiene nada que autenticar; uno
opcional sin su token arranca vacío y ningún skill lo llama
|
Los remotos con clave del usuario: los que lista
cli/lib/harness-level-mcps.ts, como la búsqueda web
|
A nivel del harness, una vez por máquina |
Son remotos y su clave es tuya, no del proyecto. Los skills los resuelven por
capacidad: buscan una herramienta cuyo nombre termine en
web_search_exa (Exa, primera opción) o en
tavily_search (Tavily, segunda), sea cual sea el prefijo del server.
|
Cómo conectar uno a nivel del harness:
| Harness | Dónde queda | Cómo |
|---|---|---|
| Claude Code | ~/.claude.json (ámbito de usuario) o un conector de claude.ai |
claude mcp add --scope user --transport http <nombre> <url>, o conectarlo desde los ajustes de claude.ai. Compruébalo con
/mcp dentro de la sesión.
|
| OpenCode |
~/.config/opencode/opencode.json: clave mcp.servers en
OpenCode 2, mcp en OpenCode 1
|
En OpenCode 2,
opencode mcp add <nombre> --global --url <url>. En OpenCode
1, añádelo a mano en la config global. El opencode.jsonc
del proyecto no lo redeclara.
|
| Codex |
~/.codex/config.toml, tabla [mcp_servers.<nombre>]
|
codex mcp add <nombre> --url <url> y después
codex mcp login <nombre> (OAuth). Evita
--bearer-token-env-var si usas la app de escritorio: lee una variable
del entorno del proceso, y abierta desde el Dock no tiene ninguna.
|
Los archivos MCP de un proyecto están protegidos: bun sync nunca los
sobreescribe. Si todavía declaran un server de la lista del harness, la actualización deja
una fila de paridad que lo explica, y las dos respuestas son válidas: dejarlo como server
propio del proyecto, o quitarlo y conectarlo a nivel del harness. El porqué está en
.context/ADR/ADR-0005-validation-scope.md.
Logins de CLI
Los CLIs se autentican solos y guardan su propia sesión. Nada de esto va en
.env.
# Atlassian CLI: sesión persistente en ~/.config/acli/
echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \
--site "$(bun run --silent jira:url --slug)" --email "$ATLASSIAN_EMAIL" --token
# Resend CLI: guarda la key en el llavero del sistema
resend login
La pareja ATLASSIAN_* sigue en .env porque los scripts de
sincronía, el CLI de Xray y el proveedor de TMS la leen por REST; el login de
acli no los sustituye. RESEND_API_KEY no va en .env:
nada del repo la lee y el CLI de Resend guarda su propia sesión.
Si tu .env todavía tiene una clave retirada (la lista es
RETIRED_KEYS en cli/lib/env-schema.ts), te ofrecen borrar esas
líneas con una sola confirmación, antes de validar: bun setup en una tarjeta de
la fase de credenciales (en la página local, o en la terminal con --headless) y
bun run setup:doctor en la terminal. El esquema no las declara, y varlock
rechaza una clave no declarada que está vacía. Sin terminal interactiva (o con
bun setup --non-interactive) solo las listan: nunca editan el archivo ni
muestran valores.
Qué te dice el doctor
bun run setup:doctor lista cada variable con su alcance, su interruptor y quién
la consume, y añade una sección con los servers a nivel del harness que encuentra en tu
configuración de usuario. Un conector de claude.ai no deja archivo, así que ahí dice "no
detectable", nunca "falta". El código de salida solo cambia por hallazgos estructurales: no
hay .env, queda una copia en texto plano de una credencial MCP, el esquema no
carga.
bun run setup:doctor # tabla por alcance + servers a nivel del harness
bun run setup:doctor --json # env_var_scopes[] y harness_level_mcps para un agente
Lo que la IA puede ver
La IA usa cada secreto por su nombre y nunca por su valor (Regla Crítica #1). Escribe
$STAGING_USER_PASSWORD dentro del comando, la terminal lo expande y en la
conversación solo queda el resultado. No abre .env, .env.local,
.auth/ ni .claude/settings.local.json, y tampoco corre
printenv, env ni curl -v, porque cualquier valor que
aparezca en pantalla viaja al proveedor del modelo y se queda en la transcripción local.
.env.example y los esquemas versionados (.env*.schema) sí los lee:
solo tienen nombres y referencias, nunca valores.
Para saber si una variable está cargada, la IA corre bunx varlock load --agent,
que lista cada variable por nombre y tapa los valores @sensitive. Si falta un
secreto o está mal, te dice cuál es y lo escribes tú en tu terminal o en tu gestor de
secretos; nunca lo pegues en el chat. Un valor que no es secreto (una URL, una clave de
proyecto, un flag, un puerto) sí puede escribirlo la IA en .env si se lo pides,
y solo con bun run env:set CLAVE=valor: el script rechaza cualquier clave que
el schema marque @sensitive o cuyo nombre suene a secreto, y no imprime
valores.
Claude Code y OpenCode traen reglas de denegación para esos archivos (.claude/settings.json
y opencode.jsonc), así que un descuido se bloquea antes de leer nada. Son una
red de seguridad y se pueden esquivar con un subproceso; lo que obliga a la IA es la regla.
El detalle completo, con los comandos seguros y qué hacer si un valor se filtra, está en
.agents/skills/agentic-qa-core/references/secret-hygiene.md.
Gestores de secretos
Es la opción avanzada. El camino por defecto es .env, y sigue
siéndolo: un clon recién hecho, un estudiante o una laptop sin internet no necesitan nada
más. Un gestor sirve cuando un equipo comparte los secretos de un proyecto y no quiere
pasarse valores por chat ni tenerlos en claro en cada máquina. El primero que trae el repo
es 1Password; el diseño deja el mismo hueco para cualquier otro gestor que varlock sepa
leer.
| Pieza | Qué hace |
|---|---|
secrets: en .agents/project.yaml |
provider: local | 1password (por defecto local), el vault,
la cuenta y el modo de autenticación (app o
service-account).
|
.env.provider.schema |
Lo escribe bun setup cuando eliges el gestor, y se versiona. Tiene
referencias (op(op://vault/VARIABLE/password)), nunca un valor. Cada
línea viene comentada: descomentas las que tu vault tiene.
|
.env.core.schema |
Lo importa con allowMissing=true. Si el archivo no existe, nada cambia.
|
El orden de precedencia no cambia: un valor no vacío en .env.local,
.env o exportado en la terminal gana sobre el vault, y entonces el vault ni
siquiera se consulta. Para leer una clave del gestor, déjala vacía (o quítala) de
.env. Así un compañero sin acceso al vault sigue trabajando con su
.env.local. El autoload de Bun no resuelve las referencias de 1Password, así
que un script que necesita un secreto del vault corre con
bunx varlock run -- <comando>.
1Password, paso a paso
-
Instala la app de escritorio y su CLI,
op(brew install 1password-clien macOS). - En la app: Settings > Developer > "Integrate with 1Password CLI". La app te pide huella o contraseña al cargar; no queda ningún token en disco.
-
Corre
bun setupy, en la fase de credenciales, elige 1Password en la tarjeta "Dónde viven los secretos de este proyecto?" (en la terminal si corres con--headless). Después te pregunta el vault, la cuenta y cómo se autentica la laptop. Para un equipo, el vault es uno compartido (<proyecto>-dev); con un plan personal, tu propio vault. -
En el vault, un item de tipo Password por variable, con el NOMBRE de la variable como
título. En
.env.provider.schema, descomenta esas líneas. - Comprueba sin ver valores:
bunx varlock load --agent.
En CI hace falta un service account con lectura sobre el vault del equipo. Su token
va en el secret de GitHub OP_SERVICE_ACCOUNT_TOKEN, al lado de los secrets por
variable de siempre (nunca en su lugar): un secret por variable que tiene valor sigue
ganando, y uno vacío ya no tapa el valor del vault porque el launcher de los scripts de test
(scripts/launch.ts) lo descarta antes de llamar a varlock. Un plan personal no
sirve para CI, porque un service account no puede leer un vault personal. Los pasos que
llaman a una herramienta directamente (bun xray, el publicador del portal)
siguen leyendo solo los secrets por variable.
varlock trae plugins para otros gestores (la lista está en su documentación). Ninguno
viene configurado aquí. Sumar uno es añadir su id y un adaptador en
cli/lib/secret-providers.ts; el archivo, el import, el launcher de los tests
y CI quedan igual. La decisión completa está en
.context/ADR/ADR-0010-secret-manager-advanced-option.md.