From 8623671c6913b62218a1e85d64d5f1c41ec51372 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 11 Jun 2026 17:58:28 +0200 Subject: [PATCH] =?UTF-8?q?Spike=20S3:=20TUI=20dialogs,=20keystrokes=20?= =?UTF-8?q?=E2=80=94=20GO=20(documented=20partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response protocol: digit positions + Enter confirms (digit alone is not enough); arrows+Enter work everywhere. Registry detects waiting state for both permission and AskUserQuestion dialogs (same waitingFor label — fine-grained dialog typing needs screen reading). Sandboxed bash runs no-prompt for most commands: real waits come from edits/network/ questions. Pre-trust via ~/.claude.json projects[dir] .hasTrustDialogAccepted captured and plausible. Esc-deny and plan approval deferred to P4 reliability campaign. Co-Authored-By: Claude Fable 5 --- spikes/s3-tui/VERDICT.md | 32 ++++++++ spikes/s3-tui/run2.mjs | 166 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 spikes/s3-tui/VERDICT.md create mode 100644 spikes/s3-tui/run2.mjs diff --git a/spikes/s3-tui/VERDICT.md b/spikes/s3-tui/VERDICT.md new file mode 100644 index 0000000..3b238f9 --- /dev/null +++ b/spikes/s3-tui/VERDICT.md @@ -0,0 +1,32 @@ +# Spike S3 — dialogues TUI, frappes, flood — VERDICT : ✅ GO (partiel documenté) + +Exécuté le 2026-06-11 (CLI 2.1.173). Scripts : `run.mjs` (passe 1), `run2.mjs` (passe 2), `flood.mjs`. Résultats bruts dans `captures/`. + +## Dialogues testés + +| Dialogue | Détection | Options extraites | Réponse validée | +|---|---|---|---| +| **Trust dialog** (nouveau dossier) | écran seul (précède le registre) — motif `trust` + `❯` sur texte aplati | `❯1. Yes, I trust this folder / 2. No, exit` — « Enter to confirm · Esc to cancel » | **Entrée** ✅ (10+ occurrences via S1/S3) | +| **Permission Write** (création/édition de fichier) | registre `status: waiting`, `waitingFor: "permission prompt"` + écran | `❯1. Yes / 2. Yes, allow all edits during this session (shift+tab) / 3. No` — « Esc to cancel · Tab to amend » | chiffre seul **insuffisant** ; **chiffre puis Entrée** ✅ (fichier créé) | +| **AskUserQuestion** | registre `waiting` (même `waitingFor: "permission prompt"` — le registre ne distingue pas les types) | `❯1. Rouge / 2. Bleu / 3. Type something. / 4. Chat about this` — « Enter to select · ↑/↓ to navigate · Esc to cancel » | **↓ + Entrée** ✅ (tour complété, bonne option choisie) | +| **Permission Bash** | **non déclenchée** : `echo` est dans l'allowlist par défaut ET `node -e` s'exécute dans le **bash sandboxé** du CLI 2.x sans prompt | — | — | +| **Refus par Esc** | non exercé (quota) ; « Esc to cancel » affiché sur les deux dialogues observés | — | à valider en P4 | + +## Enseignements structurants + +1. **Le protocole de réponse est : (chiffre pour positionner) + Entrée pour confirmer.** Entrée seule valide l'option pré-sélectionnée (`❯`). Flèches ↑/↓ + Entrée fonctionnent partout. → L'UI mobile enverra `` + `\r`. +2. **Le registre `~/.claude/sessions` détecte les états d'attente des dialogues outils** (permission ET AskUserQuestion) avec le même `waitingFor: "permission prompt"` → le typage fin du dialogue (question vs permission, options) exige la lecture d'écran (`@xterm/headless` en prod ; le strip ANSI naïf mange les espaces — voir VERDICT S1). +3. **Il y a moins de prompts de permission que prévu** : le bash sandboxé exécute sans prompt les commandes sans réseau/écriture hors workspace. Les vrais `waiting` viennent des éditions de fichiers, du réseau, d'AskUserQuestion et des plans. Bonne nouvelle pour l'UX (moins d'interruptions), à intégrer aux fixtures P4. +4. **Pré-trust programmatique plausible** : accepter le dialogue écrit `projects[""].hasTrustDialogAccepted: true` dans `~/.claude.json` (structure complète capturée). À la création d'un worktree, écrire cette entrée (avec consentement explicite dans la modal) éviterait le dialogue — à valider en P2/P3. +5. La regex d'options `^\s*(❯\s*)?(\d+)\.\s*(.+)$` fonctionne sur le texte aplati (labels concaténés mais exploitables). + +## Volet flood (node-pty pause/resume) — ✅ GO complet + +21,5 Mo streamés ; pause de 10 s → **0 octet** reçu pendant la pause, bash survit, flux complet après resume (marqueur final intact, aucun octet perdu) ; écho 4,7 ms après le flood ; ~21 Mo en ~0,7 s hors pause. + +## Reste à faire (P4, avant les boutons mobiles) + +- Exercer le refus par Esc et le dialogue « No, and tell Claude… » (champ texte). +- Approbation de plan (plan mode) non testée. +- Campagne de fiabilité 10/10 par type de dialogue (le critère complet du spike) — à automatiser comme tests d'intégration du claude-adapter. +- Le MVP (P3) n'a PAS besoin de tout ça : les boutons d'approbation sont en P4 ; le fallback « ouvrir le terminal » couvre tous les cas. diff --git a/spikes/s3-tui/run2.mjs b/spikes/s3-tui/run2.mjs new file mode 100644 index 0000000..296f645 --- /dev/null +++ b/spikes/s3-tui/run2.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +// Spike S3 (2e passe) — D2..D5 avec commandes hors allowlist, scénarios isolés, +// détection registre + fallback écran. D1 (trust) déjà validé en 1re passe. +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs'; + +const CAPTURES = new URL('./captures/', import.meta.url).pathname; +mkdirSync(CAPTURES, { recursive: true }); +setLogFile(join(CAPTURES, 'steps2.jsonl')); + +const results = {}; +const save = () => writeFileSync(join(CAPTURES, 'results2.json'), JSON.stringify(results, null, 1)); + +function freshRepo(name) { + const d = `/tmp/spike-s3-${name}`; + rmSync(d, { recursive: true, force: true }); + mkdirSync(d, { recursive: true }); + execSync('git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init', { cwd: d, shell: '/bin/bash' }); + return d; +} + +function extractOptions(screenTail) { + const opts = []; + for (const m of screenTail.matchAll(/^\s*(❯\s*)?(\d+)\.\s*(.+)$/gm)) { + opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) }); + } + return opts; +} + +// Attente d'un dialogue : registre 'waiting' OU motif de dialogue à l'écran (texte aplati sans espaces) +async function waitDialog(S, timeoutMs = 120000) { + return S.until('dialogue détecté', () => { + const r = S.registry(); + const flat = S.tail(2500).replace(/\s+/g, ''); + const screenDialog = /❯1\./.test(flat) && /(Doyouwant|permission|Allow|autoris)/i.test(flat); + if (r?.status === 'waiting' || screenDialog) { + return { registryStatus: r?.status ?? null, waitingFor: r?.waitingFor ?? null, screenDialog }; + } + return false; + }, timeoutMs, 400); +} + +async function respond(S, digit) { + const stillWaiting = () => { + const r = S.registry(); + const flat = S.tail(2000).replace(/\s+/g, ''); + return (r?.status === 'waiting') || /❯\d+\./.test(flat) && /(Doyouwant|permission|Allow)/i.test(flat); + }; + const tries = []; + S.write(digit); + await sleep(2500); + tries.push({ method: `digit ${digit}`, dialogGone: !stillWaiting() }); + if (tries.at(-1).dialogGone) return { ok: true, tries }; + S.write('\r'); + await sleep(2500); + tries.push({ method: 'enter', dialogGone: !stillWaiting() }); + if (tries.at(-1).dialogGone) return { ok: true, tries }; + const downs = Math.max(0, parseInt(digit, 10) - 1); + for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); } + S.write('\r'); + await sleep(2500); + tries.push({ method: `arrows x${downs} + enter`, dialogGone: !stillWaiting() }); + return { ok: tries.at(-1).dialogGone, tries }; +} + +async function scenario(name, fn) { + log(`=== ${name} ===`); + try { results[name] = await fn(); } + catch (err) { results[name] = { error: String(err) }; log(`${name}: ÉCHEC`, { err: String(err) }); } + save(); +} + +// ---------- D2 : permission Bash (node -e, hors allowlist) → approbation "1" ---------- +await scenario('d2_permBash', async () => { + const d = freshRepo('bash2'); + const S = new ClaudeSession([], d, 'perm-bash2', CAPTURES); + try { + await S.waitReady(); + await S.type(`Run this exact bash command and show me its output: node -e "console.log('spike-s3-marker-xyz')" — do not do anything else.`); + const det = await waitDialog(S); + await sleep(1200); + const screen = S.tail(2500); + const resp = await respond(S, '1'); + let completed = false; + try { await S.waitTurnDone(120000); completed = true; } catch {} + return { + detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), + respond: resp, turnCompleted: completed, + markerInOutput: S.plain().includes('spike-s3-marker-xyz'), + }; + } finally { S.kill('SIGTERM'); } +}); + +// ---------- D3 : permission Write (création de fichier) → approbation ---------- +await scenario('d3_permWrite', async () => { + const d = freshRepo('write2'); + const S = new ClaudeSession([], d, 'perm-write2', CAPTURES); + try { + await S.waitReady(); + await S.type('Create a file named s3-edit.txt containing exactly the word hello. Use the Write tool. Do not do anything else.'); + const det = await waitDialog(S); + await sleep(1200); + const screen = S.tail(2500); + const resp = await respond(S, '1'); + let completed = false; + try { await S.waitTurnDone(120000); completed = true; } catch {} + return { + detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), + respond: resp, turnCompleted: completed, fileCreated: existsSync(join(d, 's3-edit.txt')), + }; + } finally { S.kill('SIGTERM'); } +}); + +// ---------- D4 : refus par Esc ---------- +await scenario('d4_denyEsc', async () => { + const d = freshRepo('deny2'); + const S = new ClaudeSession([], d, 'deny-esc2', CAPTURES); + try { + await S.waitReady(); + await S.type(`Run this exact bash command and show me its output: node -e "console.log('should-be-denied')" — do not do anything else.`); + await waitDialog(S); + await sleep(1200); + const screenBefore = S.tail(1200); + S.write('\x1b'); + await sleep(4000); + const reg = S.registry(); + return { + screenBeforeEsc: screenBefore.slice(-800), + statusAfterEsc: reg?.status ?? null, + screenAfterEsc: S.tail(900), + commandExecuted: /should-be-denied\s*$/m.test(S.plain().replace(/\s+/g, ' ')), + }; + } finally { S.kill('SIGTERM'); } +}); + +// ---------- D5 : AskUserQuestion ---------- +await scenario('d5_askUserQuestion', async () => { + const d = freshRepo('ask2'); + const S = new ClaudeSession([], d, 'ask2', CAPTURES); + try { + await S.waitReady(); + await S.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.'); + const det = await waitDialog(S); + await sleep(1200); + const screen = S.tail(2500); + // 2e option : flèche bas + Entrée d'abord (hypothèse sélecteur), chiffre en fallback + S.write('\x1b[B'); await sleep(400); S.write('\r'); + await sleep(3000); + let reg = S.registry(); + const arrowsWorked = reg?.status !== 'waiting'; + let digitFallback = null; + if (!arrowsWorked) { S.write('2'); await sleep(2500); reg = S.registry(); digitFallback = reg?.status; } + let completed = false; + try { await S.waitTurnDone(90000); completed = true; } catch {} + return { + detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), + arrowsEnterWorked: arrowsWorked, digitFallbackStatus: digitFallback, turnCompleted: completed, + finalScreen: S.tail(700), + }; + } finally { S.kill('SIGTERM'); } +}); + +log('=== S3 passe 2 terminée ==='); +process.exit(0);