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:
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