← back to Answer Cockpit

server.js

245 lines

#!/usr/bin/env node
'use strict';
/**
 * Answer Cockpit — one needs-Steve item at a time, one button to continue.
 * Zero-dep Node. Binds 127.0.0.1:9805 ONLY (hard-coded; no HOST env).
 * Basic auth admin / DW2024! (constant-time), realm "Answer Cockpit".
 * Every mutation is a POST; JSON body ≤ 16KB.
 *
 *   GET  /                      public/index.html
 *   GET  /api/queue?orphans=1   scan (4s cache) → needs-Steve items, detail pre-resolved
 *   GET  /api/item/:tty         one item, FRESH scan (post-answer confirm)
 *   POST /api/answer            {tty,text,expectKey,force?,repaint?,dry?}
 *   POST /api/continue          {tty,expectKey,force?,dry?}   → types "continue"
 *   POST /api/focus             {tty}
 *   POST /api/memo/decide       {file,decision,note,tty?,expectKey?,dry?}
 *   POST /api/memo/undo         {file,decision}
 *   GET  /api/health            heartbeat + last 20 audit lines
 *
 * Testability seam (TK-11431 #3): `node server.js --test` honours COCKPIT_ITERM_APP
 * (bogus app name → osascript fails → 502 + heartbeat FAIL) and COCKPIT_TEST_PORT.
 * The plist must NEVER pass --test.
 */
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const queue = require('./lib/queue');
const memo = require('./lib/memo');
const writeback = require('./lib/writeback');
const audit = require('./lib/audit');
const transcript = require('./lib/transcript');
const menu = require('./lib/menu');

const HOST = '127.0.0.1'; // hard-coded on purpose — never expose (it types into live agents)
const TEST = process.argv.includes('--test');
const PORT = TEST && process.env.COCKPIT_TEST_PORT ? parseInt(process.env.COCKPIT_TEST_PORT, 10) : 9805;
const USER = 'admin', PASS = 'DW2024!';
const BODY_LIMIT = 16 * 1024;
const PUBLIC = path.join(__dirname, 'public');

// ---- constant-time credential check (copied from ~/.claude/skills/viewer/server.js:29-48) ----
function safeEqual(a, b) {
  const ab = Buffer.from(String(a));
  const bb = Buffer.from(String(b));
  if (ab.length !== bb.length) { crypto.timingSafeEqual(ab, ab); return false; }
  return crypto.timingSafeEqual(ab, bb);
}
function authed(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  let decoded = '';
  try { decoded = Buffer.from(h.slice(6), 'base64').toString('utf8'); } catch { return false; }
  const i = decoded.indexOf(':');
  if (i < 0) return false;
  return safeEqual(decoded.slice(0, i), USER) && safeEqual(decoded.slice(i + 1), PASS);
}

const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)); }

function readBody(req) {
  return new Promise((resolve, reject) => {
    let size = 0; const chunks = [];
    req.on('data', (c) => { size += c.length; if (size > BODY_LIMIT) { reject(new Error('body > 16KB')); req.destroy(); return; } chunks.push(c); });
    req.on('end', () => {
      const raw = Buffer.concat(chunks).toString('utf8');
      if (!raw.trim()) return resolve({});
      try { const o = JSON.parse(raw); resolve(o && typeof o === 'object' ? o : {}); } catch { reject(new Error('bad json')); }
    });
    req.on('error', reject);
  });
}

/**
 * guardTarget — the shared POST rail for anything that types into a pane.
 * Returns {code, body} on refusal, or {row} when clear.
 */
