Files
Codeman/test/session-trust-dialog.test.ts
Codeman maintainer aaa93d4252 fix(session): answer Claude Code 2.1.252's reversed folder-trust dialog
Every claude session in a directory claude had not seen before died about six
seconds after it started (`Pane is dead (status 1)`), before the agent drew a
composer. Reproduced on a fresh case and measured.

Claude Code 2.1.252 rewrote the dialog. It used to be

  ❯ 1. Yes, I trust this folder
    2. No, exit

and is now unnumbered, reversed, and highlights the option that quits:

  ❯ No, exit
    Yes, I trust this folder

Detection still worked (the confirm affordance carries the match once the
numbered option text is gone), so the failure was entirely in the answer: the
auto-accept pressed Enter on the highlighted default, which is now exit.

trustDialogNextKey() reads the ❯ marker off the rendered pane and returns ONE
keystroke at a time: an arrow while the cursor is on the wrong option, Enter
only once the screen shows it on the trust option, and null for a frame that
does not say. Both layouts are handled, and which way the trust option lies is
read from the frame rather than assumed, so a further reordering costs a
repaint instead of a session. The last marked option wins, because the
direct-PTY fallback reads an append-only buffer where an older frame must not
out-vote the freshest one.

Two things only a live pane showed:

- The scan ran solely from the PTY onData handler. The arrow that moves the
  cursor is the last output the pane produces, so the first fix parked every
  session with the cursor sitting on the right option and no Enter ever sent.
  It now schedules its own follow-up read (_trustDialogTimer, cleared in
  _clearAllTimers()), offset past the scan throttle so the chain cannot break
  on a boundary.
- The keystroke cap goes 3 -> 6, since answering is no longer one press.

The bundled codeman skill had the same blind \r as its bounded fallback, so
preamble 1.21.0 replaces it with _trust_key/_accept_trust: read
terminal?full=1, steer onto the trust option, re-read, then confirm. Those
keystrokes go out under their own clientId, because input sequence numbers are
monotonic per client and spending prompt numbers on dialog keys would make the
next send-and-wait look like a stale duplicate and vanish while reporting
success. The readiness recipes in docs/extending-codeman.md,
docs/api-reference.md and the skill's own reference carry the corrected answer,
plus a symptom-table entry for a worker whose pane is dead seconds after spawn.

Verified live on an isolated instance (own data dir and tmux socket): fresh
case -> arrow at 5 s -> Enter at 7 s -> composer, with hasTrustDialogAccepted
recorded. With the server-side auto-accept disabled in a throwaway copy, the
skill's fallback cleared a genuinely parked dialog in 1.1 s and spawn_worker
took a brand-new case to a live composer in 7.2 s; spawn_workers + sendwait +
last_text then ran end to end.
2026-09-01 02:31:26 +02:00

252 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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).
*
* The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put
* "No, exit" first and highlights IT, so the blind Enter that answered the old
* layout selects *exit* and the pane dies seconds after the session starts.
* RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import {
isTrustDialogScreen,
compactScreenText,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_KEY_DOWN,
TRUST_KEY_UP,
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');
/**
* Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no
* numbers, the options reversed, and the cursor parked on the one that quits.
*/
const RENDERED_DIALOG_2_1_252 = [
' Accessing workspace:',
' /home/arkon/codeman-cases/trustprobe1',
' 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's in this folder first.",
" Claude Code'll be able to read, edit, and execute files here.",
' Security guide',
' ❯ No, exit',
' Yes, I trust this folder',
' Enter to confirm · Esc to cancel',
].join('\n');
/** The same screen after one arrow press: the cursor has moved onto "yes". */
const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,');
/** 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('sees the 2.1.252 dialog, whose options lost their numbers', () => {
// '2.no,exit' is gone from this layout, so the confirm affordance is now the
// only thing carrying the match.
expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true);
});
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('trustDialogNextKey', () => {
it('confirms straight away when the trust option is already highlighted', () => {
expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM);
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM);
});
it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => {
// The regression in one line: Enter here answers *exit* and kills the pane.
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN);
});
it('moves UP when the trust option is the one above, as in the numbered layout', () => {
const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace(
' 2. No, exit',
' ❯ 2. No, exit'
);
expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP);
});
it('reads the LAST frame in an append-only buffer, not the first', () => {
// The direct-PTY fallback has no pane to capture, so it reads a buffer that
// still holds every repaint since launch. The freshest frame is the truth.
const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`;
expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM);
});
it('presses nothing when the screen does not say which option is selected', () => {
// A layout this cannot read is a dialog for the human, not a coin flip: the
// wrong guess exits Claude.
const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit');
expect(trustDialogNextKey(noMarker)).toBe(null);
expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null);
expect(trustDialogNextKey('')).toBe(null);
});
});
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<typeof Session>[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('walks the 2.1.252 dialog onto the trust option before it confirms', () => {
vi.useFakeTimers();
// The whole point: no Enter goes out while "No, exit" is highlighted.
let screen = RENDERED_DIALOG_2_1_252;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN]);
screen = RENDERED_DIALOG_2_1_252_ON_YES;
vi.advanceTimersByTime(2000);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]);
});
it('never presses Enter while the cursor sits on "No, exit"', () => {
vi.useFakeTimers();
// A dialog that never moves (a dropped arrow, a wedged pane) must run out of
// attempts pressing arrows, not answer *exit* on the way.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN));
});
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([]);
});
});