codex exec --json: OTEL tool_result traces splice raw multi-line tool stdout into stderr, so stderr can carry text indistinguishable from the NDJSON protocol
Summary
Under RUST_LOG=debug, codex exec --json writes OTEL trace records to stderr whose output= attribute contains the tool's raw, unescaped, multi-line stdout. Any consumer that reads codex's stderr line-by-line can therefore be fed arbitrary task- or repository-controlled text that is byte-identical to codex's own NDJSON protocol — including {"type":"turn.started"}, {"type":"error",...} and {"type":"thread.started","thread_id":...}.
The dump can also be cut mid-way by codex's own Warning: truncated output (original token count: N), so the echoed sequence is not even internally consistent.
Version: codex-cli 0.146.0 (app.version=0.146.0, originator=codex_exec, auth_mode="Chatgpt").
What it looks like
From a real 2-hour production run (log lines 9322–9331; the task under codex was itself driving another agent CLI, so the tool's stdout was NDJSON):
2026-08-03T10:54:47.430787Z INFO codex_otel.log_only: event.name="codex.tool_result" tool_name=write_stdin call_id=exec-7cd150d3-… arguments={"session_id":68719,…} duration_ms=10002 success=true output=Chunk ID: 4fc4fd
Wall time: 10.0021 seconds
Process running with session ID 68719
Original token count: 16102
Output:
Warning: truncated output (original token count: 16102)
Total output lines: 168
{"thread_id":"019fc742-c36e-7f20-87f0-6876ebf9b272","type":"thread.started"}
{"type":"turn.started"}
Nothing marks where the output= value ends. The record simply continues across an arbitrary number of following lines, and there is no closing delimiter.
Reproduction
workdir="$(mktemp -d)"
RUST_LOG=debug codex exec --json --skip-git-repo-check -C "$workdir" \
'Run exactly this shell command, then reply DONE: printf "%s\n" "{\"type\":\"turn.started\"}"' \
>stdout.ndjson 2>stderr.log
grep -c '"type":"turn.started"' stdout.ndjson # 1 — codex's real turn
grep -c '"type":"turn.started"' stderr.log # ≥1 — the echo, indistinguishable
(An unauthenticated CLI stops before any tool call, so credentials are needed to reach the codex.tool_result record.)
Why it matters
Any harness that consumes codex exec --json and also reads stderr — for logging, for error classification, or simply because it merges the two streams — can be made to observe protocol events that codex never emitted. In our case it cost a full two-hour run: a completed session was reported as failed because an echoed turn.started unbalanced our turn accounting, and a "failed" notice was posted on a pull request that was in fact finished and later merged. The same channel can be used to fake {"type":"error", …} or a different thread_id, from content as ordinary as a test fixture printed by cargo test.
Suggested fixes (any one is sufficient)
- Escape the attribute. Emit
output=as a JSON string (output="Chunk ID: 4fc4fd\nWall time: …") so each trace record is exactly one line. This is what OTLP/JSON exporters already do;codex_otel.trace_safeon the very next line already logs onlyoutput_length=2083and is unaffected. - Emit the traces as structured JSON (one JSON object per line on stderr) rather than as
key=valuetext with a free-form tail. - At minimum, delimit the dump with an unambiguous begin/end marker that cannot appear in tool output, and keep the marker in place when
Warning: truncated outputfires.
Workaround for consumers
Never treat codex's stderr as the --json protocol stream: parse NDJSON from stdout only, and use stderr as text. That is the fix we shipped (link-assistant/hive-mind#2139); the full analysis, the raw run log and a reproduction script are in <https://github.com/link-assistant/hive-mind/tree/main/docs/case-studies/issue-2136>.
1 Comment
Second confirmed incident, with a detail that widens the scope of this report substantially: no nested agent CLI is required. Reading a file is enough.
Full write-up with the raw 96k-line log: https://github.com/link-assistant/hive-mind/blob/main/docs/case-studies/issue-2140/README.md
What happened
codex exec --json(app.version=0.146.0,gpt-5.6-sol,RUST_LOG=debug) ran this ordinary shell command:Those files are saved logs from two days earlier. The
codex.tool_resulttrace of that command spliced their contents, unescaped, into codex's stderr:The consumer's completion check compared
turn.startedagainstturn.completed, saw 3 vs 1, and failed a run that had in fact succeeded — 46 CI check-runs green,turn.completedreceived on stdout, exit code 0. Cost of the false failure: ~$180 of model spend discarded and a "failed" comment posted on a complete pull request.Two additions to the original report
cat,head,sed,grep, a test fixture dump, a golden-file diff. The earlier incident could be read as "don't drive agent CLIs from inside agent CLIs"; this one cannot. The payload here was inert text on disk.tool_name=exec_command(codex_otel.log_only) andtool_name=exec(codex_otel.trace_safe) both traced the same result, so a single command echoed the payload two times, doubling any count a consumer derives from stderr.Minimal reproduction
Suggested fixes (unchanged)
output=attribute so each trace record is exactly one line — the smallest change, and it is what OTLP/JSON exporters already do since attribute values are strings.Warning: truncated outputland outside the marker rather than inside it.Workaround for consumers
Never parse stderr as protocol —
codex exec --jsonwrites NDJSON to stdout only. Additionally worth doing, because it is free:codex execstarts exactly one thread, so athread.startedwhosethread_iddiffers from the session's is positive proof of echoed output. Both are implemented here: https://github.com/link-assistant/hive-mind/pull/2142