async function guardTarget(body, { needsSteveOnly = true, requireKey = true } = {}) {
  const tty = String(body.tty || '');
  if (!queue.TTY_RE.test(tty)) return { code: 400, body: { error: 'bad tty (expected ttysNNN)' } };
  if (writeback.SELF_TTY && tty === writeback.SELF_TTY) return { code: 403, body: { error: 'refusing the cockpit\'s own tty', selfTty: writeback.SELF_TTY } };
  const { scan, live } = await queue.freshLive();
  if (scan.error && !scan.rows.length) return { code: 503, body: { error: 'scanner unavailable', detail: scan.error, stale: true } };
  if (scan.terminalApi === 'unavailable') return { code: 503, body: { error: 'terminal_api unavailable — unmeasured, refusing to type', stale: true } };
  const row = live.get(tty);
  if (!row) {
    // known a moment ago (client has a key for it) but gone now → 409 gone; never known → 400
    if (body.expectKey && String(body.expectKey).startsWith(tty + '|')) return { code: 409, body: { error: 'tty vanished', gone: true } };
    return { code: 400, body: { error: 'tty not in live scan', tty } };
  }
  // Resolve detail ONCE from the fresh row: the content digest for the key, and whether a real
  // menu is live on screen (a pane-detected question is answerable even if the dot is none/green).
  // GENUINELY fresh: drop the 4s pane batch + this pid's 30s resolve cache before resolving, so a
  // menu that closed/advanced since the last poll cannot digest-match a stale card (Cody #3).
  transcript.refreshPanesSync(); transcript._cache.delete(String(row.pid)); // one blocking fresh read — only here
  const sess = transcript.resolve(row);
  const liveMenu = !!(sess.detail && sess.detail.question && !sess.detail.queued);
  if (requireKey) {
    const key = queue.keyOf(row, sess.detail); // includes the content digest — a changed question 409s
    const keyAsYellow = queue.keyOf({ ...row, color: 'yellow' }, sess.detail); // pane-detected items carry color yellow
    const given = String(body.expectKey || '');
    if (!given || (given !== key && !(liveMenu && given === keyAsYellow))) return { code: 409, body: { error: 'stale card (expectKey mismatch)', expectKey: body.expectKey || null, currentKey: key, liveMenu, resolvedVia: sess.confidence, how: sess.how || null } };
  }
  const force = body.force === true;
  if (needsSteveOnly && !queue.NEEDS_STEVE.has(row.color) && !liveMenu && !force) return { code: 403, body: { error: `tty is ${row.color} — not waiting on you (pass force:true to override)`, color: row.color } };
  if (row.runtime === 'codex' && !force) return { code: 403, body: { error: 'tty hosts a codex REPL — answer disabled unless force', runtime: 'codex' } };
  return { row, sess, liveMenu };
}

