P4-A: commande WS answer — répondre aux dialogues sans clavier

Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).

- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
  intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-15 12:05:27 +02:00
parent 8b41140af7
commit 9f7c13dddb
14 changed files with 265 additions and 6 deletions

View File

@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
expect(typeof msg.data).toBe('string');
expect(msg.data.length).toBeLessThanOrEqual(65536);
break;
case 'answer':
expect(isU32(msg.channel)).toBe(true);
expect(['select', 'confirm', 'deny']).toContain(msg.action);
if (msg.action === 'select') {
expect(typeof msg.optionN).toBe('number');
expect(msg.optionN).toBeGreaterThanOrEqual(1);
expect(msg.optionN).toBeLessThanOrEqual(99);
}
break;
case 'resize':
expect(isU32(msg.channel)).toBe(true);
expect(isDim(msg.cols)).toBe(true);
@@ -92,6 +101,18 @@ describe('parseClientMessage — cas valides', () => {
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
});
it('answer select / confirm / deny', () => {
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
});
it('ack à zéro octet', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
});
@@ -153,6 +174,15 @@ describe('parseClientMessage — cas malformés', () => {
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
});
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
});
it('ack : bytes négatif ou non numérique', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
@@ -168,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
describe('parseClientMessage — fuzz rapide', () => {
it('ne lève jamais et tout message accepté respecte les invariants', () => {
const rand = mulberry32(0xa5b0e7);
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
const values: unknown[] = [
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
];
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
for (let i = 0; i < 1000; i++) {
const obj: Record<string, unknown> = { type: pick(types) };
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
if (rand() < 0.7) obj[key] = pick(values);
}
const raw = JSON.stringify(obj);