CLI trust prompt exits when split terminal Device Attributes tail is read as input

Resolved 💬 5 comments Opened Jul 7, 2026 by mydearniko Closed Aug 4, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.142.5

The same startup-probe logic also appears to be present on current main as of cca16a1 (codex-rs/tui/src/terminal_probe.rs still uses the 100 ms startup probe deadline and does not drain an incomplete primary Device Attributes response before returning to normal TUI input).

What subscription do you have?

Not relevant to the failure mode; the TUI exits at the directory trust prompt before any model request. I can provide this if maintainers need it for triage.

Which model were you using?

Not model-specific. The TUI exits at the directory trust prompt before any model request.

What platform is your computer?

Linux x86_64, Debian 13 over SSH.

Suggested exact command to paste from the affected shell:

uname -mprs

What terminal emulator and version are you using?

Affected session is over SSH with TERM=xterm-256color. Server-side detection did not identify the terminal emulator. No active tmux server was detected in the capture.

Please fill in the local terminal app/version if known.

Codex doctor report

Not included yet because the affected machine currently has a local patched binary installed, so runtime.provenance would report a local build rather than the original installed package. The captured pre-patch run was on codex-cli 0.142.5, Debian 13, SSH, TERM=xterm-256color.

I can provide a fresh codex doctor --json from an unpatched binary if maintainers need it, but the key signal here is the terminal I/O trace below.

What issue are you seeing?

When launching Codex in a directory that has not yet been trusted, the trust prompt appears briefly and Codex exits with status 0 without any user input.

It behaves as if option 2. No, quit was selected automatically. Disabling keyboard enhancement probing avoids the failure, but that is not a good workaround because it disables keyboard protocol behavior needed for input such as Shift+Enter.

Captured Codex log:

Codex exit status: 0
2026-07-07T14:39:26.983401Z  INFO codex_tui::tui: terminal startup probes completed duration_ms=100 cursor_position=true default_colors=true keyboard_enhancement_supported=Some(true)

Relevant terminal I/O after the prompt is rendered:

write(1, "... 1. Yes, continue ... 2. No, quit ... Press enter to continue ...", 369) = 369
read(0, "\x32\x3b\x32\x33\x3b\x32\x34\x3b\x32\x38\x3b\x33\x32\x3b\x34\x32\x3b\x35\x32\x63", 1024) = 20

The read bytes decode to:

2;23;24;28;32;42;52c

That is the tail of a primary Device Attributes response. Because the first byte is ASCII 2, the trust prompt treats it as selecting No, quit.

What steps can reproduce the bug?

This needs a terminal/SSH/PTY path that splits the primary Device Attributes response so the prefix is read during Codex's 100 ms startup probe and the tail arrives after the trust prompt is already active.

On the affected terminal:

mkdir -p /tmp/codex-trust-untrusted-repro
unset CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT
codex --no-alt-screen -c log_dir=/tmp/codex-debug -C /tmp/codex-trust-untrusted-repro

Observed result: the trust prompt flashes, then Codex exits status 0.

For diagnosis:

strace -f -ttt -s 1024 -e trace=read,write,poll \
  -o /tmp/codex-trust.strace \
  codex --no-alt-screen -c log_dir=/tmp/codex-debug -C /tmp/codex-trust-untrusted-repro

In the failing capture, Codex reports keyboard_enhancement_supported=Some(true) at exactly the startup probe deadline, renders the trust prompt, and then reads the delayed PDA tail (2;23;24;28;32;42;52c) as normal input.

What is the expected behavior?

The directory trust prompt should remain open and wait for an explicit user selection. Terminal probe response bytes should not be interpreted as trust prompt input.

Additional information

