From 8595e84c56dedbe7e8da1cab06fbb3785dd4689c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 16:01:48 +0200 Subject: [PATCH] fix(session): auto-accept the workspace trust dialog again A session on a fresh directory sat on Claude's "Quick safety check: Is this a project you created or one you trust?" dialog until a human pressed Enter. Reproduced on a new case, then read off the wire: 1.\x1b[C Yes,\x1b[C I\x1b[C trust\x1b[C this\x1b[C folder tmux repaints a row by writing each word followed by a cursor-forward escape instead of a space, and Ink colours each word separately, so `data.includes('trust this folder')` could never match a chunk. The spaces are not there to strip: they were never sent. The auto-accept has been dead for every session that hit the dialog. Match on whitespace-free, ANSI-free, lowercased text instead (`compactScreenText`), which survives both that repaint style and the spaced full-screen redraw. Answering means pressing Enter into a session, so three guards bound it: - Read the RENDERED SCREEN (capturePaneText), not the chunk. The terminal buffer is append-only and keeps the dialog in its tail long after it has been answered, so a retry driven off the buffer would type into a live session. Direct-PTY sessions, which have no pane, fall back to a short buffer tail. - Require a trust phrase AND the dialog's own confirm affordance. One phrase is not enough, since an agent's transcript can quote it. - Only look during the first 90s of the pane's life, and cap it at three attempts. Ink can drop a keystroke while it is still mounting the widget, which is the other half of why sessions got stuck, but a dialog that will not clear must not become an Enter loop. Verified end to end on a fresh case: dialog answered on attempt 1, one Enter sent in total, session went straight to the composer and answered a prompt. Before the fix the same flow parked on the dialog indefinitely. Co-Authored-By: Claude Opus 5 (1M context) --- src/session-trust-dialog.ts | 92 ++++++++++++++++++ src/session.ts | 73 +++++++++++++-- test/session-trust-dialog.test.ts | 151 ++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/session-trust-dialog.ts create mode 100644 test/session-trust-dialog.test.ts diff --git a/src/session-trust-dialog.ts b/src/session-trust-dialog.ts new file mode 100644 index 00000000..ff7e2db0 --- /dev/null +++ b/src/session-trust-dialog.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Recognizing Claude Code's workspace-trust dialog on screen. + * + * Claude asks once per directory before it will read or edit anything: + * + * Quick safety check: Is this a project you created or one you trust? ... + * ❯ 1. Yes, I trust this folder + * 2. No, exit + * Enter to confirm · Esc to cancel + * + * Codeman sessions run permission-skipping or classifier-guarded modes, so the + * answer is always yes, and a session parked on this dialog is simply stuck. + * + * **Why the text has to be compacted.** tmux repaints a row by writing each word + * and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each + * word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. + * Stripping the escapes leaves `Itrustthisfolder`: the spaces are not there to + * strip, they were never sent. A plain `includes('trust this folder')` therefore + * never matched a single chunk, which is why the auto-accept had been silently + * dead. Removing ALL whitespace instead is what survives both that repaint style + * and the spaced full-screen redraw. + * + * **Why two markers are required.** Answering means pressing Enter, so a false + * positive types into a live session. One phrase is not enough: an agent's own + * transcript can quote it (this file does). Matching a trust phrase AND the + * dialog's confirm affordance is the cheap way to require the actual widget, and + * the caller adds the real guard by only looking during session startup. + */ + +import { stripAnsi } from './utils/index.js'; + +/** Phrases from the question or the "yes" option, whitespace removed, lowercased. */ +const TRUST_PHRASES = [ + 'trustthisfolder', // 2.x: "1. Yes, I trust this folder" + 'trustthefiles', // older: "Do you trust the files in this folder?" + 'oneyoutrust', // 2.x question: "a project you created or one you trust?" +]; + +/** The dialog's own affordances. Prose that quotes the question will not have these. */ +const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit']; + +/** + * Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and + * `stripAnsi` does not cover. Left in, they would land inside a phrase as a + * literal `(B` and break the match. + */ +// eslint-disable-next-line no-control-regex +const CHARSET_SELECT = /\x1b[()][AB0]/g; + +/** + * Normalize a screen or PTY chunk for phrase matching: escapes dropped, every + * whitespace run removed, lowercased. + */ +export function compactScreenText(text: string): string { + return stripAnsi(text).replace(CHARSET_SELECT, '').replace(/\s+/g, '').toLowerCase(); +} + +/** + * True when this text is the trust dialog rather than something merely talking + * about it. Feed the RENDERED SCREEN where possible: the session's terminal + * buffer is append-only, so the dialog stays in its tail long after it is gone. + */ +export function isTrustDialogScreen(text: string): boolean { + const compact = compactScreenText(text); + return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p)); +} + +/** + * How long after the pane starts the dialog is still plausible. It renders + * before the main UI, so this only has to cover a slow first launch; leaving it + * open forever would let a transcript that quotes the dialog trigger an Enter. + */ +export const TRUST_DIALOG_WINDOW_MS = 90_000; + +/** Minimum gap between two Enter presses, and between two screen reads. */ +export const TRUST_DIALOG_RETRY_MS = 1500; + +/** + * Attempts before giving up and leaving the dialog to the user. A keystroke can + * land while Ink is still mounting the widget and be dropped, which is the other + * half of why sessions got stuck here; retrying costs nothing, but retrying + * forever would hammer Enter into whatever came next. + */ +export const TRUST_DIALOG_MAX_ATTEMPTS = 3; + +/** + * How much of the append-only terminal buffer to read on a direct-PTY session, + * which has no pane to capture. Small on purpose: the dialog scrolls out of a + * short tail as soon as Claude repaints its main UI, which is what keeps a + * fallback retry from firing at an already-answered dialog. + */ +export const TRUST_DIALOG_SCAN_BYTES = 4000; diff --git a/src/session.ts b/src/session.ts index fbce6cc9..7d6dce1c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -59,6 +59,13 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; +import { + isTrustDialogScreen, + TRUST_DIALOG_WINDOW_MS, + TRUST_DIALOG_RETRY_MS, + TRUST_DIALOG_MAX_ATTEMPTS, + TRUST_DIALOG_SCAN_BYTES, +} from './session-trust-dialog.js'; import { trackActivityStreak, isSustainedActivity, @@ -389,7 +396,10 @@ export class Session extends EventEmitter { private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection) private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read) - private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept + private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) + private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog + private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read + private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; // Token tracking for auto-clear @@ -1527,6 +1537,12 @@ export class Session extends EventEmitter { throw new Error('Session already has a running process'); } + // Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here + // rather than at PTY spawn so a slow mux attach still counts as startup. + this._interactiveStartedAt = Date.now(); + this._trustDialogAttempts = 0; + this._lastTrustDialogScanAt = 0; + // COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a // short window), refuse to respawn. This is the uniform choke point that stops // automatic recovery/reconnect callers from re-creating a crash-looping PTY. @@ -1756,14 +1772,7 @@ export class Session extends EventEmitter { this._handleTerminalOutput(data); // === Auto-accept workspace trust dialog === - // Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory. - // Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept. - if (!this._trustDialogAccepted && data.includes('trust this folder')) { - this._trustDialogAccepted = true; - console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`); - // Send Enter to accept the default selection ("Yes, I trust this folder") - this.writeViaMux('\r'); - } + this._maybeAcceptTrustDialog(); // === Idle/working detection runs on every chunk (latency-sensitive) === this._detectInteractiveActivity(data); @@ -1871,6 +1880,52 @@ export class Session extends EventEmitter { return this._respawnBlocked; } + /** + * Answer Claude's workspace-trust dialog, which blocks a fresh case until + * someone presses Enter. Codeman sessions run permission-skipping or + * classifier-guarded modes, so the answer is always "yes, I trust this folder". + * + * Reads the RENDERED SCREEN rather than the chunk that just arrived. tmux + * repaints a row with cursor-forward escapes in place of spaces, so the wire + * carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder` and the old + * `data.includes('trust this folder')` could never match: the auto-accept had + * been dead for every session that hit the dialog. The screen is also what + * makes a retry safe, since the terminal buffer is append-only and keeps the + * dialog in its tail long after it has been answered. + * + * Three guards keep an Enter press off a live session: a startup-only window, + * a two-marker match (isTrustDialogScreen), and an attempt cap. + */ + private _maybeAcceptTrustDialog(): void { + if (this._trustDialogAccepted) return; + const now = Date.now(); + if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) { + this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog + return; + } + if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return; + this._lastTrustDialogScanAt = now; + + // Prefer the pane; fall back to the buffer tail on a direct-PTY session, + // where there is no screen to read. + const screen = + (this._mux && this._muxSession ? this._mux.capturePaneText?.(this._muxSession.muxName) : null) ?? + this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES); + if (!isTrustDialogScreen(screen)) return; + + this._trustDialogAttempts++; + if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) { + this._trustDialogAccepted = true; // leave it to the user rather than keep typing + console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`); + return; + } + console.log( + `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})` + ); + // Enter confirms the highlighted default, "1. Yes, I trust this folder". + this.writeViaMux('\r'); + } + /** * Per-chunk working/idle detection for an interactive pane. Split out of the * PTY `onData` handler so it can be unit tested without spawning one. diff --git a/test/session-trust-dialog.test.ts b/test/session-trust-dialog.test.ts new file mode 100644 index 00000000..5b45a4e2 --- /dev/null +++ b/test/session-trust-dialog.test.ts @@ -0,0 +1,151 @@ +/** + * Workspace-trust dialog auto-accept. + * + * The bug this pins: `data.includes('trust this folder')` could never match, + * because tmux repaints a row with cursor-forward escapes instead of spaces, so + * the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. Every session on a fresh + * directory sat on the dialog until a human pressed Enter. + * + * RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live + * session parked on that dialog (Claude Code 2.1.220). + */ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { Session } from '../src/session.js'; +import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js'; + +/** Verbatim from the wire: note the `\x1b[C` where every space should be. */ +const RAW_DIALOG_CHUNK = + '\x1b[C\x1b[38;5;246m1.\x1b[C\x1b[38;5;153mYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\x1b[15;4H' + + '\x1b[38;5;246m2.\x1b[C\x1b[39mNo,\x1b[Cexit\x1b[17;2H\x1b[38;5;246mEnter\x1b[Cto\x1b[Cconfirm\x1b[C·\x1b[CEsc\x1b[Cto\x1b[Ccancel'; + +/** What `tmux capture-pane -p` shows for the same moment. */ +const RENDERED_DIALOG = [ + ' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source', + ' project, or work from your team). If not, take a moment to review what is in this folder first.', + '', + ' ❯ 1. Yes, I trust this folder', + ' 2. No, exit', + '', + ' Enter to confirm · Esc to cancel', +].join('\n'); + +/** An ordinary working session: no dialog anywhere. */ +const RENDERED_MAIN_UI = [ + '✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', + '────────────────────────────────', + '❯ ', + ' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents', +].join('\n'); + +describe('isTrustDialogScreen', () => { + it('sees the dialog in the raw space-less repaint', () => { + // The whole point: the literal phrase is NOT in this chunk. + expect(RAW_DIALOG_CHUNK.includes('trust this folder')).toBe(false); + expect(isTrustDialogScreen(RAW_DIALOG_CHUNK)).toBe(true); + }); + + it('sees the dialog in the rendered screen', () => { + expect(isTrustDialogScreen(RENDERED_DIALOG)).toBe(true); + }); + + it('does not fire on a normal session screen', () => { + expect(isTrustDialogScreen(RENDERED_MAIN_UI)).toBe(false); + expect(isTrustDialogScreen('')).toBe(false); + }); + + it('does not fire on text that merely quotes the dialog', () => { + // An agent reading or writing about this feature (this file, for one) must + // not cause an Enter press. The confirm affordance is what separates the + // widget from prose about it. + expect(isTrustDialogScreen('the installer asks you to trust this folder before it runs')).toBe(false); + expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false); + }); + + it('compacts away both real spaces and the escapes tmux sends instead', () => { + expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder'); + expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder'); + }); +}); + +describe('Session trust-dialog auto-accept', () => { + afterEach(() => vi.useRealTimers()); + + /** A session whose pane renders `screen`, recording everything written to it. */ + function sessionShowing(screen: () => string) { + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneText: () => screen(), + sendInput: (_id: string, data: string) => { + writes.push(data); + return Promise.resolve(true); + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'claude', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() }, + } as ConstructorParameters[0]); + const internals = session as unknown as { + _maybeAcceptTrustDialog(): void; + _interactiveStartedAt: number; + }; + internals._interactiveStartedAt = Date.now(); + return { session, writes, tick: () => internals._maybeAcceptTrustDialog() }; + } + + it('presses Enter when the dialog is on screen', () => { + vi.useFakeTimers(); + const { writes, tick } = sessionShowing(() => RENDERED_DIALOG); + tick(); + expect(writes).toEqual(['\r']); + }); + + it('retries a dropped keystroke, then gives up rather than typing forever', () => { + vi.useFakeTimers(); + // Ink can drop a keystroke while it is still mounting the widget, so one + // press is not always enough; a stuck dialog must not become an Enter loop. + const { writes, tick } = sessionShowing(() => RENDERED_DIALOG); + for (let i = 0; i < 20; i++) { + tick(); + vi.advanceTimersByTime(2000); + } + expect(writes.length).toBe(TRUST_DIALOG_MAX_ATTEMPTS); + }); + + it('stops once the dialog is answered', () => { + vi.useFakeTimers(); + let screen = RENDERED_DIALOG; + const { writes, tick } = sessionShowing(() => screen); + tick(); + expect(writes).toEqual(['\r']); + + screen = RENDERED_MAIN_UI; + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(2000); + tick(); + } + expect(writes).toEqual(['\r']); + }); + + it('never answers a dialog-looking screen outside the startup window', () => { + vi.useFakeTimers(); + // A live agent can print this text hours in; only a launching pane can be + // showing the real widget. + const { writes, tick } = sessionShowing(() => RENDERED_DIALOG); + vi.advanceTimersByTime(10 * 60_000); + tick(); + expect(writes).toEqual([]); + }); + + it('does not press Enter on a normal screen', () => { + vi.useFakeTimers(); + const { writes, tick } = sessionShowing(() => RENDERED_MAIN_UI); + for (let i = 0; i < 5; i++) { + tick(); + vi.advanceTimersByTime(2000); + } + expect(writes).toEqual([]); + }); +});