#!/usr/bin/env node // Acceptation P13 (sans navigateur, sans quota Claude) : « Démarrer le projet » (lancement // multi-terminaux). Vrai daemon + vrai repo git tmp + vrai client WS. Couvre : exposition/persistance // de launch_commands (+ broadcast repo_update), auto-détection (package.json/Procfile/compose), // lancement d'un terminal par commande activée (même launchRunId, command bash, titre = label), // auto-type réellement exécuté (marqueur dans le ring) dans un shell INTERACTIF (survit à la commande), // bornage anti-traversal du cwd + sous-dossier valide, sélection par commandIds, filtrage des désactivées, // et « tout arrêter ». import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 = 7553; 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-p13-')); const repo = join(tmp, 'demo-repo'); mkdirSync(repo, { recursive: true }); mkdirSync(join(repo, 'sub'), { recursive: true }); const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); git('init', '-b', 'main'); git('config', 'user.email', 'test@arboretum.dev'); git('config', 'user.name', 'Test'); // Fichiers pour l'auto-détection. writeFileSync(join(repo, 'package.json'), JSON.stringify({ scripts: { dev: 'echo dev', build: 'echo build', test: 'echo test' } })); writeFileSync(join(repo, 'Procfile'), 'web: echo procweb\n'); writeFileSync(join(repo, 'docker-compose.yml'), 'services: {}\n'); writeFileSync(join(repo, 'README.md'), '# demo\n'); git('add', '-A'); git('commit', '-m', 'init'); const srv = spawn( 'node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { 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)); // Client WS multiplexé (contrôle JSON + sortie binaire → ring décodé en latin1). function wsClient(cookie) { 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() }; 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.outputs.set(channel, payload.toString('latin1')); else state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-200000)); }); 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 j = (path, method, cookie, body) => fetch(`${ORIGIN}${path}`, { method, headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); 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=')); // Enregistre le repo. const reg = await j('/api/v1/repos', 'POST', cookie, { path: repo }); const repoId = (await reg.json()).repo?.id; check('register repo', reg.status === 201 && !!repoId); // launchCommands vide par défaut. const list0 = await (await j('/api/v1/repos', 'GET', cookie)).json(); const r0 = list0.repos.find((r) => r.id === repoId); check('launchCommands défaut = []', Array.isArray(r0?.launchCommands) && r0.launchCommands.length === 0); // Auto-détection. const det = await (await j(`/api/v1/repos/${repoId}/launch/detect`, 'GET', cookie)).json(); const runs = (det.suggestions ?? []).map((s) => s.run); check( 'detect : package.json + Procfile + docker-compose', runs.includes('npm run dev') && runs.includes('npm run build') && runs.some((r) => r.startsWith('echo procweb')) && runs.includes('docker compose up'), `${runs.length} suggestions`, ); check('detect : dev activé, build désactivé (heuristique)', (det.suggestions.find((s) => s.run === 'npm run dev')?.enabled === true) && (det.suggestions.find((s) => s.run === 'npm run build')?.enabled === false)); // Abonnement worktrees pour vérifier le broadcast repo_update. const c1 = wsClient(cookie); await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej))); c1.send({ type: 'hello', protocol: 1 }); await c1.waitMsg((m) => m.type === 'hello_ok'); c1.send({ type: 'sub', topics: ['worktrees', 'sessions'] }); await sleep(200); const nonce = String(token).slice(-6); const commands = [ { id: 'c-web', label: 'web', run: `echo ARB_LAUNCH_OK_${nonce}; echo ARB_FLAGS_$-; sleep 30`, enabled: true }, { id: 'c-api', label: 'api', run: 'sleep 30', enabled: true }, { id: 'c-build', label: 'build', run: 'echo SHOULD_NOT_RUN', enabled: false }, { id: 'c-sub', label: 'sub', run: 'pwd; sleep 30', cwd: 'sub', enabled: false }, { id: 'c-esc', label: 'esc', run: 'pwd', cwd: '../../etc', enabled: false }, ]; const patch = await j(`/api/v1/repos/${repoId}`, 'PATCH', cookie, { launchCommands: commands }); const patched = (await patch.json()).repo; check('PATCH launchCommands persiste', patch.status === 200 && patched.launchCommands.length === 5); const evt = await c1.waitMsg((m) => m.type === 'repo_update' && m.repo?.id === repoId && (m.repo.launchCommands?.length ?? 0) === 5); check('broadcast repo_update porte launchCommands', !!evt); // Lancement par défaut (commandes activées : web, api). const launch = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, {}); const lr = await launch.json(); check('POST /launch → N sessions (activées only)', launch.status === 201 && lr.sessions?.length === 2, `${lr.sessions?.length} sessions`); const runIds = new Set(lr.sessions.map((s) => s.launchRunId)); check('même launchRunId sur tous les terminaux', runIds.size === 1 && [...runIds][0], [...runIds][0]); check('command = bash + titre = label', lr.sessions.every((s) => s.command === 'bash') && lr.sessions.map((s) => s.title).sort().join(',') === 'api,web'); const launchRunId = [...runIds][0]; // Attache au terminal « web » → l'auto-type a été exécuté (marqueur) dans un shell INTERACTIF. const webSid = lr.sessions.find((s) => s.title === 'web').id; c1.send({ type: 'attach', sessionId: webSid, mode: 'interactive', cols: 120, rows: 32 }); const att = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === webSid); await sleep(1200); const out = c1.state.outputs.get(att.channel) ?? ''; check('auto-type exécuté (marqueur dans le ring)', out.includes(`ARB_LAUNCH_OK_${nonce}`), `${out.length} o`); check('shell interactif (flags $- contiennent i)', /ARB_FLAGS_[a-zA-Z]*i/.test(out)); // Le shell survit à la commande (sleep encore vivant). const sess1 = await (await j('/api/v1/sessions', 'GET', cookie)).json(); const apiSid = lr.sessions.find((s) => s.title === 'api').id; check('shell survivant (session live)', sess1.sessions.find((s) => s.id === apiSid)?.live === true); // Sélection par commandIds (web seul). const one = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-web'] })).json(); check('commandIds : sous-ensemble', one.sessions?.length === 1 && one.sessions[0].title === 'web'); // Sous-dossier valide : pwd sous le worktree. const subRes = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-sub'] })).json(); const subSid = subRes.sessions?.[0]?.id; c1.send({ type: 'attach', sessionId: subSid, mode: 'interactive', cols: 120, rows: 32 }); const attSub = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === subSid); await sleep(800); check('cwd sous-dossier borné (pwd dans /sub)', (c1.state.outputs.get(attSub.channel) ?? '').includes('/sub')); // Traversal rejeté : cwd ../../etc. const esc = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-esc'] }); check('cwd traversal rejeté (4xx)', esc.status >= 400 && esc.status < 500, `status ${esc.status}`); // « Tout arrêter » : kill de tous les terminaux du launchRunId initial. const before = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId); for (const s of before) await j(`/api/v1/sessions/${s.id}`, 'DELETE', cookie); // Un shell interactif ignore SIGTERM (standard) → mort garantie au SIGKILL après le délai de grâce (~5 s). await sleep(6500); const after = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId); check('tout arrêter → terminaux non vivants', before.length === 2 && after.length === 2 && after.every((s) => !s.live)); c1.ws.close(); } catch (err) { check('exception', false, String(err && err.stack ? err.stack : err)); } finally { srv.kill('SIGTERM'); await sleep(1500); rmSync(tmp, { recursive: true, force: true }); const failed = results.filter((r) => !r.ok); console.log(failed.length === 0 ? '\nACCEPTANCE P13: ALL GREEN' : `\nACCEPTANCE P13: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }