Files
arboretum/spikes/s3-tui/run2.mjs
Johan LEROY 8623671c69 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:58:28 +02:00

167 lines
6.6 KiB
JavaScript
Raw 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.
#!/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);