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.

Credencialesvarlock MCP
Fuente canónica

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
Un # sin comillas corta el valor

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".

La excepción es TEST_ENV

@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.testUser en config/variables.ts es un getter. Los proyectos ui-setup y api-setup de 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 de playwright.config.ts y nadie vuelve a leerlo.
El entorno
Un TEST_ENV que envDataMap no declara falla nombrando los entornos válidos, en lugar de romper más abajo con un undefined.
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.ts exige por nombre cada variable del portal (PORTAL_* y R2_*) solo cuando el workflow encuentra PORTAL_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.
Si tu proyecto declara uno de esos servers

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

  1. Instala la app de escritorio y su CLI, op (brew install 1password-cli en macOS).
  2. 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.
  3. Corre bun setup y, 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.
  4. 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.
  5. 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.

Otros gestores

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.