#!/usr/bin/env node // Acceptation P1 (sans navigateur, sans quota Claude) : vrai daemon + vrai client WS. // Couvre : login, hello, sub, spawn bash, attach+replay, stdin/output, flood 10 Mo // avec flow control par ACK + RSS borné, déconnexion/reconnexion+replay, kill, drain. import { spawn } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const WebSocket = require('ws'); const PORT = 7541; const ORIGIN = `http://127.0.0.1:${PORT}`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); const results = []; const check = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`); }; const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-')); const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db')], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'], }); let srvOut = ''; srv.stdout.on('data', (d) => (srvOut += d)); srv.stderr.on('data', (d) => (srvOut += d)); try { await sleep(1500); const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; check('boot + token bootstrap', !!token); const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, body: JSON.stringify({ token }), }); const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; check('login → cookie', login.status === 200 && cookie.startsWith('arb_session=')); // Client WS minimal multiplexé function wsClient() { const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); ws.binaryType = 'arraybuffer'; const state = { msgs: [], outputs: new Map(), resyncs: new Map(), bytes: new Map() }; ws.on('message', (data, isBinary) => { if (!isBinary) { state.msgs.push(JSON.parse(String(data))); return; } const buf = Buffer.from(data); const type = buf.readUInt8(0); const channel = buf.readUInt32LE(1); const payload = buf.subarray(5); if (type === 0x02) { state.resyncs.set(channel, (state.resyncs.get(channel) ?? 0) + 1); state.bytes.set(channel, 0); // resync : le compteur ACK repart de zéro state.outputs.set(channel, payload.toString('latin1')); } else { state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-300000)); const total = (state.bytes.get(channel) ?? 0) + payload.length; state.bytes.set(channel, total); // ACK tous les 64 Kio traités (invariant : pas d'ACK <= LOW_WATERMARK) const acked = state.lastAck?.get(channel) ?? 0; if (total - acked >= 64 * 1024) { (state.lastAck ??= new Map()).set(channel, total); ws.send(JSON.stringify({ type: 'ack', channel, bytes: total })); } } }); const waitMsg = async (pred, timeout = 8000) => { const t0 = Date.now(); while (Date.now() - t0 < timeout) { const m = state.msgs.find(pred); if (m) return m; await sleep(50); } return null; }; return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; } const c1 = wsClient(); await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej))); c1.send({ type: 'hello', protocol: 1 }); check('hello_ok', !!(await c1.waitMsg((m) => m.type === 'hello_ok'))); c1.send({ type: 'sub', topics: ['sessions'] }); // Spawn bash + attach const created = await fetch(`${ORIGIN}/api/v1/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie }, body: JSON.stringify({ cwd: tmp, command: 'bash' }), }); const sid = (await created.json()).session.id; check('spawn bash', created.status === 201 && !!sid); c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 }); const att = await c1.waitMsg((m) => m.type === 'attached'); check('attach interactif + controlling', att?.controlling === true); const ch = att.channel; // stdin → output await sleep(400); c1.send({ type: 'stdin', channel: ch, data: 'echo ACCEPT-$((21*2))\r' }); await sleep(800); check('stdin → output', (c1.state.outputs.get(ch) ?? '').includes('ACCEPT-42')); // Flood 10 Mo (base64 → ~14 Mo de flux TTY) avec ACK, RSS serveur borné const rssBefore = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1]; c1.send({ type: 'stdin', channel: ch, data: 'head -c 10485760 /dev/urandom | base64; echo FLOOD-END-MARKER\r' }); const t0 = Date.now(); let floodOk = false; let received = 0; while (Date.now() - t0 < 60000) { received = c1.state.bytes.get(ch) ?? 0; if ((c1.state.outputs.get(ch) ?? '').includes('FLOOD-END-MARKER')) { floodOk = true; break; } await sleep(100); } const rssAfter = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1]; const rssMb = Math.round((Number(rssAfter) - Number(rssBefore)) / 1024); check('flood 10 Mo complet via ACK', floodOk && received > 13_000_000, `${(received / 1048576).toFixed(1)} Mo reçus en ${Date.now() - t0} ms`); check('RSS serveur borné pendant le flood', Number(rssAfter) < 300 * 1024, `Δ ${rssMb} Mo (RSS ${Math.round(Number(rssAfter) / 1024)} Mo)`); // Déconnexion brutale → la session survit → reconnexion + replay c1.ws.terminate(); await sleep(2000); const c2 = wsClient(); await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej))); c2.send({ type: 'hello', protocol: 1 }); await c2.waitMsg((m) => m.type === 'hello_ok'); c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 100, rows: 30 }); const att2 = await c2.waitMsg((m) => m.type === 'attached'); await sleep(500); const replay = c2.state.outputs.get(att2.channel) ?? ''; check('reconnexion : session vivante + replay', att2?.controlling === true && c2.state.resyncs.get(att2.channel) === 1 && replay.length > 1000, `replay ${(replay.length / 1024).toFixed(0)} Kio`); c2.send({ type: 'stdin', channel: att2.channel, data: 'echo AFTER-RECONNECT\r' }); await sleep(800); check('terminal utilisable après reconnexion', (c2.state.outputs.get(att2.channel) ?? '').includes('AFTER-RECONNECT')); // Kill + notification session_exit (sub) c2.send({ type: 'sub', topics: ['sessions'] }); await fetch(`${ORIGIN}/api/v1/sessions/${sid}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } }); const exited = await c2.waitMsg((m) => m.type === 'session_exit' && m.sessionId === sid, 8000); const detached = await c2.waitMsg((m) => m.type === 'detached' && m.reason === 'session_exit', 8000); check('kill → session_exit + detached broadcast', !!exited && !!detached); c2.ws.close(); } catch (err) { check('exception', false, String(err)); } finally { srv.kill('SIGTERM'); await sleep(1500); check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); rmSync(tmp, { recursive: true, force: true }); const failed = results.filter((r) => !r.ok); console.log(failed.length === 0 ? '\nACCEPTANCE P1: ALL GREEN' : `\nACCEPTANCE P1: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }