Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ import {
isCodexUserPromptLine, isCodexStatusLine, isCodexIntermediateChromeLine,
detectCodexApproval, isApprovalOverlayVisible,
detectCodexTrustDialog, CODEX_TRUST_DIALOG_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_DECLINE_KEYS, CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS,
diffLines,
// Transcript
isCodexConversationEntry, isCodexResponseItem, isCodexEventMsg,
Expand Down Expand Up @@ -220,7 +222,7 @@ shared verbatim with `claude-code-headless` (see the header comment in
| Transcript | `~/.claude/projects/<sanitized-cwd>/<uuid>.jsonl` (per-cwd) | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (date-bucketed globally) |
| Assistant marker | `⏺` | `•` (or older `◦`) |
| User marker | `❯` | `›` |
| Trust prompt | "Accessing workspace" | "Do you trust the contents of this directory" |
| Trust prompt | "Accessing workspace" | "Do you trust the contents of this directory" (≤ 0.149); "Folder access" / "Trust this folder?" (0.156+) |
| Proxy | mitmproxy TLS interceptor | plain HTTP server behind `openai_base_url` |

---
Expand Down Expand Up @@ -421,9 +423,13 @@ also carries `ts: number` (epoch ms).

Notes on the `trust_dialog` action callbacks:

- `accept()` writes `CODEX_TRUST_DIALOG_ACCEPT_KEYS` (`'\r'`) —
confirms the pre-selected "Yes, continue".
- `reject()` writes `'2\r'` — selects "No, quit".
- `accept()` writes the matched layout's `acceptKeys`: `'1'` on the legacy
layout (selects "Yes, continue" at once); `'1\r'` on the 0.156+ layout,
where `1` only moves the highlight to option 1 and Enter confirms it.
- `reject()` writes the layout's `declineKeys`, `'2'` in both layouts. It
selects option 2 at once, with no trailing Enter to leak into the next
screen. On 0.156+ option 2 is "Quit", or "Back to Agent Command Center"
when Codex is connected to its background server.

The `event` flat surface only emits a `{ type: 'trust_dialog', … }`
member when the dialog becomes **visible**; the simple `trust-dialog`
Expand Down Expand Up @@ -1138,7 +1144,7 @@ on-screen.
| Field | Type | Description |
| --- | --- | --- |
| `state` | `CodexTrustDialogState` | The parsed trust dialog (§7.3). |
| `actions` | `ConditionAction[]` | `accept` (Trust folder, writes `'\r'`), `reject` (Quit, writes `'2\r'`). |
| `actions` | `ConditionAction[]` | `accept` and `reject`, writing the state's `acceptKeys` / `declineKeys` (above). Legacy labels are "Trust folder" / "Quit"; 0.156+ labels are the option texts painted on screen. |

**`CodexApprovalCondition`** — `kind: 'codex.approval'`

Expand Down Expand Up @@ -1301,23 +1307,27 @@ title matching, returns `boolean`.
detectCodexTrustDialog(screen: string): CodexTrustDialogState
```

Detects Codex's first-launch-in-a-new-directory trust dialog. **All**
required markers must be present (conservative — avoids
false-positiving on assistant text that mentions "trust"):
`Do you trust the contents of this directory`, `Yes, continue`,
`No, quit`.
Detects Codex's first-launch trust dialog in either upstream layout, **structurally**: the dialog's key hint must be the last painted row, with the adjacent option pair above it and the dialog's title line above that. A transcript that quotes the dialog, even verbatim, always has the live composer below it and never matches.

- **Legacy (≤ 0.149.1):** `> You are in <path>`, `1. Yes, continue` / `2. No, quit`, then `Press enter to continue`.
- **0.156+:** `Folder access` and the path, `1. Trust and continue` (or `Open restricted` / `Open existing task`), then `2. Quit` (or `Back to Agent Command Center`), then `enter continue · esc quit|back`. One wrap of the hint is accepted.

`CodexTrustDialogState`:

| Field | Type | Description |
| --- | --- | --- |
| `visible` | `boolean` | |
| `workspace?` | `string` | The directory Codex asks to trust, parsed from a `> You are in <path>` line. |
| `options?` | `Array<{ key: string; label: string }>` | The two options, hardcoded `{ '1', 'Yes, continue' }` / `{ '2', 'No, quit' }`. |
| `workspace?` | `string` | The folder the dialog names. Hard-wrapped path rows are joined. |
| `trustTarget?` | `string` | 0.156+ only: the Git repository root that trust applies to, when Codex is in a subdirectory. |
| `options?` | `Array<{ key: string; label: string }>` | The two options, labels as painted. |
| `layout?` | `'you-are-in' \| 'folder-access'` | Which layout matched. |
| `acceptKeys?` / `declineKeys?` | `string` | The bytes that choose option 1 / option 2 on that layout. |

Constants:
- `CODEX_TRUST_DIALOG_ACCEPT_KEYS` = `'1'` and `CODEX_TRUST_DIALOG_DECLINE_KEYS` = `'2'` (legacy layout).
- `CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS` = `'1\r'` and `CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS` = `'2'` (0.156+).

`CODEX_TRUST_DIALOG_ACCEPT_KEYS` = `'\r'` — confirms the pre-selected
"Yes, continue". (Reject is `'2\r'`, not exported as a constant —
see the trust-dialog condition's `reject` action.)
Prefer the state's `acceptKeys` / `declineKeys` over the constants.

### 7.4 Line diff — `LineDiff.ts`

Expand Down
75 changes: 75 additions & 0 deletions docs/plans/2026-09-27-trust-dialog-0157.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Codex 0.157 trust dialog: new layout, and `1` no longer accepts (#65)

## Evidence
- **Recording.** codex-cli 0.157.1 was recorded in a fresh untrusted folder (80×24, no keystroke sent), stored in `testing/fixtures/trust-dialog-0157/folder-access-back.json`. The dialog it paints:
```
Folder access
/private/tmp/…/untrusted-ABCD (hard-wrapped over 2 rows)

Trust this folder? Codex can read, edit, and run files here, subject to your
…
› 1. Trust and continue
2. Back to Agent Command Center

enter continue · esc back
```
`detectCodexTrustDialog` returns `{visible:false}` for it. The parser anchors on `> You are in`, "Do you trust the contents of this directory", `1. Yes, continue` and `2. No, quit`, and none of these exist any more.
- **Upstream source**, `codex-rs/tui/src/onboarding/trust_directory.rs` at tag `rust-v0.157.1`, read in `vendor/codex-src`:
- **Option 1** is "Trust and continue". It becomes "Open restricted" when the folder is saved as untrusted, and "Open existing task" when an existing task is resumed while connected.
- **Option 2** is "Quit", or "Back to Agent Command Center" when connected to the background server (`TrustCancelAction::AgentsOverview`). The hint row ends "esc quit" or "esc back" to match. An earlier 0.157.1 run in a different folder showed "2. Quit", so both labels occur locally.
- **The keys changed.** `1`/`y` (`SELECT_FIRST`) now only MOVES THE HIGHLIGHT to option 1: "A terminal response fragment can start with `1`; trust always requires an explicit Enter confirmation". Only Enter (`CONFIRM`) confirms the highlighted row. `2`/`n`, `q`, Ctrl+C, Ctrl+D and Esc still act at once (`handle_quit`).
- **Other rows.** A Git subdirectory adds "Note: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:" and a second path. A failed trust write adds an error paragraph between the options and the hint. Paths hard-wrap at any character, or are centre-truncated with `…`.
- **What breaks today:**
1. The dialog is never detected, so no `codex.trust-dialog` condition appears. Readiness waits on a blocking screen nobody is told about.
2. Answering with the current accept bytes (`'1'`) would only move the highlight and leave the dialog up.
3. `CodexHeadless`'s legacy `trust_dialog` event still sends `'2\r'` to reject. The parser's own comment says that leaks an Enter into the next screen.

## Change
- `detectCodexTrustDialog` recognises both layouts.
- **Legacy layout (≤ 0.149.1, still the accepted version):** unchanged.
- **Folder-access layout (0.157):** a line that is exactly `Folder access`. Below it, a `1.` row and then a `2.` row. Below those, the `enter continue · esc quit|back` hint.
- **Option labels are read from the screen**, and each must be one of upstream's known labels (option 1: trust / open restricted / open existing task; option 2: Quit / Back to Agent Command Center). Only real renders can match, and prose that quotes the rows still cannot.
- **Workspace:** the path rows under `Folder access`, with the hard wrap joined. When the Git note is present, `trustTarget` is the repository root.
- **The state carries its own keystrokes** (`acceptKeys`, `declineKeys`), because they depend on the layout the parser matched:
- Legacy accept stays `'1'`: it selects at once in ≤ 0.149.1, and an extra Enter would leak into the composer.
- 0.157 accept is `'1\r'`: `1` forces the highlight onto option 1, then Enter confirms it. The result is deterministic whatever row was highlighted, and it never confirms "Quit" by accident.
- Decline stays `'2'` in both layouts.
- The exported `CODEX_TRUST_DIALOG_*_KEYS` constants keep their legacy meaning, and new `CODEX_TRUST_DIALOG_FOLDER_ACCESS_*` constants are added.
- **The condition's actions** use the state's keystrokes and the on-screen labels, so the app shows "Back to Agent Command Center" rather than "Quit" when that is what the key does. Action ids (`accept` / `reject`) are unchanged.
- **The legacy `trust_dialog` event** in `CodexHeadless` uses the state's keystrokes too, which fixes the stray `'2\r'`.
- **The two other anchors** move to the shared detector: `ScreenParser.isTrustDialogVisible` (streaming-text suppression) and `Codex01491ComposerSurface.isKnownNonComposerModal`. That keeps the old anchor and adds the 0.157 one.

## Tests
- Replay the recording through the real `HeadlessTerminal` (batched drain replay, as in `ComposerState.recorded.test.ts`). The frame must detect as visible, with the joined workspace, the on-screen labels and the 0.157 keystrokes. Red on main.
- The upstream 0.157.1 insta snapshots (git subdirectory, restricted, existing task, trust error, 40-column truncation) as plain frames, labelled as upstream test output, not local recordings.
- Negative cases:
- Prose quoting every row.
- Rows above the anchor.
- A missing hint row.
- An unknown option label.
- The legacy tests stay green.
- The condition-module test pins that actions follow the state's keystrokes and labels.

## Out of scope
- The 0.157 "Cannot use the background server" screen (#66).
- The prompt-input profile (#63) and the write-stdin approval (#64).
- Answering the dialog live: that would write the user's `~/.codex/config.toml`. The keystroke semantics come from upstream source, which is stated as such.

## Review round (3 reviewers) and live evidence
- **Copied-frame phantom (a, b).** A transcript quoting the dialog verbatim was detected as live, so a blocking dialog appeared that could be answered with keys. Detection is now anchored at the bottom: the key hint must be the last painted row (one wrap allowed), the nearest adjacent option pair sits above it, and the nearest `Folder access` above that. The dialog is the onboarding screen, painted before any chat widget, while a quoted copy always has the live composer below it.
- **Windows hint wrapped at narrow widths (a, b).** One wrap of the hint is accepted, and the composer-surface anchor tolerates it too.
- **Coverage gaps (b), all now tested:**
- the `CodexHeadless` event callbacks, driven through the public class with the recording;
- both option-2 condition labels;
- an unknown option-1 label;
- option adjacency;
- legacy streaming-text suppression through `ScreenParser`.
- **The keystrokes are now live-verified.** The prompt-input recorder (#63) ran codex-cli 0.157.1 on this dialog in an isolated `CODEX_HOME`:
- after `1` alone the dialog was still up 1 s later;
- the following Enter reached the composer.

That confirms `'1\r'` beyond upstream source. It has been re-run several times, most recently on 2026-09-27.
- **Reviewer c (MERGE-READY with three follow-ups, all fixed here):**
- **F1: the legacy layout had the same copied-frame phantom** (pre-existing on main). The legacy detector is now anchored at the bottom the same way: "Press enter to continue" is the last row in rust-v0.149.1's trust_directory.rs and in the recorded 0.149.1 frame, and the Windows variant may wrap once.
- **F2: the composer-surface anchor was untested.** Writing that test exposed a real bug on this branch: unwrapped, the hint has the idle-footer shape, so the dialog was read as a composer whose draft is "1. Trust and continue". The anchor is now a bottom-row structural check (`TRUST_HINT_FOOTER`, shared byte-for-byte with #63's branch), pinned by `Codex01491ComposerSurface.test.ts`. The redundant window-based anchor is gone.
- **F3: `API.md` described stale callback bytes and the old substring detector.** It is rewritten for both layouts, and the new constants and the layout type are exported from the package entry.
47 changes: 47 additions & 0 deletions src/CodexHeadless.trustDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { IPty } from 'node-pty'
import { readFileSync } from 'node:fs'

import { afterEach, expect, it } from 'vitest'

import { CodexHeadless } from './CodexHeadless.js'

// #67 review (a and b): the legacy `trust_dialog` event carries accept/reject
// callbacks that write to the PTY, and a mutation that made accept write the
// legacy '1' survived every parser test. On 0.156+ that '1' only moves the
// highlight and leaves Codex waiting on the dialog. This drives the public
// class with the recorded 0.157.1 dialog and checks the bytes it writes.
type Recording = { cols: number; rows: number; events: Array<{ t: number; dir: string; data?: string }> }
const recording = JSON.parse(readFileSync(
new URL('../testing/fixtures/trust-dialog-0157/folder-access-back.json', import.meta.url),
'utf8',
)) as Recording

const stops: Array<() => Promise<void>> = []
afterEach(async () => { for (const stop of stops.splice(0)) await stop() })

it('answers the recorded 0.157.1 dialog with 1+Enter to accept and 2 to decline', async () => {
const listeners = new Set<(data: string) => void>()
const written: string[] = []
const pty = {
write: (data: string) => { written.push(data) },
resize: () => undefined,
onData: (listener: (data: string) => void) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) } },
onExit: () => ({ dispose: () => undefined }),
} as unknown as IPty
const headless = new CodexHeadless({ pty, cwd: '/recorded/untrusted', cols: recording.cols, rows: recording.rows })
stops.push(() => headless.stop())
const events: Array<{ type: string; accept?: () => void; reject?: () => void }> = []
headless.on('event', (event: { type: string }) => { if (event.type === 'trust_dialog') events.push(event) })
// start() also acquires the rollout file; only the screen path is under
// test, so attach the terminal the way start() does.
;(headless as unknown as { terminal: { attach(): void } }).terminal.attach()
for (const event of recording.events) if (event.dir === 'out') for (const listener of listeners) listener(event.data!)

// Waits on the event itself (the completion signal), never on a wall clock;
// a detector that never fires fails on the test timeout.
while (events.length === 0) await new Promise(resolve => setTimeout(resolve, 5))
const [trust] = events
trust!.accept!()
trust!.reject!()
expect(written).toEqual(['1\r', '2'])
})
11 changes: 8 additions & 3 deletions src/CodexHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
detectCodexTrustDialog,
type CodexTrustDialogState,
CODEX_TRUST_DIALOG_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_DECLINE_KEYS,
} from './parsers/TrustDialogParser.js'
import {
makeEvaluator,
Expand Down Expand Up @@ -108,7 +109,7 @@ import type {
// Transcript: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
// (date-bucketed globally, not per-cwd)
// Markers: • for assistant, › for user (not ⏺ and ❯)
// Trust: "Do you trust the contents" (not "Accessing workspace")
// Trust: "Do you trust the contents" (<=0.149) / "Folder access" (0.156+)
//
// The consumer owns the PTY. This class never spawns or kills processes.

Expand Down Expand Up @@ -1049,8 +1050,12 @@ export class CodexHeadless extends EventEmitter {
type: 'trust_dialog',
ts: Date.now(),
workspace: trust.workspace,
accept: () => this.write(CODEX_TRUST_DIALOG_ACCEPT_KEYS),
reject: () => this.write('2\r'),
// The keystrokes come from the layout the parser matched (#65):
// 0.156+ needs `1` + Enter to accept, where `1` alone only moves
// the highlight. Reject used to be a hard-coded '2\r', whose Enter
// leaked into whatever screen followed the dialog.
accept: () => this.write(trust.acceptKeys ?? CODEX_TRUST_DIALOG_ACCEPT_KEYS),
reject: () => this.write(trust.declineKeys ?? CODEX_TRUST_DIALOG_DECLINE_KEYS),
})
}
}
Expand Down
28 changes: 27 additions & 1 deletion src/conditions/trustDialog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
CODEX_TRUST_DIALOG_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_DECLINE_KEYS,
CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS,
type CodexTrustDialogState,
} from '../parsers/TrustDialogParser.js'
import { defineModule } from './core/contract.js'
Expand Down Expand Up @@ -49,6 +51,28 @@ const TRUST_DIALOG_ACTIONS: readonly ConditionAction[] = [
{ kind: 'pty', id: 'reject', label: 'Quit', data: CODEX_TRUST_DIALOG_DECLINE_KEYS },
]

// #65: the template above is the LEGACY layout's actions, and it is only the
// fallback now. Codex 0.156+ paints a different dialog whose keystrokes differ
// (`1` merely moves the highlight there, so accept is `1` + Enter) and whose
// option labels vary: option 2 is "Back to Agent Command Center" when Codex
// is connected to its background server, and option 1 is "Open restricted" for
// a folder saved as untrusted. The parser reports both per frame, so the
// actions follow the screen that is actually up. A fixed "Quit" button that
// in fact returned to the overview, or a fixed "Trust folder" that in fact
// opened the folder restricted, would tell the user something false.
//
// The action ids stay `accept` / `reject`: those are what the app and the
// phone key on. Only `label` and `data` follow the state.
function trustDialogActions(state: CodexTrustDialogState): ConditionAction[] {
if (state.layout !== 'folder-access') return TRUST_DIALOG_ACTIONS.map((a) => ({ ...a }))
const label = (key: string, fallback: string) =>
state.options?.find((option) => option.key === key)?.label ?? fallback
return [
{ kind: 'pty', id: 'accept', label: label('1', 'Trust and continue'), data: state.acceptKeys ?? CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS },
{ kind: 'pty', id: 'reject', label: label('2', 'Quit'), data: state.declineKeys ?? CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS },
]
}

// trustDialogModule — the headless-module form of the trust-dialog condition.
//
// `detect` takes the WHOLE input bundle and reaches into `inputs.trustDialog`,
Expand All @@ -71,7 +95,9 @@ export const trustDialogModule = defineModule<
// isolation contract requires a mutated snapshot not to poison later ones.
// `{ ...a }` is a sufficient clone because ConditionAction fields are all
// primitives (no nested objects to share).
actions: () => TRUST_DIALOG_ACTIONS.map((a) => ({ ...a })),
// Each call builds new objects (see trustDialogActions), so the isolation
// contract above still holds.
actions: (state) => trustDialogActions(state),
})

// Legacy builder, re-implemented on top of the module so any external importer
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export {
detectCodexTrustDialog,
CODEX_TRUST_DIALOG_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_DECLINE_KEYS,
CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS,
CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS,
type CodexTrustDialogLayout,
type CodexTrustDialogState,
} from './parsers/TrustDialogParser.js'

Expand Down
Loading
Loading