Likely root cause:

  • terminal_probe::startup() sends batched startup probes, including CSI ? u for keyboard enhancement support and ESC[c as the primary Device Attributes fallback.
  • In read_startup_probe(), the probe returns when the 100 ms deadline expires or poll_readable() times out.
  • parse_keyboard_enhancement_support() can mark keyboard support as present after receiving keyboard flags, while the primary Device Attributes fallback response is still only partially received.
  • If the buffer ends with an incomplete PDA prefix at the deadline, the unread PDA tail remains in the terminal input stream.
  • The trust prompt then starts normal TUI input, reads the PDA tail, and interprets the leading 2 as the No, quit menu choice.

Proposed low-risk fix:

Before returning from read_startup_probe() on timeout/poll expiry, if the keyboard probe was queried and the current probe buffer ends with an incomplete primary Device Attributes-like suffix (ESC[? followed only by digits/semicolons, with no final c yet), do a bounded drain read for that response only. Return as soon as the PDA completes or no more bytes arrive. This avoids a global startup delay and keeps keyboard enhancement support enabled.

A local patch using a 1 second maximum drain, only in that partial-PDA state, fixed the issue in the affected terminal and passed focused codex-tui terminal probe tests.

View original on GitHub ↗

5 Comments

mydearniko · 1 month ago

I now have a deterministic PTY reproducer for this. It simulates a terminal that returns cursor position, OSC 10/11 colors, and keyboard flags promptly, but splits the primary Device Attributes response so its tail arrives after the 100 ms startup probe deadline.

Results:

# unpatched codex-cli 0.142.5
sent_cursor=True sent_colors=True sent_keyboard=True sent_da=True
still_running_after_delayed_da=False
terminal startup probes completed duration_ms=99 cursor_position=true default_colors=true keyboard_enhancement_supported=Some(true)

# same version with a local patch that drains an incomplete PDA suffix before normal TUI input resumes
sent_cursor=True sent_colors=True sent_keyboard=True sent_da=True
still_running_after_delayed_da=True
terminal startup probes completed duration_ms=350 cursor_position=true default_colors=true keyboard_enhancement_supported=Some(true)

The 350 ms in the patched run is the synthetic delay below. The proposed fix does not add a global 350 ms startup delay; it only waits when the startup probe buffer already ends with an incomplete PDA-like suffix (ESC[? followed by digits/semicolons with no final c yet), and returns as soon as that suffix completes or no more bytes arrive.

Reproducer:

import os, pty, select, subprocess, sys, time

codex = sys.argv[1]
work = f"/tmp/codex-pty-delay-work-{int(time.time())}"
log = f"/tmp/codex-pty-delay-log-{int(time.time())}"
os.makedirs(work, exist_ok=True)
os.makedirs(log, exist_ok=True)

master, slave = pty.openpty()
env = os.environ.copy()
env.pop("CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT", None)
env["TERM"] = "xterm-256color"
cmd = [codex, "--no-alt-screen", "-c", f"log_dir={log}", "-C", work]
proc = subprocess.Popen(
    cmd,
    stdin=slave,
    stdout=slave,
    stderr=slave,
    env=env,
    close_fds=True,
)
os.close(slave)

out = bytearray()
sent_cursor = sent_colors = sent_keyboard = sent_da = False
start = time.time()

def write(data):
    os.write(master, data)

while time.time() - start < 4:
    r, _, _ = select.select([master], [], [], 0.02)
    if r:
        try:
            data = os.read(master, 4096)
        except OSError:
            break
        if not data:
            break
        out.extend(data)
        if (not sent_cursor) and b"\x1b[6n" in out:
            write(b"\x1b[9;1R")
            sent_cursor = True
        if (not sent_colors) and b"\x1b]10;?" in out and b"\x1b]11;?" in out:
            write(b"\x1b]10;rgb:f5f5/f5f5/f5f5\x1b\\")
            write(b"\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\")
            sent_colors = True
        if (not sent_keyboard) and b"\x1b[?u" in out:
            write(b"\x1b[?7u")
            sent_keyboard = True
        if (not sent_da) and b"\x1b[c" in out:
            write(b"\x1b[?61;4;6;7;14;21;2")
            time.sleep(0.35)
            write(b"2;23;24;28;32;42;52c")
            sent_da = True
    if proc.poll() is not None:
        break

still_running = proc.poll() is None
if still_running:
    proc.terminate()
    try:
        proc.wait(timeout=2)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait()
else:
    proc.wait()

print(f"work={work}")
print(f"log={log}")
print(
    f"sent_cursor={sent_cursor} sent_colors={sent_colors} "
    f"sent_keyboard={sent_keyboard} sent_da={sent_da}"
)
print(f"still_running_after_delayed_da={still_running}")

log_path = os.path.join(log, "codex-tui.log")
if os.path.exists(log_path):
    with open(log_path, "r", errors="replace") as f:
        for line in f:
            if "terminal startup probes completed" in line:
                print(line.rstrip())

Run shape:

python3 repro.py "$(command -v codex)"

On unpatched 0.142.5 this exits before the 4 second harness window because the delayed PDA tail begins with 2 and is consumed by the trust prompt. With the local drain patch, Codex is still running after the delayed PDA tail and the harness terminates it.

trac3r00 · 1 month ago

Root cause analysis and proposed fix

I traced this to read_startup_probe in codex-rs/tui/src/terminal_probe.rs.

The problem: The startup probe sends ESC[c (Primary Device Attributes query) as part of a batched query under a 100 ms deadline (DEFAULT_TIMEOUT). When the terminal delivers the PDA response in two chunks — e.g. the prefix ESC[?61;4;6;7;14;21;2 arrives before the deadline and the tail 2;23;24;28;32;42;52c arrives after — the probe loop exits without consuming the tail. Those residual bytes leak into the normal crossterm event stream, where the leading 2 is interpreted by the trust prompt as SELECT_SECOND ("No, quit"), causing an immediate exit.

The fix: After the probe deadline, detect whether the accumulated buffer ends with an incomplete PDA suffix — ESC [ ? followed by digits/semicolons with no terminating c. When detected, grant a short extension (up to 500 ms) to drain the remaining bytes. The extension fires only when the split is detected, so normal startup latency is unchanged.

Here is the implementation (tested against current main):

// In `mod imp` of terminal_probe.rs:

/// Additional time granted when the probe deadline expires while the
/// buffer ends with an incomplete primary-device-attributes prefix.
const INCOMPLETE_PDA_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);

fn read_startup_probe(
    tty: &mut Tty,
    timeout: Duration,
    keyboard_probe: StartupKeyboardEnhancementProbe,
) -> io::Result<StartupProbe> {
    let deadline = Instant::now() + timeout;
    let mut buffer = Vec::new();
    let mut probe = StartupProbe { /* ... */ };
    let mut saw_supported_keyboard = false;
    loop {
        tty.read_available(&mut buffer)?;
        update_startup_probe(&mut probe, &mut saw_supported_keyboard, &buffer, keyboard_probe);
        if startup_probe_complete(&probe, keyboard_probe) {
            return Ok(probe);
        }
        let now = Instant::now();
        if now >= deadline { break; }
        if !tty.poll_readable(deadline.saturating_duration_since(now))? { break; }
    }

    // Drain incomplete PDA tail to prevent it leaking into TUI input.
    if has_incomplete_pda_suffix(&buffer) {
        drain_incomplete_pda(tty, &mut buffer, &mut probe, &mut saw_supported_keyboard, keyboard_probe)?;
    }

    finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
    Ok(probe)
}

/// Returns `true` when `buffer` ends with an incomplete PDA response.
fn has_incomplete_pda_suffix(buffer: &[u8]) -> bool {
    let Some(last_csi_q) = buffer.windows(3).rposition(|w| w == b"\x1B[?") else {
        return false;
    };
    let rest = &buffer[last_csi_q + 3..];
    !rest.is_empty() && rest.iter().all(|b| b.is_ascii_digit() || *b == b';')
}

/// Drains the incomplete PDA tail within a bounded window.
fn drain_incomplete_pda(
    tty: &mut Tty, buffer: &mut Vec<u8>, probe: &mut StartupProbe,
    saw_supported_keyboard: &mut bool, keyboard_probe: StartupKeyboardEnhancementProbe,
) -> io::Result<()> {
    let drain_deadline = Instant::now() + INCOMPLETE_PDA_DRAIN_TIMEOUT;
    loop {
        let now = Instant::now();
        if now >= drain_deadline { return Ok(()); }
        if !tty.poll_readable(drain_deadline.saturating_duration_since(now))? { return Ok(()); }
        tty.read_available(buffer)?;
        update_startup_probe(probe, saw_supported_keyboard, buffer, keyboard_probe);
        if !has_incomplete_pda_suffix(buffer) { return Ok(()); }
    }
}

Full diff is in trac3r00/codex@fix/drain-incomplete-pda-on-probe-timeout — 92 additions, 4 deletions, including unit tests for has_incomplete_pda_suffix covering split, complete, keyboard-flags, empty, and non-CSI-? buffers.

Verification: cargo clippy -p codex-tui -- -D warnings passes clean; all 10 terminal_probe unit tests pass.

[bob]

zane668 · 1 month ago

We ran into what looks like the same root-cause class as this issue,
but in a different manifestation, and wanted to add it here since the
underlying mechanism (terminal_probe's fixed-deadline handoff to the
normal EventStream) appears identical.

What we saw

Running Codex CLI 0.145.0 interactively inside a terminal multiplexer,
after a long turn (Worked for Nm) completed and the composer became
visible again, the composer contained a block of literal escape-
sequence text glued to genuine user keystrokes — not just a Device
Attributes tail like the original report, but an entire batch of
startup-probe responses plus focus events:

  • a cursor position report (CPR)
  • an OSC 10 and an OSC 11 response (foreground/background color, with

real RGB payloads — i.e. these were genuine terminal responses, not
garbage)

  • a kitty keyboard-flags response (CSI ? u)
  • a primary Device Attributes response (CSI ... c)
  • several focus-in/focus-out event pairs (CSI I / CSI O)
  • interleaved with the user's own typed text and a keypress event

All five probe-response types Codex's terminal_probe::startup() is
known to send (per terminal_probe.rs) were present, in the same
order they're queried in source — i.e. this is unambiguously the
startup probe batch, not some other query. We independently confirmed
the terminal-multiplexer layer forwarded these bytes to the child
process correctly and completely (no truncation, no reordering,
byte-for-byte) — so the leak is happening on Codex's side of the
stdin handoff, consistent with what this issue already describes.

Timing

We captured a terminal-mode trace independent of Codex's own logs.
The mode-toggle events we could observe (bracketed-paste and focus-
reporting enable/disable) all landed within under a second of process
start, i.e. during Codex's TUI initialization — well before the "long
turn" that was running when the garbled composer content was later
noticed. That's consistent with this issue's mechanism: the pollution
happens at/near startup-probe time, and only becomes visible later,
whenever the composer next redraws — a long task running in the
background is enough to delay when the corrupted buffer gets noticed,
which can make it look (misleadingly) like the corruption itself
happened "at turn completion" when it didn't.

Why we're posting this here rather than as a new issue

The failure class matches this issue precisely — a terminal_probe
response (or, in our case, several) arriving after the fixed
probe-read deadline and being handed to the normal input path instead
of being recognized as a terminal response — just triggered by a
different response type/timing (a full multi-response batch plus
interleaved focus events, rather than an isolated split DA tail) and
with a different visible symptom (garbled composer text on a running
session, rather than an erroneous trust-prompt selection at startup).

Why we think this argues for the broader fix, not just a DA-specific drain

The two workarounds proposed so far in this thread's comments (bounded
extra drain specifically for an incomplete DA suffix) would not by
themselves cover what we saw, because:

  • multiple response types were affected (CPR, OSC 10/11, kitty

flags, DA), not only DA;

  • focus events were interleaved with the probe responses in the

polluted text, meaning the leak window isn't limited to the probe
responses' own bytes — anything arriving on stdin during the same
ownership gap is at risk;

  • the corrupted content only became visible well after startup, which

suggests the leaked bytes sat in a draft/composer buffer rather than
being dropped — worth keeping in mind when writing regression tests
(a test should assert the composer's final content is clean, not
just that the probe itself returns the right values).

This lines up with the "single stdin owner" direction sketched in the
original report/discussion: having terminal_probe::startup() and the
normal EventStream share one incremental demux, so that a terminal
response can never be classified as a Key/Char event regardless of
which reader happens to be attached when it arrives, and so partial
sequences are reassembled rather than raced across an ownership
boundary. A scoped alternative that stays closer to the current
architecture would be to track "outstanding probe responses" (CPR /
OSC 10 / OSC 11 / kitty flags / DA) as a small piece of state that
EventStream consults for a bounded window after the probe deadline —
continuing to strip/absorb any of those response shapes (and, ideally,
focus events arriving in the same window) until each is accounted for
or a longer safety timeout elapses — rather than a DA-only drain.

If it's useful, we can adapt our test matrix idea to something
runnable against a PTY harness: send the full 5-response probe batch
plus interleaved focus/key events, split at every possible byte
boundary, with the tail crossing the 100ms deadline by varying
margins, and assert the final composer/text-buffer state contains only
the genuine user keystrokes. We didn't manage to reproduce the full
batch+focus combination deterministically on our end yet (our own
attempts to reproduce landed a working keyboard/CPR/color probe
outcome, not a garbled one) — flagging that as an honest gap in what
we're providing rather than a firm repro. Happy to open a separate
linked issue with more detail if maintainers would rather track this
combined scenario (batched multi-response + focus interleave) apart
from the original DA-only report.

mehulparmariitr · 1 month ago

Related implementation touching terminal reply ownership:

The focus fix pauses Crossterm's event source before a bounded direct OSC probe and recreates it before normal key delivery. A regression verifies that the following key is delivered after focus handling.

It does not modify startup Device Attributes parsing or drain an incomplete DA response, so it is not claimed as a fix for this report. This issue is nevertheless a relevant review case for ensuring direct terminal probes have exclusive, bounded ownership and cannot leak reply tails into user input.

etraut-openai contributor · 23 days ago

We've decided to make all directories trusted by default unless you are using :read-only permissions. This makes the CLI (TUI) consistent with the desktop app's behavior. This will be included in the next release.