async function doType(row, text, body, action, extra = {}) {
  const dry = body.dry === true;
  const r = await writeback.typeText(row.tty, text, { dry });
  const line = audit.audit({ action, tty: row.tty, ticket: row.ticket || null, color: row.color, text, ok: r.typed || (dry && !r.err), err: r.err, dry, refused: !!r.refused, osascriptAttempted: !!r.attempted, ...extra });
  if (r.refused) return { code: 400, body: { ok: false, typed: false, error: r.err } };
  if (dry) return { code: 200, body: { ok: true, typed: false, dry: true, script: r.script, audit: line } };
  if (!r.attempted || (r.err && !r.typed && /not running|can't get|Can’t get|-600|-1728|-2700|-1743|not allowed|timed out|ETIMEDOUT|SIGTERM/i.test(r.err || ''))) {
    audit.heartbeat({ rows: [row], stale: false, terminalApi: 'available', source: 'post-writeback' }); // refresh heartbeat → FAIL
    return { code: 502, body: { ok: false, typed: false, error: 'iTerm unreachable: ' + (r.err || 'unknown') } };
  }
  if (!r.typed) return { code: 409, body: { ok: false, typed: false, error: r.err || 'no matching session', gone: true } };
  let repaint = null;
  if (body.repaint === true) {
    const label = `${row.ticket || 'TK'} · answered via cockpit`;
    repaint = await writeback.repaint(row.tty, label);
  }
  return { code: 200, body: { ok: true, typed: true, matched: r.matched, repaint, audit: line } };
}

const server = http.createServer(async (req, res) => {
  if (!authed(req)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Answer Cockpit", charset="UTF-8"', 'Content-Type': 'text/plain' });
    return res.end('Authentication required');
  }
  let u; try { u = new URL(req.url || '/', 'http://x'); } catch { return json(res, 400, { error: 'bad url' }); }
  const p = u.pathname;
  try {
    // ---- GET ----
    if (req.method === 'GET') {
      if (p === '/api/queue') { transcript.noteViewer(); return json(res, 200, await queue.build({ orphans: u.searchParams.get('orphans') === '1' })); }
      if (p.startsWith('/api/item/')) {
        const tty = p.slice('/api/item/'.length);
        if (!queue.TTY_RE.test(tty)) return json(res, 400, { error: 'bad tty' });
        return json(res, 200, await queue.item(tty));
      }
      if (p === '/api/health') {
        const probe = await writeback.probe();
        const latest = audit.latest();
        return json(res, 200, { ok: true, heartbeat: latest, iterm: probe.ok ? { reachable: true, windows: probe.stdout } : { reachable: false, err: probe.err }, selfTty: writeback.SELF_TTY || null, itermApp: writeback.ITERM_APP, test: TEST, audit: audit.tailAudit(20), cost: '$0 (local)' });
      }
      if (p === '/api/memos') return json(res, 200, { memos: memo.list().map((m) => ({ ...m, excerpt: m.excerpt.slice(0, 600) })) });
      // static: / → public/index.html, /x.js → public/x.js (traversal-guarded)
      let rel = p === '/' ? '/index.html' : p;
      let dec; try { dec = decodeURIComponent(rel); } catch { return json(res, 400, { error: 'bad path' }); }
      const abs = path.resolve(PUBLIC, '.' + dec);
      if (!abs.startsWith(PUBLIC + path.sep)) { res.writeHead(403); return res.end('forbidden'); }
      let st; try { st = fs.statSync(abs); } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
      if (!st.isFile()) { res.writeHead(404); return res.end('not found'); }
      res.writeHead(200, { 'Content-Type': TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' });
      return fs.createReadStream(abs).pipe(res);
    }
    // ---- POST ----
    if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
    let body; try { body = await readBody(req); } catch (e) { return json(res, 400, { error: e.message }); }

    if (p === '/api/answer') {
      const text = typeof body.text === 'string' ? body.text : '';
      const bad = writeback.validateText(text);
      if (bad) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: bad, refused: true }); return json(res, 400, { error: bad }); }
      const g = await guardTarget(body);
      if (g.code) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
      // Server-side menu resolution: translate a label to its displayed number, refuse free text
      // against a live menu (a stale client or a curl script must not be able to mis-answer).
      let typed = text, optionLabel = typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null, translated = false;
      if (g.liveMenu && !(body.force === true && body.rawText === true)) {
        const m = menu.resolveMenuAnswer(text, g.sess.detail.question);
        if (m.error) { audit.audit({ action: 'answer', tty: g.row.tty, ticket: g.row.ticket, color: g.row.color, text, ok: false, err: m.error, refused: true }); return json(res, 400, { error: m.error, liveMenu: true }); }
        typed = m.text; translated = m.translated; if (m.optionLabel) optionLabel = m.optionLabel;
      }
      const r = await doType(g.row, typed, body, 'answer', { optionLabel, translated: translated || undefined });
      if (r.body && translated) r.body.translated = { from: text, to: typed, label: optionLabel };
      return json(res, r.code, r.body);
    }
    if (p === '/api/continue') {
      const g = await guardTarget(body);
      if (g.code) { audit.audit({ action: 'continue', tty: body.tty, text: 'continue', ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
      const r = await doType(g.row, 'continue', body, 'continue');
      return json(res, r.code, r.body);
    }
    if (p === '/api/focus') {
      const g = await guardTarget(body, { needsSteveOnly: false, requireKey: false });
      if (g.code) { audit.audit({ action: 'focus', tty: body.tty, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
      const r = await writeback.focus(g.row.tty, { dry: body.dry === true });
      audit.audit({ action: 'focus', tty: g.row.tty, ticket: g.row.ticket, color: g.row.color, ok: !!r.ok || !!r.dry, err: r.err, dry: !!r.dry, osascriptAttempted: !!r.attempted });
      if (r.dry) return json(res, 200, { ok: true, dry: true, script: r.script });
      if (!r.attempted || (r.err && /not running|-600|-1728|-2700|-1743|timed out/i.test(r.err))) return json(res, 502, { ok: false, error: 'iTerm unreachable: ' + r.err });
      return json(res, r.ok ? 200 : 409, { ok: !!r.ok, out: r.out || null, error: r.err || null });
    }
    if (p === '/api/memo/decide') {
      const { file, decision, note } = body;
      if (!memo.safeFile(file)) return json(res, 400, { error: 'bad file' });
      const dec = decision === 'reject' ? 'block' : decision;
      if (!['approve', 'block', 'revise'].includes(dec)) return json(res, 400, { error: 'bad decision' });
      // If a tty is linked, validate it BEFORE moving the memo so a stale card can't half-apply.
      let target = null;
      if (body.tty) {
        const g = await guardTarget(body, { requireKey: !!body.expectKey });
        if (g.code) { audit.audit({ action: 'memo.decide', tty: body.tty, memoFile: file, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
        target = g.row;
      }
      let out;
      if (body.dry === true) out = { ok: true, dry: true, decision: dec, wouldMove: dec !== 'revise' };
      else { try { out = memo.decide({ file, decision: dec, note }); } catch (e) { audit.audit({ action: 'memo.decide', memoFile: file, ok: false, err: e.message }); return json(res, e.message === 'gone' ? 409 : 400, { error: e.message }); } }
      audit.audit({ action: 'memo.decide', tty: target ? target.tty : null, ticket: out.ticket || null, memoFile: file, text: dec + (note ? ': ' + String(note).slice(0, 200) : ''), ok: true, dry: body.dry === true });
      let typed = null;
      if (target) {
        const word = dec === 'approve' ? 'APPROVED' : dec === 'block' ? 'BLOCKED' : 'REVISE';
        const msg = dec === 'approve' ? `APPROVED: ${file} — proceed under the memo's rails${note ? '. Note: ' + note : ''}`
          : dec === 'block' ? `BLOCKED: ${file} — do not execute${note ? '. Reason: ' + note : ''}`
            : `REVISE: ${file}${note ? ' — ' + note : ' — see the cockpit note'}`;
        const r = await doType(target, msg, body, 'memo.' + word.toLowerCase());
        typed = r.body;
      }
      return json(res, 200, { ...out, typed });
    }
    if (p === '/api/memo/undo') {
      const { file, decision } = body;
      if (!memo.safeFile(file)) return json(res, 400, { error: 'bad file' });
      try { const out = memo.undo({ file, decision }); audit.audit({ action: 'memo.undo', memoFile: file, text: String(decision), ok: true }); return json(res, 200, out); }
      catch (e) { audit.audit({ action: 'memo.undo', memoFile: file, ok: false, err: e.message }); return json(res, 400, { error: e.message }); }
    }
    return json(res, 404, { error: 'not found' });
  } catch (e) {
    return json(res, 500, { error: String(e && e.message || e) });
  }
});

server.listen(PORT, HOST, () => {
  console.log(`COCKPIT_LISTENING port=${PORT} host=${HOST}${TEST ? ' test=1 itermApp=' + writeback.ITERM_APP : ''}`);
  console.log(`Answer Cockpit up: http://${HOST}:${PORT}  (admin / ${PASS})  selfTty=${writeback.SELF_TTY || '-'}  cost=$0 (local)`);
  // first heartbeat so latest.json exists before the first request
  queue.scan().catch(() => {});
  // background pane watcher: request paths read a cache, never block on osascript
  transcript.startPaneWatcher();
});