1// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json)2// Sessions détachées (survivent à la déconnexion du client), permissions3// relayées en cartes, monitoring Écosystème temps réel.4import http from 'node:http';5import crypto from 'node:crypto';6import fs from 'node:fs';7import path from 'node:path';8import os from 'node:os';9import { spawn } from 'node:child_process';10import { fileURLToPath } from 'node:url';11import { WebSocketServer } from 'ws';12import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, SITES } from './monitor.js';13import { execFile } from 'node:child_process';1415const __dirname = path.dirname(fileURLToPath(import.meta.url));16const APP_DIR = path.join(__dirname, '..');17const DATA_DIR = path.join(APP_DIR, 'data');18const TRANSCRIPTS_DIR = path.join(DATA_DIR, 'transcripts');19const PUBLIC_DIR = path.join(APP_DIR, 'public');20const HOME = os.homedir();2122fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });2324// ---------- config ----------25const CONFIG_PATH = path.join(DATA_DIR, 'config.json');26if (!fs.existsSync(CONFIG_PATH)) {27 console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>');28 process.exit(1);29}30const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));31const PORT = config.port || 3300;32const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude';33const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node';3435// toutes les variables de ~/.claude/.env (ANTHROPIC_API_KEY, APIFY_TOKEN, …)36// sont injectées dans les runs claude locaux (les runs distants font37// `export $(cat ~/.claude/.env | xargs)` côté nœud)38function loadClaudeEnv() {39 const out = {};40 try {41 for (const line of fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8').split('\n')) {42 const m = line.match(/^([A-Z0-9_]+)=(\S+)/);43 if (m) out[m[1]] = m[2];44 }45 } catch {}46 return out;47}48const CLAUDE_ENV = loadClaudeEnv();49const ANTHROPIC_API_KEY = CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';5051// ---------- audit ----------52const AUDIT_PATH = path.join(DATA_DIR, 'audit.jsonl');53function audit(entry) {54 fs.appendFile(AUDIT_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', () => {});55}5657// ---------- sessions (auth) ----------58const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json');59let sessions = {};60try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {}61function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); }62const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000;63const SESSION_MAX_MS = 30 * 24 * 3600 * 1000;6465function verifyPassword(pw) {66 const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex');67 return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(config.passwordHash));68}69function newSession() {70 const token = crypto.randomBytes(32).toString('hex');71 sessions[token] = { created: Date.now(), last: Date.now() };72 saveSessions();73 return token;74}75function getSession(req) {76 const cookie = req.headers.cookie || '';77 const m = cookie.match(/(?:^|;\s*)akid=([a-f0-9]{64})/);78 if (!m) return null;79 const s = sessions[m[1]];80 if (!s) return null;81 const now = Date.now();82 if (now - s.last > SESSION_IDLE_MS || now - s.created > SESSION_MAX_MS) {83 delete sessions[m[1]]; saveSessions(); return null;84 }85 s.last = now;86 return m[1];87}8889const loginFails = new Map();90function clientIp(req) {91 const xf = req.headers['x-forwarded-for'];92 return (xf ? String(xf).split(',')[0].trim() : req.socket.remoteAddress) || '?';93}94function loginAllowed(ip) {95 const rec = loginFails.get(ip);96 if (!rec) return true;97 if (Date.now() - rec.first > 15 * 60 * 1000) { loginFails.delete(ip); return true; }98 return rec.count < 8;99}100function loginFailed(ip) {101 const rec = loginFails.get(ip) || { first: Date.now(), count: 0 };102 rec.count++; loginFails.set(ip, rec);103}104105// ---------- chats ----------106const CHATS_PATH = path.join(DATA_DIR, 'chats.json');107let chats = [];108try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {}109// migration v1 → v2110for (const c of chats) {111 if (!c.model) c.model = 'fable';112 if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default';113 if (!c.state) c.state = 'idle';114 if (c.totalCost === undefined) c.totalCost = 0;115 if (!c.alwaysAllow) c.alwaysAllow = [];116 if (c.autoAllowAll === undefined) c.autoAllowAll = false;117}118function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); }119function getChat(id) { return chats.find(c => c.id === id); }120function publicChat(c) {121 return {122 id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName,123 model: c.model, permMode: c.permMode, state: c.state, autoAllowAll: !!c.autoAllowAll,124 claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null,125 totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0,126 created: c.created, updated: c.updated,127 };128}129function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); }130function appendTranscript(chatId, event) {131 fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {});132}133function readTranscript(chatId) {134 try {135 return fs.readFileSync(transcriptPath(chatId), 'utf8')136 .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } })137 .filter(Boolean);138 } catch { return []; }139}140141// ---------- projets (12 apps Groupe KA) ----------142const SELF_NODE = 'M3U96a';143const PROJECTS = [144 { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' },145 { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' },146 { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' },147 { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },148 { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },149 { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },150 { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },151 { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },152 { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },153 { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },154 { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },155 { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },156 { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },157];158function listProjects() {159 return PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir }));160}161function findProject(id) { return PROJECTS.find(p => p.id === id) || null; }162163// ---------- exécution Claude Code ----------164const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];165// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions)166const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };167const DEFAULT_MODEL = 'fable';168const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass'];169const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task';170171const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`;172173const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;174175// chatId -> { proc, permToken, stderr, remote }176const running = new Map();177// requestId -> { chatId, resolve, timer, input, toolKey }178const pendingPerms = new Map();179180function broadcast(chatId, msg) {181 const data = JSON.stringify(msg);182 for (const ws of wss.clients) {183 if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data);184 }185}186function broadcastAll(msg) {187 const data = JSON.stringify(msg);188 for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data);189}190function broadcastEco(msg) {191 const data = JSON.stringify(msg);192 for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data);193}194function emitAndLog(chatId, msg) {195 appendTranscript(chatId, msg);196 broadcast(chatId, msg);197}198function setChatState(chat, state) {199 chat.state = state;200 chat.updated = Date.now();201 saveChats();202 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });203}204205function permKey(tool, input) {206 if (tool === 'Bash') {207 const first = String(input?.command || '').trim().split(/\s+/)[0] || '?';208 return 'Bash:' + first;209 }210 return tool;211}212213function startClaude(chat, prompt) {214 if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.');215 const permToken = crypto.randomBytes(16).toString('hex');216 const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL;217 const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';218 let proc;219220 const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',221 '--model', CLI_MODEL[model], '--append-system-prompt', SYSTEM_APPEND];222 if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);223224 if (!chat.node) {225 // exécution locale (app déployée sur M3U96a)226 const args = [...baseArgs];227 if (permMode === 'bypass') {228 args.push('--dangerously-skip-permissions');229 } else {230 args.push('--permission-mode', permMode);231 const mcpCfg = {232 mcpServers: {233 adminka: {234 command: NODE_BIN,235 args: [path.join(__dirname, 'perm-mcp.js')],236 env: {237 ADMIN_KA_PERM_URL: `http://127.0.0.1:${PORT}/internal/perm`,238 ADMIN_KA_PERM_TOKEN: permToken,239 },240 },241 },242 };243 const cfgPath = path.join(DATA_DIR, `mcp-${chat.id}.json`);244 fs.writeFileSync(cfgPath, JSON.stringify(mcpCfg));245 args.push('--mcp-config', cfgPath);246 args.push('--permission-prompt-tool', 'mcp__adminka__approve');247 args.push('--allowedTools', ALLOWED_READONLY);248 }249 proc = spawn(CLAUDE_BIN, args, {250 cwd: chat.dir,251 env: {252 ...process.env, ...CLAUDE_ENV,253 PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME,254 },255 stdio: ['pipe', 'pipe', 'pipe'],256 detached: false,257 });258 } else {259 // exécution distante : claude tourne SUR le nœud de déploiement de l'app,260 // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée).261 const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6'];262 let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' ');263 if (permMode === 'bypass') {264 cmd += ' --dangerously-skip-permissions';265 } else {266 const revPort = 21000 + Math.floor(Math.random() * 3000);267 sshArgs.push('-R', `127.0.0.1:${revPort}:127.0.0.1:${PORT}`);268 const mcpCfg = {269 mcpServers: {270 adminka: {271 command: '/opt/homebrew/bin/node',272 args: ['/Users/simon-pierreboucher/.adminka/perm-mcp.js'],273 env: {274 ADMIN_KA_PERM_URL: `http://127.0.0.1:${revPort}/internal/perm`,275 ADMIN_KA_PERM_TOKEN: permToken,276 },277 },278 },279 };280 const cfgFile = `/tmp/adminka-mcp-${permToken}.json`;281 cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`;282 }283 const remoteCmd = [284 `cd ${chat.dir}`,285 'export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH',286 'export $(cat ~/.claude/.env | xargs)',287 `echo $$ > /tmp/adminka-pid-${permToken}`,288 cmd,289 ].join(' && ');290 proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], {291 env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },292 stdio: ['pipe', 'pipe', 'pipe'],293 });294 }295 proc.stdin.write(prompt);296 proc.stdin.end();297298 const state = { proc, permToken, stderr: '', remote: chat.node || null };299 running.set(chat.id, state);300 audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });301 emitAndLog(chat.id, { type: 'status', state: 'running' });302 setChatState(chat, 'running');303304 let buf = '';305 proc.stdout.on('data', (d) => {306 buf += d.toString('utf8');307 let idx;308 while ((idx = buf.indexOf('\n')) >= 0) {309 const line = buf.slice(0, idx).trim();310 buf = buf.slice(idx + 1);311 if (!line) continue;312 let ev;313 try { ev = JSON.parse(line); } catch { continue; }314 handleClaudeEvent(chat, ev);315 }316 });317 proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); });318 proc.on('close', (code) => {319 running.delete(chat.id);320 for (const [rid, p] of pendingPerms) {321 if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); }322 }323 if (code !== 0 && code !== null) {324 emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` });325 }326 emitAndLog(chat.id, { type: 'status', state: 'idle' });327 setChatState(chat, 'idle');328 broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName });329 });330 proc.on('error', (err) => {331 running.delete(chat.id);332 emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message });333 emitAndLog(chat.id, { type: 'status', state: 'idle' });334 setChatState(chat, 'idle');335 });336}337338function handleClaudeEvent(chat, ev) {339 if (ev.type === 'system' && ev.subtype === 'init') {340 if (ev.session_id) chat.claudeSessionId = ev.session_id;341 if (ev.model) chat.lastModel = ev.model;342 saveChats();343 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });344 }345 if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {346 for (const block of ev.message.content) {347 if (block.type === 'tool_use') {348 audit({ kind: 'tool_use', chatId: chat.id, tool: block.name, input: JSON.stringify(block.input || {}).slice(0, 1000) });349 }350 }351 }352 if (ev.type === 'result') {353 chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0);354 chat.turns = (chat.turns || 0) + (ev.num_turns || 0);355 const u = ev.usage || {};356 chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);357 saveChats();358 audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns });359 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });360 }361 if (ev.type === 'stream_event') {362 broadcast(chat.id, { type: 'claude', event: ev });363 } else {364 emitAndLog(chat.id, { type: 'claude', event: ev });365 }366}367368function interrupt(chatId) {369 const st = running.get(chatId);370 if (!st) return;371 audit({ kind: 'interrupt', chatId });372 if (st.remote) {373 spawn('ssh', ['-o', 'BatchMode=yes', st.remote, `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], {374 env: { ...process.env, HOME }, stdio: 'ignore',375 });376 }377 st.proc.kill('SIGTERM');378 setTimeout(() => { try { st.proc.kill('SIGKILL'); } catch {} }, 5000);379}380381// exécuter un script sur un nœud (local ou ssh) — pour les actions d'admin382function runOnNode(node, script) {383 return new Promise((resolve) => {384 const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`;385 const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';386 const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full];387 execFile(bin, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME } },388 (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null }));389 });390}391392// ---------- HTTP ----------393const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.woff2': 'font/woff2' };394395function readBody(req, limit = 512 * 1024) {396 return new Promise((resolve, reject) => {397 let data = '';398 req.on('data', (c) => { data += c; if (data.length > limit) { reject(new Error('body trop gros')); req.destroy(); } });399 req.on('end', () => resolve(data));400 req.on('error', reject);401 });402}403function json(res, code, obj) {404 res.writeHead(code, { 'Content-Type': 'application/json' });405 res.end(JSON.stringify(obj));406}407408const server = http.createServer(async (req, res) => {409 const url = new URL(req.url, 'http://x');410 const p = url.pathname;411412 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }413414 // --- endpoint interne de permission (long-poll par perm-mcp.js) ---415 if (p === '/internal/perm' && req.method === 'POST') {416 let body;417 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }418 let chatId = null;419 for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid;420 if (!chatId) return json(res, 403, { error: 'bad token' });421 const chat = getChat(chatId);422 const toolKey = permKey(body.tool_name, body.input);423424 // « Tout autoriser » activé → toute la session est auto-approuvée : la tâche425 // va au bout sans jamais attendre l'utilisateur426 if (chat && chat.autoAllowAll) {427 audit({ kind: 'permission_auto_all', chatId, tool: body.tool_name });428 emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: 'tout autoriser', input: body.input });429 return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });430 }431432 // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation433 if (chat && (chat.alwaysAllow || []).includes(toolKey)) {434 audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey });435 emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input });436 return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });437 }438439 const requestId = crypto.randomBytes(8).toString('hex');440 const p2 = new Promise((resolve) => {441 const timer = setTimeout(() => {442 pendingPerms.delete(requestId);443 emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' });444 if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');445 resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" });446 }, 30 * 60 * 1000);447 pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey });448 });449 audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) });450 emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input });451 if (chat) setChatState(chat, 'waiting_perm');452 broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name });453 const decision = await p2;454 return json(res, 200, decision);455 }456457 // --- auth ---458 if (p === '/api/login' && req.method === 'POST') {459 const ip = clientIp(req);460 if (!loginAllowed(ip)) return json(res, 429, { error: 'Trop de tentatives — réessayer dans 15 minutes.' });461 let body;462 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }463 if (typeof body.password !== 'string' || !verifyPassword(body.password)) {464 loginFailed(ip);465 audit({ kind: 'login_fail', ip });466 return json(res, 401, { error: 'Mot de passe invalide.' });467 }468 const token = newSession();469 audit({ kind: 'login_ok', ip });470 res.writeHead(200, {471 'Content-Type': 'application/json',472 'Set-Cookie': `akid=${token}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax; Secure`,473 });474 return res.end(JSON.stringify({ ok: true }));475 }476 if (p === '/api/logout' && req.method === 'POST') {477 const t = getSession(req);478 if (t) { delete sessions[t]; saveSessions(); }479 res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'akid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax; Secure' });480 return res.end(JSON.stringify({ ok: true }));481 }482483 // --- API authentifiée ---484 if (p.startsWith('/api/')) {485 if (!getSession(req)) return json(res, 401, { error: 'auth' });486 if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' });487 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });488 if (p === '/api/chats' && req.method === 'GET') {489 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);490 return json(res, 200, { chats: list });491 }492 if (p.startsWith('/api/chats/') && req.method === 'DELETE') {493 const id = p.split('/')[3];494 const chat = getChat(id);495 if (chat && !running.has(id)) {496 chats = chats.filter(c => c.id !== id); saveChats();497 try { fs.unlinkSync(transcriptPath(id)); } catch {}498 return json(res, 200, { ok: true });499 }500 return json(res, 400, { error: 'introuvable ou en cours' });501 }502 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());503 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });504 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());505 if (p.startsWith('/api/eco/site/')) {506 const d = ecoSite(p.split('/')[4]);507 return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' });508 }509510 // --- actions d'administration par site (pm2 / logs / commits) ---511 if (p === '/api/admin/action' && req.method === 'POST') {512 let body;513 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }514 const site = SITES.find(s => s.app === body.app);515 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;516 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });517 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });518 const out = await runOnNode(site.node, `pm2 ${action} ${site.pm2} && pm2 jlist | python3 -c "import json,sys; l=json.load(sys.stdin); p=[x for x in l if x['name']=='${site.pm2}']; print(p[0]['pm2_env']['status'] if p else 'introuvable')"`);519 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });520 }521 if (p.startsWith('/api/admin/logs/')) {522 const site = SITES.find(s => s.app === p.split('/')[4]);523 if (!site) return json(res, 404, { error: 'site inconnu' });524 const out = await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`);525 return json(res, 200, { logs: out.stdout || out.error || '(vide)' });526 }527 if (p.startsWith('/api/admin/commits/')) {528 const site = SITES.find(s => s.app === p.split('/')[4]);529 if (!site) return json(res, 404, { error: 'site inconnu' });530 const out = await runOnNode(site.node, `cd ${site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`);531 return json(res, 200, { text: out.stdout || out.error || '(vide)' });532 }533 return json(res, 404, { error: 'not found' });534 }535536 // --- statique ---537 let file = p === '/' ? '/index.html' : p;538 file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');539 const full = path.join(PUBLIC_DIR, file);540 if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end(); }541 fs.readFile(full, (err, data) => {542 if (err) { res.writeHead(404); return res.end('not found'); }543 res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream', 'Cache-Control': full.includes('/vendor/') ? 'public, max-age=86400' : 'no-cache' });544 res.end(data);545 });546});547548// ---------- WebSocket ----------549const wss = new WebSocketServer({ noServer: true });550551server.on('upgrade', (req, socket, head) => {552 const url = new URL(req.url, 'http://x');553 if (url.pathname !== '/ws' || !getSession(req)) {554 socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');555 return socket.destroy();556 }557 wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));558});559560wss.on('connection', (ws) => {561 ws.chatId = null;562 ws.eco = false;563 ws.isAlive = true;564 ws.on('pong', () => { ws.isAlive = true; });565 ws.on('message', (raw) => {566 let msg;567 try { msg = JSON.parse(raw.toString()); } catch { return; }568 try { handleWsMessage(ws, msg); } catch (e) {569 ws.send(JSON.stringify({ type: 'error', message: e.message }));570 }571 });572 // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours.573});574575setInterval(() => {576 for (const ws of wss.clients) {577 if (!ws.isAlive) { ws.terminate(); continue; }578 ws.isAlive = false; ws.ping();579 }580}, 30000);581582function handleWsMessage(ws, msg) {583 if (msg.type === 'open') {584 const chat = getChat(msg.chatId);585 if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' }));586 ws.chatId = chat.id;587 // rejoue tout l'historique persisté puis raccroche le live588 ws.send(JSON.stringify({589 type: 'history',590 chat: publicChat(chat),591 events: readTranscript(chat.id),592 running: running.has(chat.id),593 }));594 return;595 }596 if (msg.type === 'start') {597 const prompt = String(msg.prompt || '').trim();598 if (!prompt) return;599 let chat = msg.chatId ? getChat(msg.chatId) : null;600 if (!chat) {601 const proj = findProject(msg.project) || PROJECTS[0];602 chat = {603 id: crypto.randomBytes(8).toString('hex'),604 title: prompt.slice(0, 60),605 projectId: proj.id, projectName: proj.name,606 node: proj.node, dir: proj.dir,607 model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL,608 permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default',609 claudeSessionId: null, state: 'idle',610 totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [],611 created: Date.now(), updated: Date.now(),612 };613 chats.push(chat); saveChats();614 ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) }));615 } else {616 if (MODELS.includes(msg.model)) chat.model = msg.model;617 if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;618 saveChats();619 }620 ws.chatId = chat.id;621 emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() });622 startClaude(chat, prompt);623 return;624 }625 if (msg.type === 'set_opts') {626 const chat = getChat(msg.chatId);627 if (!chat) return;628 if (MODELS.includes(msg.model)) chat.model = msg.model;629 if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;630 if (typeof msg.autoAllowAll === 'boolean') chat.autoAllowAll = msg.autoAllowAll;631 saveChats();632 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });633 return;634 }635 if (msg.type === 'clear') {636 const chat = getChat(msg.chatId);637 if (!chat || running.has(chat.id)) return;638 chat.claudeSessionId = null;639 chat.contextTokens = 0;640 saveChats();641 emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' });642 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });643 return;644 }645 if (msg.type === 'perm') {646 const pending = pendingPerms.get(msg.requestId);647 if (!pending) return;648 clearTimeout(pending.timer);649 pendingPerms.delete(msg.requestId);650 const chat = getChat(pending.chatId);651 if (msg.allow && msg.allowAll && chat) {652 // « Tout autoriser » : plus AUCUNE permission ne sera demandée pour cette653 // conversation — le serveur approuve tout jusqu'à la fin de la tâche654 chat.autoAllowAll = true;655 saveChats();656 emitAndLog(chat.id, { type: 'notice', text: '🔓 Tout autoriser activé — la tâche ira au bout sans autre demande de permission.' });657 broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });658 }659 if (msg.allow && msg.always && chat) {660 if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey);661 saveChats();662 }663 audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });664 emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });665 if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');666 pending.resolve(msg.allow667 ? { behavior: 'allow', updatedInput: pending.input || {} }668 : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" });669 return;670 }671 if (msg.type === 'interrupt') {672 if (msg.chatId) interrupt(msg.chatId);673 else if (ws.chatId) interrupt(ws.chatId);674 return;675 }676 if (msg.type === 'eco_sub') { ws.eco = true; return; }677 if (msg.type === 'eco_unsub') { ws.eco = false; return; }678}679680// ---------- monitoring Écosystème ----------681startMonitor({682 dataDir: DATA_DIR,683 onEvent: (ev) => {684 if (ev.type === 'eco_alert') broadcastAll(ev); // alertes → tout le monde685 else broadcastEco(ev); // ticks → abonnés à l'onglet686 },687});688689// au démarrage, les chats marqués "running" par un ancien processus sont orphelins690for (const c of chats) if (c.state !== 'idle') c.state = 'idle';691saveChats();692693server.listen(PORT, '::', () => {694 console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`);695});696