From 41ba98027d6f92a719b284834cdc7da337c23525 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 23:06:40 -0700 Subject: [PATCH 1/6] docs(plans): Codex 0.157 trust dialog layout and keystrokes (#65) Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-27-trust-dialog-0157.md | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/plans/2026-09-27-trust-dialog-0157.md diff --git a/docs/plans/2026-09-27-trust-dialog-0157.md b/docs/plans/2026-09-27-trust-dialog-0157.md new file mode 100644 index 0000000..292e497 --- /dev/null +++ b/docs/plans/2026-09-27-trust-dialog-0157.md @@ -0,0 +1,56 @@ +# 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. From 86e03d0a2e41b4b20c740398084082fe25c045f4 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 23:12:02 -0700 Subject: [PATCH 2/6] fix(trust): detect Codex 0.156+ Folder access dialog and send its keystrokes Codex 0.157.1 rewrote the trust dialog: no '> You are in', no 'Do you trust the contents', options 'Trust and continue' / 'Quit' or 'Back to Agent Command Center' (or 'Open restricted' / 'Open existing task'). detectCodexTrustDialog returned not-visible for the live, blocking dialog. It now recognises both layouts structurally (whole-line anchor, adjacent option pair, key hint that must agree with option 2) and reads labels, folder and Git trust target from the screen. Upstream also changed the keys (trust_directory.rs at rust-v0.157.1): 1 only moves the highlight, Enter confirms. The state now carries per-layout keystrokes (legacy '1'; 0.156+ '1\r'), the condition labels its actions from the screen, and CodexHeadless's legacy trust_dialog event stops sending '2\r'. ScreenParser and the 0.149.1 composer surface share the detector / add the new hint anchor. Fail-first: a recorded 0.157.1 PTY replay (no key sent) and upstream's insta snapshots, red on main. Fixes #65 Co-Authored-By: Claude Opus 5.5 --- src/CodexHeadless.ts | 11 +- src/conditions/trustDialog.ts | 28 +- src/parsers/ScreenParser.ts | 19 +- .../TrustDialogParser.recorded.test.ts | 77 ++++++ src/parsers/TrustDialogParser.test.ts | 116 +++++++++ src/parsers/TrustDialogParser.ts | 241 +++++++++++++++++- .../prompt-input/Codex01491ComposerSurface.ts | 3 + .../trust-dialog-0157/folder-access-back.json | 163 ++++++++++++ .../upstream-snapshots-0157.1.json | 50 ++++ 9 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 src/parsers/TrustDialogParser.recorded.test.ts create mode 100644 testing/fixtures/trust-dialog-0157/folder-access-back.json create mode 100644 testing/fixtures/trust-dialog-0157/upstream-snapshots-0157.1.json diff --git a/src/CodexHeadless.ts b/src/CodexHeadless.ts index 0264e5b..7865f2f 100644 --- a/src/CodexHeadless.ts +++ b/src/CodexHeadless.ts @@ -46,6 +46,7 @@ import { detectCodexTrustDialog, type CodexTrustDialogState, CODEX_TRUST_DIALOG_ACCEPT_KEYS, + CODEX_TRUST_DIALOG_DECLINE_KEYS, } from './parsers/TrustDialogParser.js' import { makeEvaluator, @@ -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. @@ -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), }) } } diff --git a/src/conditions/trustDialog.ts b/src/conditions/trustDialog.ts index a41e801..e156fc3 100644 --- a/src/conditions/trustDialog.ts +++ b/src/conditions/trustDialog.ts @@ -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' @@ -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`, @@ -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 diff --git a/src/parsers/ScreenParser.ts b/src/parsers/ScreenParser.ts index 03fe2a1..158957d 100644 --- a/src/parsers/ScreenParser.ts +++ b/src/parsers/ScreenParser.ts @@ -170,21 +170,22 @@ export function isCodexIntermediateChromeLine(line: string): boolean { return false } -// --- Trust dialog detection (inlined) --- - -const TRUST_DIALOG_MARKERS = [ - 'Do you trust the contents of this directory', - 'Yes, continue', - 'No, quit', -] - +// --- Trust dialog detection --- +// +// Delegates to the structural detector. This used to be an inlined copy that +// asked whether three legacy phrases appeared ANYWHERE on screen, which (a) +// blanked the streaming text whenever an assistant merely quoted the dialog, +// the false positive TrustDialogParser was rewritten to stop, and (b) never +// matched the 0.156+ `Folder access` layout, whose phrases are all different +// (#65). One detector means one answer to "is the dialog up". function isTrustDialogVisible(screen: string): boolean { - return TRUST_DIALOG_MARKERS.every(m => screen.includes(m)) + return detectCodexTrustDialog(screen).visible } // Approval detection lives in ApprovalParser.ts — import for the // streaming text suppression check. import { isApprovalOverlayVisible } from './ApprovalParser.js' +import { detectCodexTrustDialog } from './TrustDialogParser.js' // --- Resume picker detection --- diff --git a/src/parsers/TrustDialogParser.recorded.test.ts b/src/parsers/TrustDialogParser.recorded.test.ts new file mode 100644 index 0000000..7ced585 --- /dev/null +++ b/src/parsers/TrustDialogParser.recorded.test.ts @@ -0,0 +1,77 @@ +import type { IPty } from 'node-pty' +import { readFileSync } from 'node:fs' + +import { afterEach, expect, it } from 'vitest' + +import { trustDialogModule } from '../conditions/trustDialog.js' +import { HeadlessTerminal } from '../terminal/HeadlessTerminal.js' +import { extractCodexStreamingText } from './ScreenParser.js' +import { detectCodexTrustDialog } from './TrustDialogParser.js' + +// #65: a raw PTY recording of codex-cli 0.157.1 showing its trust dialog in a +// fresh untrusted folder (see the fixture's `source`; no key was ever sent). +// Replayed through the real terminal so the frame the parser reads is xterm's +// own parse of Codex's bytes: the cursor-addressed word placement, the +// highlighted row's full-width padding and the hard-wrapped path all come +// from upstream, not from a hand-written string. Every assertion below was red +// on main, where this dialog read as not visible. +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 terminals: HeadlessTerminal[] = [] +afterEach(() => { for (const terminal of terminals.splice(0)) terminal.dispose() }) + +// Batched feed with a drain between batches, for the reasons written down in +// ComposerState.recorded.test.ts (#57): draining is the completion signal, and +// no wall-clock deadline decides when the frame is "done". +async function replay(): Promise { + const listeners = new Set<(data: string) => void>() + const pty = { + write: () => undefined, + resize: () => undefined, + onData: (listener: (data: string) => void) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) } }, + onExit: () => ({ dispose: () => undefined }), + } as unknown as IPty + const terminal = new HeadlessTerminal({ pty, cols: recording.cols, rows: recording.rows, snapshotIntervalMs: 1 }) + terminals.push(terminal) + terminal.attach() + const chunks = recording.events.filter(event => event.dir === 'out').map(event => event.data!) + for (let start = 0; start < chunks.length; start += 50) { + for (const chunk of chunks.slice(start, start + 50)) for (const listener of listeners) listener(chunk) + while ((terminal as unknown as { pendingWrites: number }).pendingWrites !== 0) await new Promise(resolve => setImmediate(resolve)) + } + return terminal +} + +it('detects the recorded 0.157.1 Folder access dialog with its on-screen labels and keystrokes', async () => { + const screen = (await replay()).snapshotPlain() + expect(screen).toContain('Folder access') + + const state = detectCodexTrustDialog(screen) + expect(state).toEqual({ + visible: true, + // Painted over two rows (hard wrap at 76 columns); joined back into one. + workspace: '/private/tmp/claude-501/-Users-fixture-user-Desktop-Development-agent-code/d0000000-0000-0000-0000-000000000000/scratchpad/rec/untrusted-ABCD', + options: [ + { key: '1', label: 'Trust and continue' }, + { key: '2', label: 'Back to Agent Command Center' }, + ], + layout: 'folder-access', + acceptKeys: '1\r', + declineKeys: '2', + }) + + // The condition the app renders: ids unchanged, but the reject button says + // what the key really does here (back to the overview, Codex keeps running). + expect(trustDialogModule.actions(state)).toEqual([ + { kind: 'pty', id: 'accept', label: 'Trust and continue', data: '1\r' }, + { kind: 'pty', id: 'reject', label: 'Back to Agent Command Center', data: '2' }, + ]) + + // The streaming-text extractor must treat the dialog as a blocking screen, + // not as assistant output. + expect(extractCodexStreamingText(screen)).toBe('') +}) diff --git a/src/parsers/TrustDialogParser.test.ts b/src/parsers/TrustDialogParser.test.ts index 48e978e..fb2eb89 100644 --- a/src/parsers/TrustDialogParser.test.ts +++ b/src/parsers/TrustDialogParser.test.ts @@ -1,8 +1,13 @@ +import { readFileSync } from 'node:fs' + import { describe, expect, it } from 'vitest' +import { trustDialogModule } from '../conditions/trustDialog.js' 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, detectCodexTrustDialog, } from './TrustDialogParser.js' @@ -100,3 +105,114 @@ describe('detectCodexTrustDialog', () => { expect(CODEX_TRUST_DIALOG_DECLINE_KEYS).toBe('2') }) }) + +// --- 0.156+ `Folder access` layout (#65) --- +// +// Upstream's own insta snapshots at rust-v0.157.1, verbatim (see the fixture's +// `evidence`). They cover the variants one local folder cannot produce. The +// recorded local frame lives in TrustDialogParser.recorded.test.ts. +type UpstreamSnapshot = { name: string; frame: string } +const upstream = (JSON.parse(readFileSync( + new URL('../../testing/fixtures/trust-dialog-0157/upstream-snapshots-0157.1.json', import.meta.url), + 'utf8', +)) as { snapshots: UpstreamSnapshot[] }).snapshots +const frame = (name: string) => upstream.find(snapshot => snapshot.name === name)!.frame + +describe('detectCodexTrustDialog on the 0.156+ Folder access layout', () => { + it('reads every upstream variant, with labels exactly as painted', () => { + const expected: Record = { + renders_snapshot_for_git_repo: { workspace: '/workspace/project', labels: ['Trust and continue', 'Quit'] }, + renders_snapshot_for_remote_git_subdirectory: { workspace: '/srv/remote/project/nested', trustTarget: '/srv/remote/project', labels: ['Trust and continue', 'Back to Agent Command Center'] }, + renders_snapshot_for_trust_error: { workspace: '/workspace/project', labels: ['Trust and continue', 'Quit'] }, + renders_restricted_folder: { workspace: '/workspace/project', labels: ['Open restricted', 'Back to Agent Command Center'] }, + existing_untrusted_task: { workspace: '/workspace/project', labels: ['Open existing task', 'Back to Agent Command Center'] }, + // Hard wrap at an arbitrary character: "…/long-nested-folde" + "r". + folder_picker_restricted_40x24: { workspace: '/workspace/project/long-nested-folder', labels: ['Open restricted', 'Back to Agent Command Center'] }, + // No spacer rows at all: the paragraph opener ends the path block. + long_checkout_40x13: { workspace: 'workspace/…/repository', labels: ['Trust and continue', 'Quit'] }, + long_repository_root_40x17: { workspace: 'workspace/…/repository/checkout', trustTarget: 'workspace/…/repository', labels: ['Trust and continue', 'Quit'] }, + // Only the repository root fits, so the folder row is elided and the + // trust target stands in for the workspace. + only_repository_root_fits_40x16: { workspace: 'workspace/…/repository', trustTarget: 'workspace/…/repository', labels: ['Trust and continue', 'Quit'] }, + } + expect(upstream.map(snapshot => snapshot.name).sort()).toEqual(Object.keys(expected).sort()) + for (const [name, want] of Object.entries(expected)) { + const state = detectCodexTrustDialog(frame(name)) + expect({ name, state }).toEqual({ + name, + state: { + visible: true, + workspace: want.workspace, + ...(want.trustTarget !== undefined ? { trustTarget: want.trustTarget } : {}), + options: [{ key: '1', label: want.labels[0] }, { key: '2', label: want.labels[1] }], + layout: 'folder-access', + acceptKeys: '1\r', + declineKeys: '2', + }, + }) + } + }) + + it('ignores prose that quotes every row of the new layout', () => { + // The same class of phantom as PROSE_FALSE_POSITIVE: an assistant pasting + // the dialog inside a sentence or a code literal. No anchor is a whole line. + const prose = [ + '• The new dialog says "Folder access" and offers', + " const ROWS = ['1. Trust and continue', '2. Quit']", + ' with the hint enter continue · esc quit.', + ].join('\n') + expect(detectCodexTrustDialog(prose).visible).toBe(false) + }) + + it('requires the key hint below the options', () => { + expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace(/\n.*enter continue.*$/m, '')).visible).toBe(false) + }) + + it('requires the options below the Folder access anchor', () => { + const lines = frame('renders_snapshot_for_git_repo').split('\n') + const anchor = lines.findIndex(line => line.trim() === 'Folder access') + const moved = [...lines.slice(anchor + 1), lines[anchor]].join('\n') + expect(detectCodexTrustDialog(moved).visible).toBe(false) + }) + + it('rejects an option label upstream cannot paint', () => { + expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace('2. Quit', '2. Delete folder')).visible).toBe(false) + }) + + it('rejects a hint that contradicts option 2', () => { + // Upstream derives both from one TrustCancelAction, so "Quit" with + // "esc back" is not a real render. + expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace('esc quit', 'esc back')).visible).toBe(false) + }) +}) + +describe('keystrokes and condition actions per layout', () => { + it('keeps the legacy layout on the legacy keys and labels', () => { + const state = detectCodexTrustDialog(REAL_DIALOG) + expect(state.layout).toBe('you-are-in') + expect([state.acceptKeys, state.declineKeys]).toEqual(['1', '2']) + expect(trustDialogModule.actions(state)).toEqual([ + { kind: 'pty', id: 'accept', label: 'Trust folder', data: '1' }, + { kind: 'pty', id: 'reject', label: 'Quit', data: '2' }, + ]) + }) + + it('accepts the new layout with 1 then Enter, because 1 alone only moves the highlight there', () => { + // rust-v0.157.1 trust_directory.rs: SELECT_FIRST sets the highlight, + // CONFIRM (Enter) acts on it. Enter alone could confirm option 2. + expect(CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS).toBe('1\r') + expect(CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS).toBe('2') + const state = detectCodexTrustDialog(frame('renders_restricted_folder')) + expect(trustDialogModule.actions(state)).toEqual([ + { kind: 'pty', id: 'accept', label: 'Open restricted', data: '1\r' }, + { kind: 'pty', id: 'reject', label: 'Back to Agent Command Center', data: '2' }, + ]) + }) + + it('hands every caller its own action objects', () => { + const state = detectCodexTrustDialog(frame('renders_snapshot_for_git_repo')) + const first = trustDialogModule.actions(state) + first[0]!.label = 'mutated' + expect(trustDialogModule.actions(state)[0]!.label).toBe('Trust and continue') + }) +}) diff --git a/src/parsers/TrustDialogParser.ts b/src/parsers/TrustDialogParser.ts index 151bf9e..ac2957f 100644 --- a/src/parsers/TrustDialogParser.ts +++ b/src/parsers/TrustDialogParser.ts @@ -1,6 +1,8 @@ // Detect Codex's trust dialog from a screen snapshot. // -// Codex shows this on first launch in a new directory. Captured live from +// Two upstream layouts are recognised. The 0.156+ `Folder access` layout is +// documented at detectFolderAccessLayout below (#65). The legacy layout, still +// painted by the accepted 0.149.1, was captured live from // codex-cli 0.145.0 in a fresh temp dir (see // docs/decomposition/provider-condition-answering.md in agent-code): // @@ -17,12 +19,34 @@ export type CodexTrustDialogState = { /** True if Codex is currently showing the trust dialog. */ visible: boolean - /** The directory Codex is asking the user to trust. */ + /** + * The folder the dialog names: `> You are in ` (legacy) or the path + * under `Folder access` (0.157). When 0.157 elides that row for space, this + * falls back to `trustTarget`. + */ workspace?: string - /** The selectable options. */ + /** + * 0.157 only: the Git repository root that trust will actually apply to, + * shown under "Note: You’re in a subdirectory of a Git project". Absent when + * trust applies to `workspace` itself. + */ + trustTarget?: string + /** The selectable options, labels exactly as painted. */ options?: Array<{ key: string; label: string }> + /** Which upstream layout matched; decides the keystrokes below. */ + layout?: CodexTrustDialogLayout + /** Bytes that choose option 1 on THIS layout. See the constants below. */ + acceptKeys?: string + /** Bytes that choose option 2 on THIS layout. */ + declineKeys?: string } +/** + * `you-are-in`: codex-cli 0.145–0.149 (`> You are in …` / `1. Yes, continue`). + * `folder-access`: codex-cli 0.156+ (`Folder access` / `1. Trust and continue`). + */ +export type CodexTrustDialogLayout = 'you-are-in' | 'folder-access' + // STRUCTURAL anchoring, not substring presence. // // The previous implementation asked `screen.includes(marker)` for three @@ -72,13 +96,17 @@ const NO_ROW_RE = /^\s*[›>]?\s*2\.\s*No, quit\s*$/ /** * Detect Codex's trust dialog from a plain-text screen snapshot. * - * Returns { visible: true, workspace, options } when the dialog is genuinely - * on screen, { visible: false } otherwise. Called on every changed screen - * frame, so the cheap whole-string reject runs first. + * Returns { visible: true, … } when either upstream layout is genuinely on + * screen, { visible: false } otherwise. Called on every changed screen frame, + * so each layout's cheap whole-string reject runs first. */ export function detectCodexTrustDialog(screen: string): CodexTrustDialogState { if (!screen) return { visible: false } - if (!QUESTION_RE.test(screen)) return { visible: false } + return detectFolderAccessLayout(screen) ?? detectYouAreInLayout(screen) ?? { visible: false } +} + +function detectYouAreInLayout(screen: string): CodexTrustDialogState | null { + if (!QUESTION_RE.test(screen)) return null const lines = screen.split('\n') let anchorIdx = -1 @@ -91,7 +119,7 @@ export function detectCodexTrustDialog(screen: string): CodexTrustDialogState { break } } - if (anchorIdx === -1) return { visible: false } + if (anchorIdx === -1) return null // Both option rows must appear BELOW the anchor, in order. Scanning the // whole screen would re-admit a transcript that happens to quote them. @@ -107,14 +135,170 @@ export function detectCodexTrustDialog(screen: string): CodexTrustDialogState { break } } - if (yesIdx === -1 || noIdx === -1) return { visible: false } + if (yesIdx === -1 || noIdx === -1) return null + + return { + visible: true, + workspace, + options: [ + { key: '1', label: 'Yes, continue' }, + { key: '2', label: 'No, quit' }, + ], + layout: 'you-are-in', + acceptKeys: CODEX_TRUST_DIALOG_ACCEPT_KEYS, + declineKeys: CODEX_TRUST_DIALOG_DECLINE_KEYS, + } +} + +// --- 0.156+ "Folder access" layout (#65) --- +// +// Recorded from codex-cli 0.157.1 (testing/fixtures/trust-dialog-0157) and +// read against upstream `codex-rs/tui/src/onboarding/trust_directory.rs` at +// tag rust-v0.157.1: +// +// Folder access +// /path/to/folder (hard-wrapped, or …-truncated) +// +// Note: You’re in a subdirectory of a Git project. Trusting will apply +// to the repository root: (only in a Git subdirectory) +// /path/to/repo +// +// Trust this folder? Codex can read, edit, and run files here, … +// +// › 1. Trust and continue +// 2. Back to Agent Command Center +// +// (only after a failed trust write) +// +// enter continue · esc back +// +// The old anchors are all gone: no `> You are in`, no "Do you trust the +// contents", no `Yes, continue` / `No, quit`. So the previous parser returned +// not-visible for a live, blocking dialog. +// +// The same STRUCTURAL rule as the legacy layout, for the same reason (prose +// that quotes the dialog must never raise a blocking modal): a row that is +// exactly `Folder access`, then BELOW it an adjacent `1.` / `2.` pair, then +// BELOW that the key hint. Each piece must be a whole line, and the option +// labels must be ones upstream can paint. The question paragraph is NOT an +// anchor: it has three different texts (trust / restricted / existing task) +// and it wraps at every width. +// +// WHY the labels are read from the screen and not fixed. Upstream varies both: +// option 1: "Trust and continue", or "Open restricted" for a folder saved +// as untrusted, or "Open existing task" when resuming one; +// option 2: "Quit", or "Back to Agent Command Center" when Codex is +// connected to its background server (TrustCancelAction). +// Both option-2 labels were seen locally on the same 0.157.1 binary in +// different folders. A fixed "Quit" button would lie in the second case: the +// key goes back to the overview, and Codex keeps running. +// +// The hint must agree with option 2 (`esc quit` with Quit, `esc back` with +// Back…), because upstream derives both from the same TrustCancelAction. A +// frame where they disagree is not a real render. +// +// Width floor: every anchor line is at most 33 characters +// (" 2. Back to Agent Command Center"), and upstream's own 40-column snapshots +// keep each on one row, so detection holds at 40 columns. The exception is +// the Windows sandbox hint (46 characters), which wraps below 46 columns. +// When an anchor wraps, detection fails closed (not visible), the same +// failure direction as the legacy layout's floor. +const FOLDER_ACCESS_RE = /^\s*Folder access\s*$/ +const FIRST_OPTION_RE = /^\s*[›>]?\s*1\.\s*(Trust and continue|Open restricted|Open existing task)\s*$/ +const SECOND_OPTION_RE = /^\s*[›>]?\s*2\.\s*(Quit|Back to Agent Command Center)\s*$/ +// "and create sandbox" is the Windows variant of the confirm hint. +const HINT_RE = /^\s*enter continue(?: and create sandbox)?\s*·\s*esc (quit|back)\s*$/ +// Only the note's first words: the sentence wraps as early as "…of a" at 40 +// columns (upstream's long_repository_root_40x17 snapshot). +const GIT_NOTE_RE = /^\s*Note: You[’']re in a subdirectory/ +const GIT_NOTE_END_RE = /repository root:\s*$/ +// The three fixed paragraph openers. They end a path block when the dialog is +// so short that upstream drops the blank spacer rows (the 40x13 snapshot). +const PARAGRAPH_OPENER_RE = /^\s*(Trust this folder\?|Config, hooks, and exec policies|This existing task may retain)/ + +function detectFolderAccessLayout(screen: string): CodexTrustDialogState | null { + if (!screen.includes('Folder access')) return null + const lines = screen.split('\n') + + const anchorIdx = lines.findIndex(line => FOLDER_ACCESS_RE.test(line)) + if (anchorIdx === -1) return null + + // The option pair is adjacent in every upstream render: two picker rows + // pushed back to back with no spacer between them. + let firstIdx = -1 + for (let i = anchorIdx + 1; i < lines.length - 1; i++) { + if (FIRST_OPTION_RE.test(lines[i]) && SECOND_OPTION_RE.test(lines[i + 1])) { + firstIdx = i + break + } + } + if (firstIdx === -1) return null + const firstLabel = lines[firstIdx].match(FIRST_OPTION_RE)![1] + const secondLabel = lines[firstIdx + 1].match(SECOND_OPTION_RE)![1] - const options = [ - { key: '1', label: 'Yes, continue' }, - { key: '2', label: 'No, quit' }, - ] + // The hint sits below the options, after at most a spacer and an error + // paragraph, so it is searched for rather than expected at a fixed offset. + let hintVerb: string | undefined + for (let i = firstIdx + 2; i < lines.length; i++) { + const m = lines[i].match(HINT_RE) + if (m) { + hintVerb = m[1] + break + } + } + if (!hintVerb) return null + if ((hintVerb === 'quit') !== (secondLabel === 'Quit')) return null - return { visible: true, workspace, options } + const { workspace, trustTarget } = readFolderAccessPaths(lines.slice(anchorIdx + 1, firstIdx)) + + return { + visible: true, + workspace: workspace ?? trustTarget, + ...(trustTarget !== undefined ? { trustTarget } : {}), + options: [ + { key: '1', label: firstLabel }, + { key: '2', label: secondLabel }, + ], + layout: 'folder-access', + acceptKeys: CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS, + declineKeys: CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS, + } +} + +// Reads the folder path under `Folder access` and, in a Git subdirectory, the +// repository root under the note. +// +// WHY rows are concatenated with no separator: upstream renders a path as a +// ratatui Paragraph with `trim: false` in a (width - 4) column, so a long path +// hard-wraps at an arbitrary CHARACTER ("…/long-nested-folde" + "r" in the +// 40x24 snapshot), not at a separator. Stripping the 2-column inset and the +// right padding, then joining, restores it. The one thing this cannot restore +// is a space that sat exactly at a wrap boundary; a folder name with a space +// in precisely that column loses it. A path too tall for its rows is instead +// centre-truncated to one row with `…`, which is reported as painted. +function readFolderAccessPaths(block: string[]): { workspace?: string; trustTarget?: string } { + const joinRows = (rows: string[]) => { + const text = rows.map(row => row.replace(/^ {2}/, '').replace(/\s+$/, '')).join('') + return text.length > 0 ? text : undefined + } + const endsPathBlock = (line: string) => + line.trim() === '' || GIT_NOTE_RE.test(line) || PARAGRAPH_OPENER_RE.test(line) + + let i = 0 + const cwdRows: string[] = [] + while (i < block.length && !endsPathBlock(block[i])) cwdRows.push(block[i++]) + while (i < block.length && block[i].trim() === '') i++ + + let trustTarget: string | undefined + if (i < block.length && GIT_NOTE_RE.test(block[i])) { + // The note paragraph wraps too; it ends on the row ending "root:". + while (i < block.length && !GIT_NOTE_END_RE.test(block[i])) i++ + i++ + const rootRows: string[] = [] + while (i < block.length && !endsPathBlock(block[i])) rootRows.push(block[i++]) + trustTarget = joinRows(rootRows) + } + return { workspace: joinRows(cwdRows), trustTarget } } /** @@ -138,3 +322,32 @@ export const CODEX_TRUST_DIALOG_ACCEPT_KEYS = '1' * next. */ export const CODEX_TRUST_DIALOG_DECLINE_KEYS = '2' + +/** + * The keystrokes that choose option 1 on the 0.156+ `Folder access` layout. + * + * `1` then Enter, NOT `1` alone. Upstream changed what the digit does + * (trust_directory.rs at rust-v0.157.1): `1`/`y` (SELECT_FIRST) now only MOVES + * THE HIGHLIGHT to option 1, "trust always requires an explicit Enter + * confirmation" (a terminal colour-query reply can begin with `1`), and only + * Enter (CONFIRM) acts on the highlighted row. So the legacy `'1'` would + * leave the dialog up, and a bare `'\r'` would confirm whatever happened to + * be highlighted, which may be option 2. `1` first pins the highlight, so the + * Enter that follows can only confirm option 1. + * + * Source-verified, not live-verified: answering the dialog live writes the + * trust decision into the user's own ~/.codex/config.toml, so the recording + * (testing/fixtures/trust-dialog-0157) deliberately never pressed a key. + * The upstream unit test `fragmented_terminal_response_cannot_grant_directory_trust` + * pins exactly this: digits move the highlight, Enter grants. + */ +export const CODEX_TRUST_DIALOG_FOLDER_ACCESS_ACCEPT_KEYS = '1\r' + +/** + * The keystroke that chooses option 2 on the 0.156+ layout. + * + * Still `2` alone: `2`/`n` (SELECT_SECOND) acts immediately (`handle_quit`), + * as before. What option 2 MEANS varies (quit, or back to the overview), which + * is why the condition labels its action from the screen. + */ +export const CODEX_TRUST_DIALOG_FOLDER_ACCESS_DECLINE_KEYS = '2' diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.ts index 46f5e8c..70a66c4 100644 --- a/src/transcript/prompt-input/Codex01491ComposerSurface.ts +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.ts @@ -154,7 +154,10 @@ function findPreviousNonBlank(rows: readonly string[], from: number): number { } function isKnownNonComposerModal(text: string): boolean { + // The 0.156+ trust dialog has none of the legacy phrases (#65); its key hint + // row is the one line of it that always sits in this bottom window. return /Do you trust the contents of this directory/i.test(text) || + /enter continue(?: and create sandbox)? · esc (?:quit|back)/i.test(text) || /Press enter to continue/i.test(text) || /Would you like to run the following command/i.test(text) || /Yes, and don't ask again/i.test(text) || diff --git a/testing/fixtures/trust-dialog-0157/folder-access-back.json b/testing/fixtures/trust-dialog-0157/folder-access-back.json new file mode 100644 index 0000000..e387d59 --- /dev/null +++ b/testing/fixtures/trust-dialog-0157/folder-access-back.json @@ -0,0 +1,163 @@ +{ +"source": "codex-cli 0.157.1 (standalone ~/.local/bin/codex), spawned through node-pty at its default 80x24 in a fresh untrusted scratch directory on 2026-09-27. No keystroke was sent: the dialog was observed, never answered, and the process was killed after 9 s (the observed marker). Redacted at equal length: the account name, the session uuid and the directory suffix inside the scratch path.", +"cols": 80, +"rows": 24, +"events": [ +{ +"t": 52, +"dir": "out", +"data": "\u001b[?2004h\u001b[>4;0m\u001b[>7u\u001b[?1004h" +}, +{ +"t": 53, +"dir": "out", +"data": "\u001b[6n\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[?u\u001b[c" +}, +{ +"t": 302, +"dir": "out", +"data": "\u001b[?2026h\u001b[?25l\u001b[?1049h\u001b[>4;0m\u001b[>7u\u001b[?1007l\u001b[?1000h\u001b[?1002h\u001b[?1006h\u001b[?1003h\u001b[?25l" +}, +{ +"t": 302, +"dir": "out", +"data": "\u001b[1;1H\u001b[J" +}, +{ +"t": 303, +"dir": "out", +"data": "\u001b[?2026h\u001b[?25l\u001b[1;1H\u001b[2m\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[2;1H\u2502 >_ \u001b[22m\u001b[1mOpenAI Codex\u001b[22m\u001b[2m\u001b[2m (v0.157.1) \u2502\u001b[3;1H\u2502 \u2502\u001b[4;1H\u2502 model: \u001b[3mloading\u001b[23m \u001b[22m\u001b[38;2;99;168;248;49m/model\u001b[2m\u001b[39;49m to change \u2502\u001b[5;1H\u2502 directory: \u001b[22mloading\u001b[2m \u2502\u001b[6;1H\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[21;1H\u001b[22m\u001b[1m\u203a\u001b[21;3H\u001b[22m\u001b[2m\u001b[2mAsk Codex to do anything\u001b[24;3H\u001b[22m\u001b[1m?\u001b[24;5H\u001b[22mfor\u001b[24;9Hshortcuts\u001b[39m\u001b[49m\u001b[0m\u001b[1;1H\u001b[0 q\u001b[1;1H\u001b[2m\u256d\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l\u001b[?2026l" +}, +{ +"t": 303, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 304, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 305, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 306, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 307, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 364, +"dir": "out", +"data": "\u001b[?2026h\u001b[?25l\u001b[1;41H\u001b[2m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[2;41H \u2502\u001b[3;41H \u2502\u001b[4;41H \u2502\u001b[5;14H\u001b[22m/private/tmp/claude-501/\u2026/rec/untrusted-ABCD\u001b[2m \u2502\u001b[6;41H\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h" +}, +{ +"t": 364, +"dir": "out", +"data": "\u001b[?2026l" +}, +{ +"t": 365, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 365, +"dir": "out", +"data": "\u001b[?1006l\u001b[?1015l\u001b[?1003l\u001b[?1002l\u001b[?1000l\u001b[<1u\u001b[?1007l\u001b[?1049l\u001b[<1u\u001b[>4;0m\u001b[?2004l\u001b[?1004l\u001b[0 q\u001b[?25h" +}, +{ +"t": 1522, +"dir": "out", +"data": "\u001b[?2004h\u001b[>4;0m\u001b[>7u\u001b[?1004h\u001b[?25l\u001b[?1049h\u001b[>4;0m\u001b[>7u\u001b[?1007l\u001b[?1000h\u001b[?1002h\u001b[?1006h\u001b[?1003h\u001b[?25l\u001b[1;1H\u001b[J\u001b[?2026h\u001b[?25l\u001b[1;1H\u001b[2m\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[2;1H\u2502 >_ \u001b[22m\u001b[1mOpenAI Codex\u001b[22m\u001b[2m\u001b[2m (v0.157.1) \u2502\u001b[3;1H\u2502 \u2502\u001b[4;1H\u2502 model: \u001b[3mloading\u001b[23m \u001b[22m\u001b[38;2;99;168;248;49m/model\u001b[2m\u001b[39;49m to change \u2502\u001b[5;1H\u2502 directory: \u001b[22m/private/tmp/claude-501/\u2026/rec/untrusted-ABCD\u001b[2m \u2502\u001b[6;1H\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[21;1H\u001b[22m\u001b[1m\u203a\u001b[21;3H\u001b[22m\u001b[2m\u001b[2mAsk Codex to do anything\u001b[24;3H\u001b[22m\u001b[1m?\u001b[24;5H\u001b[22mfor\u001b[24;9Hshortcuts\u001b[39m\u001b[49m\u001b[0m\u001b[1;1H\u001b[0 q\u001b[1;1H\u001b[2m\u256d\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25" +}, +{ +"t": 1522, +"dir": "out", +"data": "h\u001b[?2026l" +}, +{ +"t": 1530, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1637, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1691, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1694, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1716, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1742, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1742, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1744, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1745, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1753, +"dir": "out", +"data": "\u001b[?2026h\u001b[39m\u001b[49m\u001b[0m\u001b[21;3H\u001b[?25h\u001b[?2026l" +}, +{ +"t": 1848, +"dir": "out", +"data": "\u001b[?2026h\u001b[?25l" +}, +{ +"t": 1848, +"dir": "out", +"data": "\u001b[1;2H\u001b[0m\u001b[49m\u001b[K\u001b[2;16H\u001b[0m\u001b[49m\u001b[K\u001b[5;2H\u001b[0m\u001b[49m\u001b[K\u001b[21;2H\u001b[0m\u001b[49m\u001b[K\u001b[24;2H\u001b[0m\u001b[49m\u001b[K\u001b[1;1H \u001b[2;1H\u001b[1m Folder access\u001b[3;1H\u001b[22m \u001b[2m/private/tmp/claude-501/-Users-fixture-user-Desktop-Development-agent-code/d\u001b[4;1H\u001b[22m \u001b[2m0000000-0000-0000-0000-000000000000/scratchpad/rec/untrusted-ABCD \u001b[5;1H\u001b[22m \u001b[6;1H Trust this folder? Codex can read, edit, and run files here,\u001b[6;64Hsubject\u001b[6;72Hto\u001b[6;75Hyour\u001b[7;3Hpermission\u001b[7;14Hsettings.\u001b[7;24HFolder\u001b[7;31Hsettings\u001b[7;40Hcan\u001b[7;44Hrun\u001b[7;48Hcode\u001b[7;53Hautomatically,\u001b[7;68Heven\u001b[8;3Hwithout\u001b[8;11Ha\u001b[8;13Hmodel\u001b[8;19Hrequest.\u001b[8;28HContinue\u001b[8;37Honly\u001b[8;42Hif\u001b[8;45Hyou\u001b[8;49Htrust\u001b[8;55Hthese\u001b[8;61Hfiles.\u001b[8;68HYour\u001b[8;73Htrust\u001b[9;3Hdecision\u001b[9;12Hwill\u001b[9;17Hbe\u001b[9;20Hsaved.\u001b[11;1H\u001b[7m\u001b[1m\u203a 1. Trust and continue \u001b[12;3H\u001b[27m\u001b[22m2.\u001b[12;6HBack\u001b[12;11Hto\u001b[12;14HAgent\u001b[12;20HCommand\u001b[12;28HCenter\u001b[14;3H\u001b[1menter\u001b[22m\u001b[2m\u001b[2m continue \u00b7 \u001b[22m\u001b[1mesc\u001b[22m\u001b[2m\u001b[2m back\u001b[21;1H\u001b[22m \u001b[39m\u001b[49m\u001b[" +}, +{ +"t": 1848, +"dir": "out", +"data": "0m\u001b[?2026l" +}, +{ +"t": 9010, +"dir": "in", +"label": "observed", +"data": "" +}, +{ +"t": 9019, +"dir": "exit", +"code": 0 +} +] +} \ No newline at end of file diff --git a/testing/fixtures/trust-dialog-0157/upstream-snapshots-0157.1.json b/testing/fixtures/trust-dialog-0157/upstream-snapshots-0157.1.json new file mode 100644 index 0000000..09e885f --- /dev/null +++ b/testing/fixtures/trust-dialog-0157/upstream-snapshots-0157.1.json @@ -0,0 +1,50 @@ +{ + "evidence": "Upstream insta snapshots of TrustDirectoryWidget at tag rust-v0.157.1 (commit 36650394c5b3), copied verbatim from openai/codex (Apache-2.0). They are upstream TEST OUTPUT, not local recordings: they cover the layout variants a single local folder cannot produce (Git subdirectory, saved-untrusted, existing task, trust-write error, 40-column truncation).", + "snapshots": [ + { + "name": "existing_untrusted_task", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__existing_untrusted_task.snap", + "frame": "\n Folder access\n /workspace/project \n\n This existing task may retain settings and history, including\n project configuration or hooks loaded while it was trusted. To use\n restricted settings, start a new task. The folder's trust setting\n will not change.\n\n› 1. Open existing task \n 2. Back to Agent Command Center\n\n enter continue · esc back" + }, + { + "name": "folder_picker_restricted_40x24", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__folder_picker_restricted_40x24.snap", + "frame": "\n Folder access\n /workspace/project/long-nested-folde\n r \n\n Config, hooks, and exec policies\n from untrusted folders stay\n disabled. Trusted project folders\n can still contribute settings.\n Skills still load, and tools follow\n your permission settings. Opening\n will not change saved trust.\n\n› 1. Open restricted \n 2. Back to Agent Command Center\n\n enter continue · esc back" + }, + { + "name": "long_checkout_40x13", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__long_checkout_40x13.snap", + "frame": " Folder access\n workspace/…/repository \n Trust this folder? Codex can read,\n edit, and run files here, subject to\n your permission settings. Folder\n settings can run code automatically,\n even without a model request.\n Continue only if you trust these\n files. Your trust decision will be\n saved.\n› 1. Trust and continue \n 2. Quit\n enter continue · esc quit" + }, + { + "name": "long_repository_root_40x17", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__long_repository_root_40x17.snap", + "frame": " Folder access\n workspace/…/repository/checkout \n Note: You’re in a subdirectory of a \n Git project. Trusting will apply to \n the repository root: \n workspace/…/repository \n Trust this folder? Codex can read,\n edit, and run files here, subject to\n your permission settings. Folder\n settings can run code automatically,\n even without a model request.\n Continue only if you trust these\n files. Your trust decision will be\n saved.\n› 1. Trust and continue \n 2. Quit\n enter continue · esc quit" + }, + { + "name": "only_repository_root_fits_40x16", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__only_repository_root_fits_40x16.snap", + "frame": " Folder access\n Note: You’re in a subdirectory of a \n Git project. Trusting will apply to \n the repository root: \n workspace/…/repository \n Trust this folder? Codex can read,\n edit, and run files here, subject to\n your permission settings. Folder\n settings can run code automatically,\n even without a model request.\n Continue only if you trust these\n files. Your trust decision will be\n saved.\n› 1. Trust and continue \n 2. Quit\n enter continue · esc quit" + }, + { + "name": "renders_restricted_folder", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_restricted_folder.snap", + "frame": "\n Folder access\n /workspace/project \n\n Config, hooks, and exec policies from untrusted folders stay\n disabled. Trusted project folders can still contribute settings.\n Skills still load, and tools follow your permission settings.\n Opening will not change saved trust.\n\n› 1. Open restricted \n 2. Back to Agent Command Center\n\n enter continue · esc back" + }, + { + "name": "renders_snapshot_for_git_repo", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap", + "frame": "\n Folder access\n /workspace/project \n\n Trust this folder? Codex can read, edit, and run files here,\n subject to your permission settings. Folder settings can run code\n automatically, even without a model request. Continue only if you\n trust these files. Your trust decision will be saved.\n\n› 1. Trust and continue \n 2. Quit\n\n enter continue and create sandbox · esc quit" + }, + { + "name": "renders_snapshot_for_remote_git_subdirectory", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_remote_git_subdirectory.snap", + "frame": "\n Folder access\n /srv/remote/project/nested\n\n Note: You’re in a subdirectory of a Git project. Trusting will\n apply to the repository root:\n /srv/remote/project\n\n Trust this folder? Codex can read, edit, and run files here,\n subject to your permission settings. Folder settings can run code\n automatically, even without a model request. Continue only if you\n trust these files. Your trust decision will be saved.\n\n› 1. Trust and continue\n 2. Back to Agent Command Center\n\n enter continue · esc back" + }, + { + "name": "renders_snapshot_for_trust_error", + "source": "openai/codex rust-v0.157.1 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap", + "frame": "\n Folder access\n /workspace/project \n\n Trust this folder? Codex can read, edit, and run files here,\n subject to your permission settings. Folder settings can run code\n automatically, even without a model request. Continue only if you\n trust these files. Your trust decision will be saved.\n\n› 1. Trust and continue \n 2. Quit\n\n Failed to set trust for /workspace/project: config/batchWrite \n failed in TUI: Invalid configuration: features.fast_mode=true is \n not supported; allowed set [fast_mode=false] \n\n enter continue · esc quit" + } + ] +} \ No newline at end of file From 7f60d862c36b3a3e79f8f0388fdd606ce5b47ac6 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 23:39:25 -0700 Subject: [PATCH 3/6] fix(trust): anchor the 0.156+ dialog at the bottom of the screen; accept a wrapped hint Review of #67 (a, b): a transcript quoting the dialog verbatim was read as a live, answerable trust dialog. The dialog is Codex's onboarding screen, so its key hint is the last painted row; a quoted copy always has the live composer below it. Detection now reads bottom-up: hint last (one wrap allowed, for the Windows sandbox hint below 46 columns), nearest adjacent option pair above, nearest Folder access above that. Adds tests for the copied frame, the wrapped hint, an unknown option-1 label, option adjacency, both option-2 condition labels, legacy streaming suppression, and the CodexHeadless trust_dialog callbacks driven through the public class with the 0.157.1 recording. Refs #65 Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-27-trust-dialog-0157.md | 15 ++++ src/CodexHeadless.trustDialog.test.ts | 47 +++++++++++++ src/parsers/TrustDialogParser.test.ts | 52 ++++++++++++++ src/parsers/TrustDialogParser.ts | 68 +++++++++++++------ .../prompt-input/Codex01491ComposerSurface.ts | 5 +- 5 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 src/CodexHeadless.trustDialog.test.ts diff --git a/docs/plans/2026-09-27-trust-dialog-0157.md b/docs/plans/2026-09-27-trust-dialog-0157.md index 292e497..0eb8194 100644 --- a/docs/plans/2026-09-27-trust-dialog-0157.md +++ b/docs/plans/2026-09-27-trust-dialog-0157.md @@ -54,3 +54,18 @@ - 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. diff --git a/src/CodexHeadless.trustDialog.test.ts b/src/CodexHeadless.trustDialog.test.ts new file mode 100644 index 0000000..42d6660 --- /dev/null +++ b/src/CodexHeadless.trustDialog.test.ts @@ -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> = [] +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']) +}) diff --git a/src/parsers/TrustDialogParser.test.ts b/src/parsers/TrustDialogParser.test.ts index fb2eb89..4a432d8 100644 --- a/src/parsers/TrustDialogParser.test.ts +++ b/src/parsers/TrustDialogParser.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { trustDialogModule } from '../conditions/trustDialog.js' +import { extractCodexStreamingText } from './ScreenParser.js' import { CODEX_TRUST_DIALOG_ACCEPT_KEYS, CODEX_TRUST_DIALOG_DECLINE_KEYS, @@ -164,6 +165,45 @@ describe('detectCodexTrustDialog on the 0.156+ Folder access layout', () => { expect(detectCodexTrustDialog(prose).visible).toBe(false) }) + it('ignores a verbatim copy of the dialog inside a transcript', () => { + // Review of #67 (a and b): a pasted upstream .snap or copied terminal frame + // satisfied every whole-line anchor and raised an answerable phantom. What + // a copy cannot fake is position: the live composer is always below it. + for (const name of ['renders_snapshot_for_remote_git_subdirectory', 'renders_restricted_folder']) { + const transcript = [ + '• Here is the Codex screen I captured:', + frame(name), + '', + '› Ask Codex to do anything', + '', + ' gpt-6-sol high · ~/project', + ].join('\n') + expect(detectCodexTrustDialog(transcript).visible).toBe(false) + // And the quoted frame stays visible as assistant text. + expect(extractCodexStreamingText(transcript)).toContain('Folder access') + } + }) + + it('reads the Windows sandbox hint wrapped over two rows at narrow widths', () => { + // Upstream wraps "enter continue and create sandbox · esc quit" (46 columns + // with the inset) below 46 columns. Built from the upstream git_repo frame, + // which carries that hint, re-wrapped the way a 40-column Paragraph does. + const wrapped = frame('renders_snapshot_for_git_repo').replace( + /^\s*enter continue and create sandbox · esc quit\s*$/m, + ' enter continue and create sandbox ·\n esc quit', + ) + expect(wrapped).toContain('sandbox ·\n esc quit') + expect(detectCodexTrustDialog(wrapped).visible).toBe(true) + }) + + it('rejects an option 1 label upstream cannot paint', () => { + expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace('1. Trust and continue', '1. Delete folder')).visible).toBe(false) + }) + + it('requires the two options to be adjacent rows', () => { + expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace(/(1\. Trust and continue[^\n]*)\n/, '$1\n\n')).visible).toBe(false) + }) + it('requires the key hint below the options', () => { expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace(/\n.*enter continue.*$/m, '')).visible).toBe(false) }) @@ -209,6 +249,18 @@ describe('keystrokes and condition actions per layout', () => { ]) }) + it('labels option 2 Quit when that is what the screen says', () => { + // Both option-2 texts are live on the same binary; the Back variant alone + // would not catch a condition that always said "Back" (review of #67 b). + const state = detectCodexTrustDialog(frame('renders_snapshot_for_git_repo')) + expect(trustDialogModule.actions(state).map(action => action.label)).toEqual(['Trust and continue', 'Quit']) + }) + + it('keeps blanking the streaming text for the legacy dialog', () => { + // ScreenParser now delegates to this detector; pin the legacy side too. + expect(extractCodexStreamingText(REAL_DIALOG)).toBe('') + }) + it('hands every caller its own action objects', () => { const state = detectCodexTrustDialog(frame('renders_snapshot_for_git_repo')) const first = trustDialogModule.actions(state) diff --git a/src/parsers/TrustDialogParser.ts b/src/parsers/TrustDialogParser.ts index ac2957f..27be130 100644 --- a/src/parsers/TrustDialogParser.ts +++ b/src/parsers/TrustDialogParser.ts @@ -176,11 +176,12 @@ function detectYouAreInLayout(screen: string): CodexTrustDialogState | null { // contents", no `Yes, continue` / `No, quit`. So the previous parser returned // not-visible for a live, blocking dialog. // -// The same STRUCTURAL rule as the legacy layout, for the same reason (prose -// that quotes the dialog must never raise a blocking modal): a row that is -// exactly `Folder access`, then BELOW it an adjacent `1.` / `2.` pair, then -// BELOW that the key hint. Each piece must be a whole line, and the option -// labels must be ones upstream can paint. The question paragraph is NOT an +// The same STRUCTURAL rule as the legacy layout, for the same reason (text +// that quotes the dialog must never raise a blocking modal), plus position: +// the key hint must be the LAST painted row (see detectFolderAccessLayout), +// with the nearest adjacent `1.` / `2.` pair above it and the nearest +// `Folder access` title above that. Each piece must be a whole line, and the +// option labels must be ones upstream can paint. The question paragraph is NOT an // anchor: it has three different texts (trust / restricted / existing task) // and it wraps at every width. // @@ -199,9 +200,9 @@ function detectYouAreInLayout(screen: string): CodexTrustDialogState | null { // // Width floor: every anchor line is at most 33 characters // (" 2. Back to Agent Command Center"), and upstream's own 40-column snapshots -// keep each on one row, so detection holds at 40 columns. The exception is -// the Windows sandbox hint (46 characters), which wraps below 46 columns. -// When an anchor wraps, detection fails closed (not visible), the same +// keep each on one row, so detection holds at 40 columns. The Windows sandbox +// hint (46 characters) wraps below 46 columns; one wrap is accepted. When +// anything wraps further, detection fails closed (not visible), the same // failure direction as the legacy layout's floor. const FOLDER_ACCESS_RE = /^\s*Folder access\s*$/ const FIRST_OPTION_RE = /^\s*[›>]?\s*1\.\s*(Trust and continue|Open restricted|Open existing task)\s*$/ @@ -220,13 +221,38 @@ function detectFolderAccessLayout(screen: string): CodexTrustDialogState | null if (!screen.includes('Folder access')) return null const lines = screen.split('\n') - const anchorIdx = lines.findIndex(line => FOLDER_ACCESS_RE.test(line)) - if (anchorIdx === -1) return null + // WHY the match is anchored at the BOTTOM of the screen and read upward + // (review of #67, both reviewers). The first cut accepted the first + // `Folder access` line anywhere, then any later option pair and hint. A + // transcript that quotes this dialog verbatim (a pasted upstream .snap, a + // copied terminal frame, this repo's own plan) satisfied all of that and + // raised a blocking, ANSWERABLE phantom whose keys would then be written + // into whatever screen was really up. What a copy cannot fake is position: + // this dialog is Codex's onboarding screen, painted before any chat widget + // exists, so its key hint is the last painted row. A quoted frame inside a + // transcript always has the live composer (and footer) below it. + let last = lines.length - 1 + while (last >= 0 && lines[last].trim() === '') last-- + if (last < 0) return null + + // The hint is a wrapping paragraph. The Windows variant ("enter continue + // and create sandbox · esc quit", 46 columns with its inset) wraps onto a + // second row below 46 columns, so the last row alone or the last two rows + // joined must read as the hint. + let hintStart = last + let hintMatch = lines[last].match(HINT_RE) + if (!hintMatch && last > 0 && lines[last - 1].trim() !== '') { + hintMatch = `${lines[last - 1].trim()} ${lines[last].trim()}`.match(HINT_RE) + hintStart = last - 1 + } + if (!hintMatch) return null + const hintVerb = hintMatch[1] - // The option pair is adjacent in every upstream render: two picker rows - // pushed back to back with no spacer between them. + // The option pair is adjacent in every upstream render (two picker rows + // pushed back to back, no spacer) and is the nearest pair above the hint; + // only a spacer and an optional error paragraph sit between them. let firstIdx = -1 - for (let i = anchorIdx + 1; i < lines.length - 1; i++) { + for (let i = hintStart - 2; i >= 0; i--) { if (FIRST_OPTION_RE.test(lines[i]) && SECOND_OPTION_RE.test(lines[i + 1])) { firstIdx = i break @@ -235,19 +261,17 @@ function detectFolderAccessLayout(screen: string): CodexTrustDialogState | null if (firstIdx === -1) return null const firstLabel = lines[firstIdx].match(FIRST_OPTION_RE)![1] const secondLabel = lines[firstIdx + 1].match(SECOND_OPTION_RE)![1] + if ((hintVerb === 'quit') !== (secondLabel === 'Quit')) return null - // The hint sits below the options, after at most a spacer and an error - // paragraph, so it is searched for rather than expected at a fixed offset. - let hintVerb: string | undefined - for (let i = firstIdx + 2; i < lines.length; i++) { - const m = lines[i].match(HINT_RE) - if (m) { - hintVerb = m[1] + // The nearest `Folder access` above the options is the dialog's own title. + let anchorIdx = -1 + for (let i = firstIdx - 1; i >= 0; i--) { + if (FOLDER_ACCESS_RE.test(lines[i])) { + anchorIdx = i break } } - if (!hintVerb) return null - if ((hintVerb === 'quit') !== (secondLabel === 'Quit')) return null + if (anchorIdx === -1) return null const { workspace, trustTarget } = readFolderAccessPaths(lines.slice(anchorIdx + 1, firstIdx)) diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.ts index 70a66c4..b032c48 100644 --- a/src/transcript/prompt-input/Codex01491ComposerSurface.ts +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.ts @@ -155,9 +155,10 @@ function findPreviousNonBlank(rows: readonly string[], from: number): number { function isKnownNonComposerModal(text: string): boolean { // The 0.156+ trust dialog has none of the legacy phrases (#65); its key hint - // row is the one line of it that always sits in this bottom window. + // row is the one line of it that always sits in this bottom window (it may + // wrap once, the Windows variant below 46 columns, hence \s around the dot). return /Do you trust the contents of this directory/i.test(text) || - /enter continue(?: and create sandbox)? · esc (?:quit|back)/i.test(text) || + /enter continue(?: and create sandbox)?\s*·\s*esc (?:quit|back)/i.test(text) || /Press enter to continue/i.test(text) || /Would you like to run the following command/i.test(text) || /Yes, and don't ask again/i.test(text) || From 99a7a7b66384832edf639e63b96a42719999b00f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:13:21 -0700 Subject: [PATCH 4/6] fix(trust): anchor the legacy dialog at the bottom too; classify the 0.156+ hint row as a modal Review c of #67: - F1: the legacy layout accepted a verbatim quoted dialog above the live composer, the same phantom fixed for 0.156+. Its 'Press enter to continue' hint is the last painted row as well (rust-v0.149.1 and the recorded 0.149.1 frame), so it is read bottom-up the same way. - F2: pinning the composer-surface anchor exposed that the unwrapped 0.156+ hint has the idle-footer shape, so the dialog read as a composer drafting '1. Trust and continue'. It is now a bottom-row structural check (identical to #63's branch); the window-based anchor is removed. - F3: API.md rewritten for both layouts; new constants and the layout type exported. Refs #65 Co-Authored-By: Claude Opus 5.5 --- API.md | 40 +++++++++------ docs/plans/2026-09-27-trust-dialog-0157.md | 4 ++ src/index.ts | 3 ++ src/parsers/TrustDialogParser.test.ts | 36 +++++++++++++ src/parsers/TrustDialogParser.ts | 50 +++++++++++------- .../Codex01491ComposerSurface.test.ts | 51 +++++++++++++++++++ .../prompt-input/Codex01491ComposerSurface.ts | 19 +++++-- 7 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 src/transcript/prompt-input/Codex01491ComposerSurface.test.ts diff --git a/API.md b/API.md index 3dc1e6c..c7d6a5b 100644 --- a/API.md +++ b/API.md @@ -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, @@ -220,7 +222,7 @@ shared verbatim with `claude-code-headless` (see the header comment in | Transcript | `~/.claude/projects//.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` | --- @@ -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` @@ -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'` @@ -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 `, `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 ` 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` diff --git a/docs/plans/2026-09-27-trust-dialog-0157.md b/docs/plans/2026-09-27-trust-dialog-0157.md index 0eb8194..0578149 100644 --- a/docs/plans/2026-09-27-trust-dialog-0157.md +++ b/docs/plans/2026-09-27-trust-dialog-0157.md @@ -69,3 +69,7 @@ - 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. diff --git a/src/index.ts b/src/index.ts index 409e10e..edc7df6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/parsers/TrustDialogParser.test.ts b/src/parsers/TrustDialogParser.test.ts index 4a432d8..3650a28 100644 --- a/src/parsers/TrustDialogParser.test.ts +++ b/src/parsers/TrustDialogParser.test.ts @@ -94,6 +94,42 @@ describe('detectCodexTrustDialog', () => { expect(detectCodexTrustDialog(inverted).visible).toBe(false) }) + it('ignores a verbatim legacy dialog quoted above the live composer', () => { + // Review c of #67: the legacy layout had the same copied-frame phantom the + // 0.156+ layout was fixed for. Its hint is the last painted row too. + const quoted = [ + '• Here is the old Codex screen I captured:', + REAL_DIALOG, + '', + '› Ask Codex to do anything', + '', + ' gpt-5.4 medium fast · ~/project', + ].join('\n') + expect(detectCodexTrustDialog(quoted).visible).toBe(false) + expect(extractCodexStreamingText(quoted)).toContain('Yes, continue') + }) + + it('requires the legacy options to be adjacent rows directly under the question', () => { + const scattered = [ + '> You are in /tmp/x', + ' Do you trust the contents of this directory?', + '• unrelated assistant paragraph one', + '› 1. Yes, continue', + '• unrelated assistant paragraph two', + ' 2. No, quit', + ' Press enter to continue', + ].join('\n') + expect(detectCodexTrustDialog(scattered).visible).toBe(false) + }) + + it('reads the legacy Windows hint wrapped over two rows', () => { + const wrapped = REAL_DIALOG.replace( + ' Press enter to continue', + ' Press enter to continue and create a\n sandbox...', + ) + expect(detectCodexTrustDialog(wrapped).visible).toBe(true) + }) + it('returns not-visible for empty input', () => { expect(detectCodexTrustDialog('').visible).toBe(false) }) diff --git a/src/parsers/TrustDialogParser.ts b/src/parsers/TrustDialogParser.ts index 27be130..2cfe5e5 100644 --- a/src/parsers/TrustDialogParser.ts +++ b/src/parsers/TrustDialogParser.ts @@ -92,6 +92,7 @@ const YOU_ARE_IN_RE = /^\s*>\s*You are in\s+(.+?)\s*$/ // keys, so either row may or may not be marked. const YES_ROW_RE = /^\s*[›>]?\s*1\.\s*Yes, continue\s*$/ const NO_ROW_RE = /^\s*[›>]?\s*2\.\s*No, quit\s*$/ +const LEGACY_HINT_RE = /^\s*Press enter to continue(?: and create a sandbox\.\.\.)?\s*$/ /** * Detect Codex's trust dialog from a plain-text screen snapshot. @@ -107,11 +108,38 @@ export function detectCodexTrustDialog(screen: string): CodexTrustDialogState { function detectYouAreInLayout(screen: string): CodexTrustDialogState | null { if (!QUESTION_RE.test(screen)) return null - const lines = screen.split('\n') - let anchorIdx = -1 + + // WHY bottom-up, like the 0.156+ layout (review c of #67). The first + // structural version only required the anchor with the two option rows + // somewhere below it, so a transcript quoting the dialog verbatim (or even + // with unrelated rows between its lines) was still a live, answerable + // phantom. rust-v0.149.1's trust_directory.rs paints "Press enter to + // continue" (or the Windows "… and create a sandbox..." variant) as the + // dialog's LAST row, below an optional error paragraph, and the options are + // adjacent picker rows; a quoted copy always has the live composer below it. + let last = lines.length - 1 + while (last >= 0 && lines[last].trim() === '') last-- + if (last < 0) return null + let hintStart = last + if (!LEGACY_HINT_RE.test(lines[last])) { + if (last === 0 || lines[last - 1].trim() === '' || + !LEGACY_HINT_RE.test(`${lines[last - 1].trim()} ${lines[last].trim()}`)) return null + hintStart = last - 1 + } + + let yesIdx = -1 + for (let i = hintStart - 2; i >= 0; i--) { + if (YES_ROW_RE.test(lines[i]) && NO_ROW_RE.test(lines[i + 1])) { + yesIdx = i + break + } + } + if (yesIdx === -1) return null + let workspace: string | undefined - for (let i = 0; i < lines.length; i++) { + let anchorIdx = -1 + for (let i = yesIdx - 1; i >= 0; i--) { const m = lines[i].match(YOU_ARE_IN_RE) if (m) { anchorIdx = i @@ -121,22 +149,6 @@ function detectYouAreInLayout(screen: string): CodexTrustDialogState | null { } if (anchorIdx === -1) return null - // Both option rows must appear BELOW the anchor, in order. Scanning the - // whole screen would re-admit a transcript that happens to quote them. - let yesIdx = -1 - let noIdx = -1 - for (let i = anchorIdx + 1; i < lines.length; i++) { - if (yesIdx === -1 && YES_ROW_RE.test(lines[i])) { - yesIdx = i - continue - } - if (yesIdx !== -1 && NO_ROW_RE.test(lines[i])) { - noIdx = i - break - } - } - if (yesIdx === -1 || noIdx === -1) return null - return { visible: true, workspace, diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.test.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.test.ts new file mode 100644 index 0000000..eb9651a --- /dev/null +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import type { StableTerminalFrame } from '../../terminal/HeadlessTerminal.js' +import { classifyCodex01491ComposerSurface } from './Codex01491ComposerSurface.js' + +// Review c of #67: the 0.156+ trust-hint anchor in this surface survived the +// whole suite when broken. With it broken, the new trust dialog reads as +// something other than a modal to the prompt-input surface, so these pin it, +// with the hint as Codex paints it (last row) and wrapped once (the Windows +// variant below 46 columns). Rows are the 80-column 0.157.1 dialog from +// testing/fixtures/trust-dialog-0157, below its hard-wrapped path. +function frame(rows: string[]): StableTerminalFrame { + return { + generation: 1, + layoutEpoch: 0, + providerLayoutEpoch: 0, + cols: 80, + cursor: { x: 0, y: 0 }, + rows: rows.map(text => ({ text, cells: [...text], isWrapped: false })), + } +} + +const DIALOG = [ + ' Folder access', + ' /private/tmp/claude-501/-Users-fixture-user-Desktop-Development-agent-code/d', + ' 0000000-0000-0000-0000-000000000000/scratchpad/rec/untrusted-ABCD', + '', + ' Trust this folder? Codex can read, edit, and run files here, subject to your', + ' permission settings. Folder settings can run code automatically, even', + ' without a model request. Continue only if you trust these files. Your trust', + ' decision will be saved.', + '', + '› 1. Trust and continue', + ' 2. Back to Agent Command Center', + '', +] + +describe('classifyCodex01491ComposerSurface on the 0.156+ trust dialog', () => { + it('treats the dialog as a modal, not a composer', () => { + expect(classifyCodex01491ComposerSurface(frame([...DIALOG, ' enter continue · esc back']))) + .toEqual({ kind: 'non-composer-modal' }) + }) + + it('treats the dialog as a modal when the Windows hint wraps', () => { + expect(classifyCodex01491ComposerSurface(frame([ + ...DIALOG, + ' enter continue and create sandbox ·', + ' esc back', + ]))).toEqual({ kind: 'non-composer-modal' }) + }) +}) diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.ts index b032c48..c728d2d 100644 --- a/src/transcript/prompt-input/Codex01491ComposerSurface.ts +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.ts @@ -16,6 +16,14 @@ const HISTORY_FOOTER = /^\s{2}reverse-i-search:/i const COMPLETION_FOOTER = /^\s{2}Press enter to insert or esc to close\s*$/i const QUEUE_FOOTER = /^ tab to queue(?: message)?\s+\d+% context left\s*$/i const IDLE_FOOTER = /^ \S.*\s·\s.+$/u +// The 0.156+ trust dialog's key hint (#63, codex-headless#65). Codex paints it +// as the dialog's LAST row, where a composer paints its status footer, and it +// also has the IDLE_FOOTER shape (" enter continue · esc quit"). Without this +// check the dialog was read as a composer whose "draft" is +// "1. Trust and continue". It is matched on the bottom row only (one wrap +// allowed: the Windows "… and create sandbox ·" hint wraps below 46 columns), +// so the same words typed into a draft are never mistaken for it. +const TRUST_HINT_FOOTER = /^\s*enter continue(?: and create sandbox)?\s*·\s*esc (?:quit|back)\s*$/i // Codex 0.149.1 renders Vim mode as a distinct right-hand status atom, with a // run of layout padding before the atom and no content after it. A cwd ending // in `/Vim: Insert` is part of the left status value and has neither boundary. @@ -43,6 +51,11 @@ export function classifyCodex01491ComposerSurface( const bottom = rows[lastNonBlank] ?? '' if (HISTORY_FOOTER.test(bottom)) return { kind: 'history-search' } if (COMPLETION_FOOTER.test(bottom)) return { kind: 'completion-popup' } + const aboveBottom = rows[lastNonBlank - 1] ?? '' + if (TRUST_HINT_FOOTER.test(bottom) || + (aboveBottom.trim() !== '' && TRUST_HINT_FOOTER.test(`${aboveBottom.trim()} ${bottom.trim()}`))) { + return { kind: 'non-composer-modal' } + } const composerRow = findComposerRow(rows, lastNonBlank) if (composerRow >= 0) { @@ -154,11 +167,9 @@ function findPreviousNonBlank(rows: readonly string[], from: number): number { } function isKnownNonComposerModal(text: string): boolean { - // The 0.156+ trust dialog has none of the legacy phrases (#65); its key hint - // row is the one line of it that always sits in this bottom window (it may - // wrap once, the Windows variant below 46 columns, hence \s around the dot). + // The 0.156+ trust dialog is recognised structurally by TRUST_HINT_FOOTER + // before this point, since its hint is always the pane's last row. return /Do you trust the contents of this directory/i.test(text) || - /enter continue(?: and create sandbox)?\s*·\s*esc (?:quit|back)/i.test(text) || /Press enter to continue/i.test(text) || /Would you like to run the following command/i.test(text) || /Yes, and don't ask again/i.test(text) || From 295412e790f4c5601f33202839e4ba15f198c6f5 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:22:35 -0700 Subject: [PATCH 5/6] test(trust): pin the option-2 label whitelist where the hint cannot reject it Verification a of #67: with 'esc quit' the hint/label agreement already rejects an unknown option 2, so the whitelist mutation survived. The restricted-folder frame ('esc back') makes the whitelist the only guard. Refs #65 Co-Authored-By: Claude Opus 5.5 --- src/parsers/TrustDialogParser.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/parsers/TrustDialogParser.test.ts b/src/parsers/TrustDialogParser.test.ts index 3650a28..213770d 100644 --- a/src/parsers/TrustDialogParser.test.ts +++ b/src/parsers/TrustDialogParser.test.ts @@ -253,6 +253,9 @@ describe('detectCodexTrustDialog on the 0.156+ Folder access layout', () => { it('rejects an option label upstream cannot paint', () => { expect(detectCodexTrustDialog(frame('renders_snapshot_for_git_repo').replace('2. Quit', '2. Delete folder')).visible).toBe(false) + // With "esc back" the hint agrees with any non-Quit label, so only the + // label whitelist rejects this one (verification a of #67). + expect(detectCodexTrustDialog(frame('renders_restricted_folder').replace('2. Back to Agent Command Center', '2. Delete folder')).visible).toBe(false) }) it('rejects a hint that contradicts option 2', () => { From d9a13053c584285a8f12760aae94db16f642d401 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:38:24 -0700 Subject: [PATCH 6/6] docs(plans): strip trailing whitespace (verification b of #67) Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-27-trust-dialog-0157.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-09-27-trust-dialog-0157.md b/docs/plans/2026-09-27-trust-dialog-0157.md index 0578149..cbd754a 100644 --- a/docs/plans/2026-09-27-trust-dialog-0157.md +++ b/docs/plans/2026-09-27-trust-dialog-0157.md @@ -67,7 +67,7 @@ - **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.