DEV Community

Cover image for Cómo envolver Codex CLI desde Python igual que lo hace osFoundry
BBT for BBT Español

Posted on

Cómo envolver Codex CLI desde Python igual que lo hace osFoundry

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"'
Enter fullscreen mode Exit fullscreen mode

Vamos parte por parte:

  • exec con el prompt como un único argumento posicional. Codex no tiene flag para el system prompt, así que osFoundry monta ese argumento como system + "\n\n" + transcript.
  • --json hace que stdout sea un objeto JSON por línea. Ese stream es la única salida que lee osFoundry.
  • --skip-git-repo-check permite arrancar la ejecución en una carpeta que no es un repositorio.
  • --ignore-user-config se salta ~/.codex/config.toml, así los servidores MCP propios del usuario quedan fuera de la ejecución. La autenticación se sigue cargando desde CODEX_HOME.
  • --ignore-rules solo se añade si codex exec --help lo lista. osFoundry se lo pregunta al binario porque las versiones antiguas rechazan el flag, y un flag desconocido tumba toda la ejecución.
  • -s junto con approval_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:

  1. 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.
  2. 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:]
Enter fullscreen mode Exit fullscreen mode

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 pass a 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 --help para 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.

Top comments (0)