Files
arboretum/spikes/s3-tui/run2.mjs
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

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