Files
Codeman/test/session-trust-dialog.test.ts
Codeman maintainer 9aa3083280 Merge pull request #634 from JDProfresh/fix/idle-not-on-trust-dialog
fix(idle): hold idle while the workspace trust dialog is on screen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:55 +02:00

406 lines
16 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';
// The restart case below starts a real direct-PTY session; the real `claude` must never
// run from a test, so pty.spawn runs a harmless stand-in instead.
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (_file: string, _args: string[] | string, options: import('node-pty').IPtyForkOptions) =>
real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options),
};
});
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('reads the dialog that painted inside the throttle of the read before it', () => {
vi.useFakeTimers();
// Measured on 2.1.294: one read at +156 ms saw a blank pane, the dialog painted
// within the throttle, and a quiet dialog sends no further output to read on.
let screen = '';
const { writes, tick } = sessionShowing(() => screen);
tick();
vi.advanceTimersByTime(500);
screen = RENDERED_DIALOG_2_1_252;
tick(); // the dialog's own paint: throttled, and the last output the pane produces
vi.advanceTimersByTime(5000);
expect(writes[0]).toBe(TRUST_KEY_DOWN);
});
it('cancels a deferred read when the session is stopped', async () => {
vi.useFakeTimers();
// DELETE goes through stop(), and a read deferred just before it must not outlive it.
let screen = '';
const { session, tick } = sessionShowing(() => screen);
tick();
screen = RENDERED_DIALOG_2_1_252;
const before = vi.getTimerCount();
tick(); // throttled, so a read is deferred
expect(vi.getTimerCount()).toBe(before + 1);
await session.stop(false);
expect(vi.getTimerCount()).toBe(0);
});
it('neither scans nor arms a timer once the session is stopped', async () => {
vi.useFakeTimers();
// Killing the pane produces output of its own, and the buffer tail still shows the
// dialog, so a deleted session went on "answering" it on a timer for several seconds.
const { session, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
await session.stop(false);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
tick();
tick(); // inside the throttle of the first
expect(vi.getTimerCount()).toBe(0);
expect(log.mock.calls.some(([line]) => String(line).includes('trust dialog'))).toBe(false);
log.mockRestore();
});
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([]);
});
});
describe('Session idle detection while the trust dialog is up', () => {
afterEach(() => vi.useRealTimers());
/** A just-launched pane (busy, inside the startup window) rendering `screen` (null: capture failed). */
function launchingSession(screen: () => string | null) {
const mux = { isAvailable: () => true, capturePaneText: () => screen() };
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as unknown as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
_interactiveStartedAt: number;
_status: string;
};
internals._interactiveStartedAt = Date.now();
internals._status = 'busy'; // what startInteractive() leaves a new pane at
let idles = 0;
session.on('idle', () => idles++);
/** One repaint carrying a `❯`, which is what arms the idle confirmation. */
const paint = (chunk: string) => {
internals._markActivity();
internals._detectInteractiveActivity(chunk);
};
return { session, paint, idles: () => idles };
}
it('does not call the dialog idle, although its own ❯ arms the check', () => {
vi.useFakeTimers();
const { session, paint, idles } = launchingSession(() => RENDERED_DIALOG_2_1_252);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it('goes idle once the dialog has been answered and the composer is up', () => {
vi.useFakeTimers();
let screen = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('busy');
// No new output needed: the check keeps re-reading the screen on its own.
screen = RENDERED_MAIN_UI.replace('✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', '');
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('idle');
expect(idles()).toBe(1);
});
it('keeps holding when a capture fails after the dialog was seen', () => {
vi.useFakeTimers();
// A failed capture says nothing about whether the dialog went away.
let screen: string | null = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(5000);
screen = null;
vi.advanceTimersByTime(20_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it("does not carry the last pane's dialog verdict into a restarted pane", async () => {
// The session saw the dialog on its last pane, then restarted where no capture can
// replace that verdict (here the direct-PTY fallback, which has no pane to read; a
// failed capture keeps it the same way). The new pane must still be able to go idle.
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
(session as unknown as { _lastPaneProbeTrustDialog: boolean })._lastPaneProbeTrustDialog = true;
try {
await session.startInteractive();
vi.useFakeTimers();
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
};
internals._markActivity();
internals._detectInteractiveActivity('❯ ');
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
} finally {
vi.useRealTimers();
await session.stop();
}
});
it('lets a dialog-looking screen go idle outside the startup window', () => {
vi.useFakeTimers();
// A live agent can print the dialog's words hours in, and must not be held busy.
const { session, paint } = launchingSession(() => RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10 * 60_000);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
});
});