Spike S3: TUI dialogs, keystrokes — GO (documented partial)
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.
This commit is contained in:
32
spikes/s3-tui/VERDICT.md
Normal file
32
spikes/s3-tui/VERDICT.md
Normal file
@@ -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 `<chiffre>` + `\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["<dir>"].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.
|
||||||
166
spikes/s3-tui/run2.mjs
Normal file
166
spikes/s3-tui/run2.mjs
Normal file
@@ -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);
|
||||||
Reference in New Issue
Block a user