Este es el comando que ejecuta la app de escritorio de osFoundry cuando pulsas Verify sign-in para Codex. Lo construye build_codex_command en src-tauri/src/providers/agent_cli.rs, la misma función por la que pasa cualquier ejecución real de cli:codex:agent:
codex exec "Reply with the single word: ok" \ --json \ --skip-git-repo-check \ --ignore-user-config \ --ignore-rules \ -s read-only \ -c 'approval_policy="never"' Vamos parte por parte:
-
execcon el prompt como un único argumento posicional. Codex no tiene flag para el system prompt, así que osFoundry monta ese argumento comosystem + "\n\n" + transcript. -
--jsonhace que stdout sea un objeto JSON por línea. Ese stream es la única salida que lee osFoundry. -
--skip-git-repo-checkpermite arrancar la ejecución en una carpeta que no es un repositorio. -
--ignore-user-configse salta~/.codex/config.toml, así los servidores MCP propios del usuario quedan fuera de la ejecución. La autenticación se sigue cargando desdeCODEX_HOME. -
--ignore-rulessolo se añade sicodex exec --helplo lista. osFoundry se lo pregunta al binario porque las versiones antiguas rechazan el flag, y un flag desconocido tumba toda la ejecución. -
-sjunto conapproval_policy="never"fija el sandbox y le dice a Codex que no habrá nadie para aprobar nada. Una ejecución autónoma recibe-s workspace-write. Una que conecta el puente MCP de osFoundry recibe en su lugar--dangerously-bypass-approvals-and-sandbox, y solo dentro de un git worktree.
Antes de lanzar el proceso, osFoundry también elimina CODEX_API_KEY, OPENAI_API_KEY y OPENAI_BASE_URL del entorno del proceso hijo. Si alguna estuviera definida, la ejecución iría a una cuenta de API de pago por uso en lugar de al inicio de sesión de ChatGPT.
El resto del post trata del otro extremo de esa tubería. Tienes una CLI de agente cuyo formato de stream no está garantizado, y necesitas sacar de ella la respuesta, el progreso y un fallo limpio.
¿Por qué leer eventos en lugar de un archivo con la respuesta final?
Hay dos formas de sacar una respuesta de una CLI como esta:
- Respuesta final en un archivo. Pasas una ruta de salida (
codex exec -o FILE; las notas del propio módulo de osFoundry la llaman la salida a archivo documentada), esperas a que el proceso termine y lees el archivo. El contrato es un archivo con un string dentro. - Stream de eventos en stdout. Pasas
--json, lees línea a línea y te quedas con los items que entiendes.
El builder actual de osFoundry hace lo segundo. El parser está en runtime/session_event.rs (parse_codex_line) y se fija en muy poco:
| Evento | Se convierte en |
|---|---|
item.completed + agent_message | texto de la respuesta |
item.completed + reasoning | línea Thinking: … |
item.completed + command_execution | Using shell — <command> |
item.completed + mcp_tool_call | Using <tool>… (Codex da server y tool por separado) |
item.completed + file_change / todo_list | Edited <path> / Plan: 1. … 2. … |
turn.completed | uso (input_tokens, cached_input_tokens, output_tokens) |
turn.failed, error | fallo, marcado como de autenticación si el mensaje lo parece |
Todo lo demás se ignora, igual que cualquier línea que no sea JSON. Esa tolerancia es lo que permite que esto funcione sin un esquema estable. Un tipo de evento desconocido no cuesta nada. Solo un campo renombrado en una de esas siete filas puede romper el parser.
El precio es la granularidad. Codex emite items completos, no deltas de tokens. La respuesta aparece de golpe cuando el mensaje termina, y por eso la app marca Codex como No live stream y lo describe como run-then-emit. Las líneas de progreso son distintas: llegan a medida que se completa cada item.
Reconstruir ambas en un Mac
En la máquina que usé no hay ningún binario de Codex instalado. Así que escribí fake_codex.py, un sustituto que acepta los mismos flags, imprime los tipos de evento de arriba con retardos fijos, escribe el mensaje final en el archivo -o al salir y se le puede pedir que se cuelgue, que se caiga o que falle la autenticación. No es Codex. Lo que te permite medir es el comportamiento del wrapper, no la velocidad de Codex.
La línea de tiempo del sustituto: un item reasoning a los 0.4 s, luego un proceso hijo de shell (sleep 0.8), luego el agent_message unos 1.3 s después, y luego turn.completed 0.2 s más tarde. El archivo de respuesta se escribe al salir.
El wrapper, wrap.py, usa solo la librería estándar en Python 3.9.6. El núcleo de la estrategia de eventos, recortado:
def run_events(argv, timeout, env=None, group=True, drain_stderr=True): t0 = time.monotonic() p = subprocess.Popen(argv + ["--json"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, start_new_session=group) err = [] if drain_stderr: threading.Thread(target=_drain, args=(p.stderr, err), daemon=True).start() lines = queue.Queue() threading.Thread(target=pump, args=(p.stdout, lines), daemon=True).start() deadline = t0 + timeout while True: left = deadline - time.monotonic() if left <= 0: os.killpg(p.pid, signal.SIGKILL) if group else p.kill() r["error"] = "timed out after %ss" % timeout break try: at, raw = lines.get(timeout=left) except queue.Empty: continue if raw is None: # stdout closed break try: v = json.loads(raw) except ValueError: continue # log line on stdout t = v.get("type") if t == "item.completed": item = v.get("item", {}) if item.get("type") == "agent_message": r["text"] += item.get("text", "") else: r["steps"].append(item.get("type")) elif t == "turn.completed": r["usage"] = v.get("usage") elif t in ("turn.failed", "error"): r["error"] = (v.get("error") or {}).get("message") or v.get("message") p.wait() if not r["error"] and not r["text"].strip(): r["error"] = "returned no output (%s)" % tail(err)[-200:] La estrategia de archivo es el mismo spawn con -o <tmp> en lugar de --json, luego p.wait(timeout=…) y después se lee el archivo si existe.
Tiempo hasta el primer byte frente a tiempo hasta la respuesta
Entorno de prueba: Apple M1, macOS 15.8.1, 20 ejecuciones por estrategia, medianas, cronometradas con time.monotonic() desde justo antes de Popen (bench.py).
eventos (--json) | archivo (-o) | |
|---|---|---|
| primer byte en stdout | 0.034 s | ninguno |
| primer item completado | 0.443 s | ninguno |
| respuesta disponible | 2.562 s | 2.824 s |
| proceso terminado | 2.775 s | 2.824 s |
| ejecuciones correctas | 20 / 20 | 20 / 20 |
Los números absolutos salen de los sleeps del sustituto. Dos cosas de la tabla no:
- Con un archivo, no sale nada hasta que el proceso termina. Durante 2.8 s quien llama no tiene forma de distinguir "pensando" de "atascado". Con eventos, la primera línea de progreso llega a los 0.44 s, y los bytes empiezan a los 0.034 s, más o menos lo que le cuesta a Python lanzar el proceso (un
python3 -c passa secas dio de media 19 ms aquí). - El archivo solo existe después del último paso de la CLI. En esta ejecución eso fue 0.26 s después de que el mensaje ya hubiera aparecido en stdout. Con un agente real, la diferencia es lo que sea que haga la CLI entre terminar su mensaje y salir.
¿Qué aspecto tiene un fallo desde cada lado?
El mismo wrapper, una ejecución por caso:
| Caso | exit | estrategia de eventos | estrategia de archivo |
|---|---|---|---|
| crash antes de responder | 101 | error, con el final de stderr como pista, 0.44 s | error: no hay archivo de respuesta, 0.51 s |
fallo de autenticación (turn.failed 401) | 1 | 401 Unauthorized: please log in, marcado como de autenticación | no hay archivo de respuesta + texto de stderr |
matado después del mensaje (sin turn.completed) | 137 | se conserva la respuesta, sin error | no hay archivo de respuesta |
| cuelgue más allá de un deadline de 3 s | -9 | timed out after 3s a los 3.004 s | timed out after 3s a los 3.027 s |
Aquí importan dos filas.
Autenticación. El stream de eventos te dice que fue un problema de login en un campo estructurado. La estrategia de archivo solo tiene la prosa de stderr para adivinar. osFoundry compara el mensaje con una lista corta (401, unauthorized, please log in, sign in, …) y muestra su propio aviso de inicio de sesión.
Matado después del mensaje. Las dos estrategias no coinciden. Leyendo eventos, tienes un agent_message completo y ningún turn.completed. Leyendo un archivo, no tienes nada. osFoundry decide según lo que dijo el stream: la ejecución falla si llegó un evento turn.failed/error o si no llegó ningún texto, y el código de salida no entra en esa decisión. Si prefieres tratar la falta de turn.completed como un resultado parcial, ese es el sitio donde añadirlo.
¿Qué mata realmente un timeout?
osFoundry envuelve toda la ejecución en tokio::time::timeout: 1,800 s para una ejecución autónoma, 300 s para la comprobación de inicio de sesión y otras completions sin streaming. El hijo se lanza con kill_on_drop(true), así que cuando el timeout descarta el future, el proceso muere.
Pero una CLI de agente tiene sus propios hijos. El sustituto arranca sleep 30 como su "herramienta de shell" y se queda colgado en él. Comprobé ese PID 0.3 s después de que el wrapper devolviera el control:
| Kill | el wrapper devolvió | hijo de la herramienta después |
|---|---|---|
p.kill() (solo el hijo directo) | 3.010 s | sigue en ejecución |
start_new_session=True + os.killpg | 3.011 s | desaparecido |
Mismo tiempo, limpieza distinta. Si el hijo de la herramienta mantiene abierta tu tubería de stdout, un lector que depende del EOF tampoco llega a verlo nunca, y por eso el bucle de arriba comprueba un deadline en lugar de esperar a que readline devuelva algo.
¿Por qué vaciar stderr en su propio hilo?
Estas CLIs escriben los fallos de autenticación y los panics en stderr, así que osFoundry lo canaliza y guarda el final (unos 4,000 bytes en la ruta del agente) para el mensaje de error. Pero una tubería tiene un buffer fijo. En este Mac, un hijo se bloqueó tras exactamente 65,536 bytes escritos en un stderr que nadie leía (pipecap.py). Después hice que el sustituto escribiera unos 200 KB de trazas antes de responder:
| Manejo de stderr | resultado |
|---|---|
| vaciado en un hilo mientras se lee stdout | respuesta a los 2.56 s |
| leído solo después de que se cierre stdout | sin respuesta, timed out after 8s |
La CLI se bloquea en una escritura que nadie lee, stdout sigue abierto y la ejecución se pierde por el deadline. Leer stderr en paralelo cuesta un hilo.
Si lo montas tú mismo
- Elige el stream de eventos antes que el archivo de respuesta si alguien va a seguir la ejecución. Fíjate solo en los pocos tipos de evento que necesitas, y sáltate las líneas que no sean JSON y los tipos desconocidos en lugar de fallar con ellos.
- Mete el system prompt en el texto del prompt cuando la CLI no tenga flag para ello, con un separador simple, y conserva los turnos anteriores como un transcript etiquetado.
- Decide de antemano si el éxito lo marca el código de salida o el stream, y qué cuenta como un mensaje sin
turn.completed. - Dale al hijo su propio grupo de procesos y mata el grupo en el timeout, para que los procesos de las herramientas caigan con él.
- Lee stderr en paralelo y guarda un final acotado. Ahí vienen los errores de autenticación que vas a querer mostrar.
- Quita las variables de entorno que redirigirían un login de suscripción a una clave de pago por uso, y consulta
--helppara los flags opcionales en lugar de mantener una tabla de versiones.
La fila de Codex descrita arriba está en la pestaña External AI del diálogo BYOK keys de la app.
