/ src / daemon.ts
daemon.ts
  1  /**
  2   * opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
  3   *
  4   * Architecture:
  5   *   CLI → HTTP POST /command → daemon → WebSocket → Extension
  6   *   Extension → WebSocket result → daemon → HTTP response → CLI
  7   *
  8   * Security (defense-in-depth against browser-based CSRF):
  9   *   1. Origin check — reject HTTP/WS from non chrome-extension:// origins
 10   *   2. Custom header — require X-OpenCLI header (browsers can't send it
 11   *      without CORS preflight, which we deny)
 12   *   3. No CORS headers on command endpoints — only /ping is readable from the
 13   *      Browser Bridge extension origin so the extension can probe daemon reachability
 14   *   4. Body size limit — 1 MB max to prevent OOM
 15   *   5. WebSocket verifyClient — reject upgrade before connection is established
 16   *
 17   * Lifecycle:
 18   *   - Auto-spawned by opencli on first browser command
 19   *   - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall
 20   *   - Listens on localhost:19825
 21   */
 22  
 23  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
 24  import { WebSocketServer, WebSocket, type RawData } from 'ws';
 25  import { DEFAULT_DAEMON_PORT } from './constants.js';
 26  import { EXIT_CODES } from './errors.js';
 27  import { log } from './logger.js';
 28  import { PKG_VERSION } from './version.js';
 29  
 30  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
 31  
 32  // ─── State ───────────────────────────────────────────────────────────
 33  
 34  let extensionWs: WebSocket | null = null;
 35  let extensionVersion: string | null = null;
 36  let extensionCompatRange: string | null = null;
 37  const pending = new Map<string, {
 38    resolve: (data: unknown) => void;
 39    reject: (error: Error) => void;
 40    timer: ReturnType<typeof setTimeout>;
 41  }>();
 42  // Extension log ring buffer
 43  interface LogEntry { level: string; msg: string; ts: number; }
 44  const LOG_BUFFER_SIZE = 200;
 45  const logBuffer: LogEntry[] = [];
 46  
 47  function pushLog(entry: LogEntry): void {
 48    logBuffer.push(entry);
 49    if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
 50  }
 51  
 52  // ─── HTTP Server ─────────────────────────────────────────────────────
 53  
 54  const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
 55  
 56  function readBody(req: IncomingMessage): Promise<string> {
 57    return new Promise((resolve, reject) => {
 58      const chunks: Buffer[] = [];
 59      let size = 0;
 60      let aborted = false;
 61      req.on('data', (c: Buffer) => {
 62        size += c.length;
 63        if (size > MAX_BODY) { aborted = true; req.destroy(); reject(new Error('Body too large')); return; }
 64        chunks.push(c);
 65      });
 66      req.on('end', () => { if (!aborted) resolve(Buffer.concat(chunks).toString('utf-8')); });
 67      req.on('error', (err) => { if (!aborted) reject(err); });
 68    });
 69  }
 70  
 71  function jsonResponse(
 72    res: ServerResponse,
 73    status: number,
 74    data: unknown,
 75    extraHeaders?: Record<string, string>,
 76  ): void {
 77    res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
 78    res.end(JSON.stringify(data));
 79  }
 80  
 81  export function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined {
 82    if (pathname !== '/ping') return undefined;
 83    if (!origin || !origin.startsWith('chrome-extension://')) return undefined;
 84    return {
 85      'Access-Control-Allow-Origin': origin,
 86      Vary: 'Origin',
 87    };
 88  }
 89  
 90  async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
 91    // ─── Security: Origin & custom-header check ──────────────────────
 92    // Block browser-based CSRF: browsers always send an Origin header on
 93    // cross-origin requests.  Node.js CLI fetch does NOT send Origin, so
 94    // legitimate CLI requests pass through.  Chrome Extension connects via
 95    // WebSocket (which bypasses this HTTP handler entirely).
 96    const origin = req.headers['origin'] as string | undefined;
 97    if (origin && !origin.startsWith('chrome-extension://')) {
 98      jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' });
 99      return;
100    }
101  
102    // CORS: do NOT send Access-Control-Allow-Origin for normal requests.
103    // Only handle preflight so browsers get a definitive "no" answer.
104    if (req.method === 'OPTIONS') {
105      // No ACAO header → browser will block the actual request.
106      res.writeHead(204);
107      res.end();
108      return;
109    }
110  
111    const url = req.url ?? '/';
112    const pathname = url.split('?')[0];
113  
114    // Health-check endpoint — no X-OpenCLI header required.
115    // Used by the extension to silently probe daemon reachability before
116    // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
117    // Security note: this endpoint is reachable by any client that passes the
118    // origin check above (chrome-extension:// or no Origin header, e.g. curl).
119    // Timing side-channels can reveal daemon presence to local processes, which
120    // is an accepted risk given the daemon is loopback-only and short-lived.
121    if (req.method === 'GET' && pathname === '/ping') {
122      jsonResponse(res, 200, { ok: true }, getResponseCorsHeaders(pathname, origin));
123      return;
124    }
125  
126    // Require custom header on all other HTTP requests.  Browsers cannot attach
127    // custom headers in "simple" requests, and our preflight returns no
128    // Access-Control-Allow-Headers, so scripted fetch() from web pages is
129    // blocked even if Origin check is somehow bypassed.
130    if (!req.headers['x-opencli']) {
131      jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
132      return;
133    }
134  
135    if (req.method === 'GET' && pathname === '/status') {
136      const uptime = process.uptime();
137      const mem = process.memoryUsage();
138      jsonResponse(res, 200, {
139        ok: true,
140        pid: process.pid,
141        uptime,
142        daemonVersion: PKG_VERSION,
143        extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
144        extensionVersion,
145        extensionCompatRange,
146        pending: pending.size,
147        memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
148        port: PORT,
149      });
150      return;
151    }
152  
153    if (req.method === 'GET' && pathname === '/logs') {
154      const params = new URL(url, `http://localhost:${PORT}`).searchParams;
155      const level = params.get('level');
156      const filtered = level
157        ? logBuffer.filter(e => e.level === level)
158        : logBuffer;
159      jsonResponse(res, 200, { ok: true, logs: filtered });
160      return;
161    }
162  
163    if (req.method === 'DELETE' && pathname === '/logs') {
164      logBuffer.length = 0;
165      jsonResponse(res, 200, { ok: true });
166      return;
167    }
168  
169    if (req.method === 'POST' && pathname === '/shutdown') {
170      jsonResponse(res, 200, { ok: true, message: 'Shutting down' });
171      setTimeout(() => shutdown(), 100);
172      return;
173    }
174  
175    if (req.method === 'POST' && url === '/command') {
176      try {
177        const body = JSON.parse(await readBody(req));
178        if (!body.id) {
179          jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
180          return;
181        }
182  
183        if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
184          jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
185          return;
186        }
187  
188        const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
189          ? body.timeout * 1000
190          : 120000;
191        if (pending.has(body.id)) {
192          jsonResponse(res, 409, {
193            id: body.id,
194            ok: false,
195            error: 'Duplicate command id already pending; retry',
196          });
197          return;
198        }
199        const result = await new Promise<unknown>((resolve, reject) => {
200          const timer = setTimeout(() => {
201            pending.delete(body.id);
202            reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
203          }, timeoutMs);
204          pending.set(body.id, { resolve, reject, timer });
205          extensionWs!.send(JSON.stringify(body));
206        });
207  
208        jsonResponse(res, 200, result);
209      } catch (err) {
210        jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
211          ok: false,
212          error: err instanceof Error ? err.message : 'Invalid request',
213        });
214      }
215      return;
216    }
217  
218    jsonResponse(res, 404, { error: 'Not found' });
219  }
220  
221  // ─── WebSocket for Extension ─────────────────────────────────────────
222  
223  const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
224  const wss = new WebSocketServer({
225    server: httpServer,
226    path: '/ext',
227    verifyClient: ({ req }: { req: IncomingMessage }) => {
228      // Block browser-originated WebSocket connections.  Browsers don't
229      // enforce CORS on WebSocket, so a malicious webpage could connect to
230      // ws://localhost:19825/ext and impersonate the Extension.  Real Chrome
231      // Extensions send origin chrome-extension://<id>.
232      const origin = req.headers['origin'] as string | undefined;
233      return !origin || origin.startsWith('chrome-extension://');
234    },
235  });
236  
237  wss.on('connection', (ws: WebSocket) => {
238    log.info('[daemon] Extension connected');
239    extensionWs = ws;
240    extensionVersion = null; // cleared until hello message arrives
241    extensionCompatRange = null;
242  
243    // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
244    let missedPongs = 0;
245    const heartbeatInterval = setInterval(() => {
246      if (ws.readyState !== WebSocket.OPEN) {
247        clearInterval(heartbeatInterval);
248        return;
249      }
250      if (missedPongs >= 2) {
251        log.warn('[daemon] Extension heartbeat lost, closing connection');
252        clearInterval(heartbeatInterval);
253        ws.terminate();
254        return;
255      }
256      missedPongs++;
257      ws.ping();
258    }, 15000);
259  
260    ws.on('pong', () => {
261      missedPongs = 0;
262    });
263  
264    ws.on('message', (data: RawData) => {
265      try {
266        const msg = JSON.parse(data.toString());
267  
268        // Handle hello message from extension (version handshake)
269        if (msg.type === 'hello') {
270          extensionVersion = typeof msg.version === 'string' ? msg.version : null;
271          extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
272          return;
273        }
274  
275        // Handle log messages from extension
276        if (msg.type === 'log') {
277          if (msg.level === 'error') log.error(`[ext] ${msg.msg}`);
278          else if (msg.level === 'warn') log.warn(`[ext] ${msg.msg}`);
279          else log.info(`[ext] ${msg.msg}`);
280          pushLog({ level: msg.level, msg: msg.msg, ts: msg.ts ?? Date.now() });
281          return;
282        }
283  
284        // Handle command results
285        const p = pending.get(msg.id);
286        if (p) {
287          clearTimeout(p.timer);
288          pending.delete(msg.id);
289          p.resolve(msg);
290        }
291      } catch {
292        // Ignore malformed messages
293      }
294    });
295  
296    ws.on('close', () => {
297      log.info('[daemon] Extension disconnected');
298      clearInterval(heartbeatInterval);
299      if (extensionWs === ws) {
300        extensionWs = null;
301        extensionVersion = null;
302        extensionCompatRange = null;
303        // Reject all pending requests since the extension is gone
304        for (const [id, p] of pending) {
305          clearTimeout(p.timer);
306          p.reject(new Error('Extension disconnected'));
307        }
308        pending.clear();
309      }
310    });
311  
312    ws.on('error', () => {
313      clearInterval(heartbeatInterval);
314      if (extensionWs === ws) {
315        extensionWs = null;
316        extensionVersion = null;
317        extensionCompatRange = null;
318        // Reject pending requests in case 'close' does not follow this 'error'
319        for (const [, p] of pending) {
320          clearTimeout(p.timer);
321          p.reject(new Error('Extension disconnected'));
322        }
323        pending.clear();
324      }
325    });
326  });
327  
328  // ─── Start ───────────────────────────────────────────────────────────
329  
330  httpServer.listen(PORT, '127.0.0.1', () => {
331    log.info(`[daemon] Listening on http://127.0.0.1:${PORT}`);
332  });
333  
334  httpServer.on('error', (err: NodeJS.ErrnoException) => {
335    if (err.code === 'EADDRINUSE') {
336      log.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
337      process.exit(EXIT_CODES.SERVICE_UNAVAIL);
338    }
339    log.error(`[daemon] Server error: ${err.message}`);
340    process.exit(EXIT_CODES.GENERIC_ERROR);
341  });
342  
343  // Graceful shutdown
344  function shutdown(): void {
345    // Reject all pending requests so CLI doesn't hang
346    for (const [, p] of pending) {
347      clearTimeout(p.timer);
348      p.reject(new Error('Daemon shutting down'));
349    }
350    pending.clear();
351    if (extensionWs) extensionWs.close();
352    httpServer.close();
353    process.exit(EXIT_CODES.SUCCESS);
354  }
355  
356  process.on('SIGTERM', shutdown);
357  process.on('SIGINT', shutdown);