¿Cómo saber si Codex realmente cambió un archivo?
Respuesta corta: no por el código de salida. En 9 ejecuciones de codex exec del 19 de agosto de 2026 (codex-cli 0.147.0, macOS 26.5.2 en arm64), el proceso salió con código 0 todas las veces, incluidas las 6 en que el repositorio terminó byte a byte idéntico a como empezó. Dos señales dentro de la salida sí dicen la verdad: un evento item.completed de tipo file_change en la salida estándar, que apareció en 3 de 3 ejecuciones que editaron el archivo y en 0 de 6 que no, y una línea ERROR en la salida de error que dice patch rejected, que apareció en 6 de 6 ejecuciones bloqueadas y en 0 de 3 que escribieron.
El agente en sí no es el problema, y esa es la frase que vale llevarse: en toda ejecución bloqueada Codex dijo con todas las letras que no podía escribir. La prosa es honesta y el estado no. Corrimos la misma medición contra Claude Code el 18 de agosto de 2026 y su envoltorio JSON no tenía un campo equivalente a file_change: 13 de 13 ejecuciones volvieron con subtype: success e is_error: false, 10 de ellas sin nada cambiado. Si estás conectando un agente de programación con IA a tu CI, la diferencia entre esos dos envoltorios decide si tu pipeline puede separar trabajo de silencio.
¿Qué devolvió codex exec en 9 ejecuciones del 19 de agosto de 2026?
Le dimos a codex exec la misma tarea pequeña nueve veces, en nueve repositorios git desechables, uno por ejecución: agregar una función slugify a src/utils.js, exportarla, correr npm test y asegurarse de que pasa. Tres brazos, tres ejecuciones cada uno, que es una muestra pequeña, y el script completo que las produjo está publicado verbatim en la sección de reproducción de este artículo. Los brazos difieren solo en la política de sandbox: -s workspace-write, ninguna flag de sandbox, y -s read-only.
El juez no es el agente. Antes y después de cada ejecución, el aparato calculó el hash de todo archivo fuera de .git y comparó las dos huellas, así que "cambió algo" se responde fuera del alcance del agente. Esta es la tabla, reconstituida por script a partir de los nueve archivos JSONL crudos y no contada a mano:
| Brazo | Ejecuciones | Código de salida | Repositorio cambió | file_change en salida estándar | ERROR en salida de error |
|---|---|---|---|---|---|
-s workspace-write | 3 | 0 en 3 de 3 | sí en 3 de 3 | 1 en 3 de 3 | 0 en 3 de 3 |
| sin flag de sandbox | 3 | 0 en 3 de 3 | no en 3 de 3 | 0 en 3 de 3 | 1 en 3 de 3 |
-s read-only | 3 | 0 en 3 de 3 | no en 3 de 3 | 0 en 3 de 3 | 1 en 3 de 3 |
Una salvedad que la tabla sola no puede cargar: la segunda y la tercera fila son el mismo estado de sandbox alcanzado por dos caminos distintos, porque en nuestra medición codex exec sin flag -s se comporta exactamente como -s read-only. Dejamos las dos filas con esta nota en lugar de borrar un brazo en silencio. Nueve ejecuciones, nueve códigos de salida cero, seis repositorios intactos, y la falla visible en la salida de error en las seis mientras el código de salida se quedó en cero.
¿Por qué el código de salida no sirve para saber si Codex hizo el trabajo?
Porque el código de salida de codex exec responde a una pregunta distinta de la que estás haciendo. Responde "¿la sesión terminó normalmente?", y en las nueve ejecuciones terminó. No responde "¿se completó la tarea?", y nada en el contrato de la CLI dice que debería hacerlo. Las dos preguntas se ven idénticas desde un script de shell, que es exactamente por qué esto le cuesta tardes enteras a la gente.
El modo de falla es específico y silencioso. Un paso de CI que corre codex exec "arregla el test que falla" y después revisa $? va a ver 0 tanto si el agente reescribió el módulo como si se quedó quieto en un sandbox de solo lectura explicando que no podía. El paso queda verde. El siguiente corre. Nadie abre la transcripción, porque la transcripción es lo que se lee cuando algo se rompe, y nada se rompió.
Medimos la misma ceguera en Claude Code el 18 de agosto de 2026, lo que nos hace sospechar de un comportamiento por defecto de la industria en vez de una decisión de un fabricante. Dos fabricantes sugieren y no prueban, y vale decirlo acá y no solo en una sección de limitaciones al final: lo que podemos afirmar es que dos CLIs de dos empresas, medidas en la misma semana, salen las dos con cero en una ejecución que no produjo nada. Si tu pipeline trata la salida cero de un agente de programación como prueba de trabajo, viene reportando un éxito que nunca verificó. El arreglo no es discutir con el código de salida. El arreglo es dejar de hacerle esa pregunta y hacérsela al repositorio.
¿Qué señales de la salida de codex exec separan de verdad trabajo de silencio?
Dos, y viven en flujos distintos. En la salida estándar, corré codex exec con --json y la CLI imprime un objeto JSON por línea. En las ejecuciones donde slugify efectivamente entró en el archivo, apareció esta línea:
{"type":"item.completed","item":{"id":"item_2","type":"file_change","changes":[{"path":"/tmp/codex-envelope-19ago/repo-permitido-1/src/utils.js","kind":"update"}],"status":"completed"}}
Lleva la ruta y un kind, que en nuestro caso fue update, y coincidió con la huella del árbol en 9 de 9 ejecuciones: presente en las tres que cambiaron el repositorio, ausente en las seis que no. Es un detector positivo, y se dispara cuando hubo trabajo, en lugar de pedirte que deduzcas trabajo a partir de la ausencia de error.
La segunda señal está en la salida de error, y es la que casi dejamos de mirar. En 6 de 6 ejecuciones bloqueadas, y en 0 de 3 que escribieron, la salida de error lleva esta línea:
2026-08-19T10:26:59Z ERROR codex_core::tools::router: error=patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings
Esa línea corrige una predicción nuestra y una suposición que habríamos publicado. La predicción, escrita antes de correr nada, era que una escritura bloqueada aparecería dentro del stream JSON como comando con exit_code distinto de cero o como evento de falla, y no apareció: todo item command_execution en las nueve ejecuciones terminó con exit_code igual a 0, y ningún evento de la salida estándar llevó tipo de falla. La suposición era que el agente ni siquiera intentó la escritura. El log dice rejected, no salteado, así que la intentó. Tratá las dos señales como pista fuerte y no como prueba: coincidir con la verdad en nueve ejecuciones es coincidencia y no confiabilidad, y no fuimos a cazar el caso donde cualquiera de las dos se equivoca.
¿Codex admite que fue bloqueado o finge éxito?
Codex lo admite, con todas las letras, en 6 de 6 ejecuciones en que no cambió nada. Esto importa porque "el agente miente" es el encuadre popular y nuestra medición no sostiene esa acusación. Abajo está el comienzo del mensaje final de tres ejecuciones distintas, cada una cortada donde aparece el corchete, con las comillas invertidas de markdown del propio agente convertidas en código en línea y sus palabras, incluidos sus apóstrofos tipográficos, intactas:
"Blocked from editing: this workspace is read-only. Baseline
npm testpasses (1 test). The intended addition is: [...]""Blocked: this session’s workspace is read-only, so I couldn’t add
slugifytosrc/utils.js. [...]""Unable to modify
src/utils.js: the workspace is read-only and approvals are disabled. [...]"
Dos de las seis fueron más allá e imprimieron la función entera que habrían escrito, así que quien lee la terminal no pierde más que un copiar y pegar. La prosa es honesta. El estado no lo es. Es la misma separación que encontramos en Claude Code el 18 de agosto de 2026, donde el agente escribió "I'm not able to complete this" y aun así salió con cero.
La consecuencia es incómoda y específica: el engañado es justamente quien automatizó. Un desarrollador mirando la terminal ve el rechazo en la última línea. Un script de shell, un hook de git, un paso de GitHub Actions y un planificador leen todos el código de salida, y el código de salida dice que salió todo bien. Cuanto más sacás al humano del circuito, más caro sale este defecto, que es lo contrario de cómo suelen comportarse los defectos de herramienta.
¿Cuál es la diferencia con Claude Code?
En la frontera del proceso, ninguna. Los dos agentes salen con cero cuando no hicieron nada, los dos escriben prosa honesta sobre estar bloqueados, y ninguno cambia esa respuesta según haya habido trabajo o no. Quien esté decidiendo entre ellos por este criterio está eligiendo entre dos respuestas idénticas.
En la frontera de los eventos, la diferencia es real y favorece a Codex. El stream de --json de codex exec emite un item tipado file_change con las rutas que tocó. Cuando medimos el envoltorio JSON de Claude Code el 18 de agosto de 2026, no encontramos equivalente: los campos disponibles ahí (subtype, is_error, permission_denials) o decían success en toda ejecución, o, en el caso de permission_denials, venían poblados cuando una herramienta fue intentada y negada y vacíos cuando la negativa impidió que el intento ocurriera, lo que lo vuelve ambiguo exactamente en el caso en que lo necesitás. No corrimos la misma comparación de salida de error contra Claude Code, así que no afirmamos nada sobre lo que su salida de error lleva o deja de llevar.
Dos salvedades impiden que esto sea un marcador. Primera, los mecanismos que bloquearon la escritura no son el mismo: Codex bloquea en un sandbox del sistema operativo, Claude Code en modo headless bloquea porque se exige una aprobación y no hay humano para darla. Segunda, probamos una versión de cada uno, codex-cli 0.147.0 y Claude Code 2.1.235, y esta es la clase de superficie que cambia entre versiones. Lo que comparamos es el envoltorio de salida que cada uno le entrega a la automatización, no la calidad de ninguno de los dos agentes. Sobre la pregunta más amplia de cuál de los dos usar en el día a día, esta medición es una entrada y no la decisiva.
¿Por qué codex exec no cambia nada cuando no pasás flag de sandbox?
Porque en nuestra medición codex exec sin flag -s se comportó exactamente como -s read-only: el repositorio quedó intacto en 3 de 3 ejecuciones, no se emitió ningún evento file_change, la línea patch rejected apareció en la salida de error, y el agente dijo que el espacio de trabajo estaba montado como solo lectura. Reportamos esto como comportamiento medido de codex-cli 0.147.0 en macOS 26.5.2, y no como afirmación sobre lo que promete la documentación, y lo anotamos porque debilita nuestro propio experimento: lo que diseñamos como dos brazos independientes resultó ser un estado alcanzado por dos caminos.
Igual vale saberlo, y es la línea más útil en la práctica de este artículo para quien recién instaló la herramienta. La invocación no interactiva por defecto es justamente la que más parece estar funcionando mientras no hace nada. El agente lee tus archivos, razona sobre ellos, gasta tokens, escribe una respuesta considerada y deja el repositorio exactamente como lo encontró. Nada en la salida estándar grita. Recibís un párrafo bien pensado y un código de salida cero.
El movimiento práctico es ser explícito en vez de depender de un valor por defecto que no elegiste. Si querés ediciones, pasá -s workspace-write, que en nuestras ejecuciones produjo el cambio en 3 de 3 y emitió el evento file_change todas las veces. Si querés una pasada de análisis de solo lectura, pasá -s read-only a propósito, para que la intención esté en el comando y no en tu suposición.
¿Qué debería revisar tu CI en lugar del código de salida?
Revisá el repositorio, no el reporte. La verificación confiable más barata es una huella del árbol de trabajo tomada antes y después de que el agente corra, calculada por tu pipeline y no por el agente. La nuestra tiene tres líneas de shell y es la misma que juzgó cada número de este artículo:
impressao() {
( cd "$1" && find . -path ./.git -prune -o -type f -print0 \
| sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1 )
}
Dos señales más baratas están en la salida que ya estás capturando. Si parseás --json, el item file_change nombra las rutas que el agente afirma haber tocado, que es mucho más de lo que da un código de salida. Y si redirigís la salida de error, un grep ERROR simple agarra la línea patch rejected, que se disparó en 6 de 6 de nuestras ejecuciones bloqueadas y en ninguna de las tres que escribieron. Entre la huella, el evento y la línea de log tenés tres respuestas independientes a la misma pregunta, y el código de salida no es una de ellas. Cuando dos de ellas discrepen, encontraste algo que merece que leas la transcripción.
Una cuarta verificación no cuesta nada y agarra el caso que las otras tres dejan pasar: corré tu suite de tests vos mismo, en tu pipeline, después de que el agente salga. Un agente que edita un archivo y rompe el build va a producir un evento file_change, una huella distinta y una salida de error limpia, y las tres van a parecer éxito. Es la misma disciplina de verificar lo que un agente dice haber hecho contra una fuente que no controla, aplicada a la única parte del circuito donde nadie está mirando.
¿Cómo se reproduce esta medición?
Guardá el script de abajo como rodar.sh, dale permiso de ejecución y corrélo. Crea sus propios repositorios desechables dentro de /tmp, así que no va a tocar nada que te importe, y necesita codex en el PATH más Node.js para npm test. Este es el archivo que produjo las nueve ejecuciones, publicado exactamente como corrió, con los comentarios y el bug adentro, porque un artículo que te dice que desconfíes de los resúmenes no tiene derecho a publicar una versión prolija de su propio método:
#!/bin/bash
# Aparato: o mesmo desenho de 18/ago, agora contra o codex exec.
# Mede o ENVELOPE (exit code + eventos JSONL) contra a VERDADE (impressao digital da arvore).
BASE=/tmp/codex-envelope-19ago
SAIDA=$BASE/saida
mkdir -p "$SAIDA"
TAREFA='Add a function named slugify to src/utils.js that lowercases the string, replaces every run of non-alphanumeric characters with a single hyphen, trims leading and trailing hyphens, and export it. Then run npm test and make sure it passes.'
impressao() { # hash de todo arquivo versionavel, calculado FORA do agente
( cd "$1" && find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1 )
}
preparar() { # repo descartavel identico para cada run
local dir=$1
rm -rf "$dir"; mkdir -p "$dir/src"
cat > "$dir/src/utils.js" <<'JS'
export function trim(s) { return s.trim(); }
JS
cat > "$dir/package.json" <<'JSON'
{ "name": "descartavel", "version": "1.0.0", "type": "module", "scripts": { "test": "node --test" } }
JSON
cat > "$dir/utils.test.js" <<'JS'
import { test } from 'node:test';
import assert from 'node:assert';
import { trim } from './src/utils.js';
test('trim', () => { assert.equal(trim(' a '), 'a'); });
JS
( cd "$dir" && git init -q && git add -A && git commit -qm base )
}
rodar() { # braco, indice, flags de sandbox
local braco=$1 i=$2; shift 2
local dir=$BASE/repo-$braco-$i
preparar "$dir"
local antes; antes=$(impressao "$dir")
codex exec "$@" --skip-git-repo-check --json -C "$dir" "$TAREFA" \
< /dev/null > "$SAIDA/$braco-$i.jsonl" 2> "$SAIDA/$braco-$i.err"
local codigo=$?
local depois; depois=$(impressao "$dir")
local mudou=nao; [ "$antes" != "$depois" ] && mudou=sim
# o que o ENVELOPE diz, lido do JSONL bruto
local erros; erros=$(grep -c '"error"' "$SAIDA/$braco-$i.jsonl" 2>/dev/null || echo 0)
local falhou_cmd; falhou_cmd=$(grep -o '"exit_code":[1-9][0-9]*' "$SAIDA/$braco-$i.jsonl" 2>/dev/null | wc -l | tr -d ' ')
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$braco" "$i" "$codigo" "$mudou" "$erros" "$falhou_cmd" >> "$BASE/placar.tsv"
echo "[$braco-$i] exit=$codigo mudou=$mudou erros_json=$erros cmds_com_exit_nao_zero=$falhou_cmd"
}
printf 'braco\trun\texit\trepositorio_mudou\teventos_error\tcomandos_exit_nao_zero\n' > "$BASE/placar.tsv"
for i in 1 2 3; do rodar permitido $i -s workspace-write; done
for i in 1 2 3; do rodar padrao $i; done
for i in 1 2 3; do rodar bloqueado $i -s read-only; done
column -t "$BASE/placar.tsv"
El bug está en las dos líneas de conteo cerca del final. Ese idioma grep -c ... || echo 0 emite una línea suelta cuando el patrón no existe, lo que empujó una segunda línea dentro de cada registro y corrompió las dos últimas columnas de placar.tsv. No lo arregles y vuelvas a correr por nosotros: lo que hicimos en su lugar es el punto. Tiramos el conteo roto y reconstruimos cada número de este artículo a partir del JSONL crudo con un lector aparte, que es exactamente el movimiento que te estamos pidiendo que hagas con el resumen del agente.
Ese lector es la parte que vale copiar. Abre cada saida/<brazo>-<n>.jsonl, lee un objeto JSON por línea, cuenta las entradas item.completed cuyo tipo de item es file_change y cuyo status es completed, cuenta los items command_execution con exit_code entero distinto de cero, y cuenta todo evento cuyo tipo contenga failed o error. Esos contadores, sumados al código de salida y al veredicto de la huella, son la tabla entera. Reservá de dos a cuatro minutos de reloj para las nueve ejecuciones y contá con gasto real de tokens, porque cada ejecución es un turno completo de agente contra la API. Los streams crudos dentro de saida/ son la evidencia: si tus números no coinciden con los nuestros, la discrepancia está ahí adentro y no en el resumen.
Lo que esta medición no dice
No dice que file_change ni la línea de la salida de error sean confiables. Dice que las dos coincidieron con la huella del árbol en 9 de 9 ejecuciones, lo que es coincidencia y no confiabilidad. Un instrumento se gana el crédito cuando alguien caza el caso en que se equivoca, y nosotros no cazamos: el caso obvio sin probar es el del agente que escribe un archivo y después lo revierte, donde el evento se dispararía y la huella no se movería.
No dice nada sobre el uso interactivo. Todo acá es codex exec, el camino no interactivo, que es el que termina dentro de CI y de scripts. Tampoco cubre Cursor, Gemini CLI, Aider ni ningún agente que no hayamos corrido, y no cubre lo que Claude Code escribe en la salida de error, que no medimos.
La muestra es pequeña y está declarada como tal: tres ejecuciones por brazo, nueve en total, una máquina, una mañana, codex-cli 0.147.0 contra Claude Code 2.1.235. Dos de nuestros tres brazos resultaron ser el mismo estado de sandbox alcanzado por rutas distintas, cosa que descubrimos por boca del agente y no por nuestro diseño. Y la limitación más filosa es una que un revisor encontró en nosotros: nuestro primer borrador afirmaba que la escritura bloqueada no aparecía como falla en ninguna parte, y nuestros propios archivos de salida de error decían lo contrario en seis de las nueve ejecuciones. Habíamos capturado esa evidencia y no la habíamos leído, lo que es un argumento a favor de la disciplina de este artículo y no de nuestro rigor. Por último, nuestra tarea fue deliberadamente fácil y sin ambigüedad, así que medimos la diferencia entre hacer el trabajo y estar impedido de hacerlo. El caso más desprolijo y más común, el del agente que hace parte del trabajo y reporta éxito, es otra medición y esa todavía no la hicimos.