bloby-bot 0.53.10 → 0.54.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import { log } from '../shared/logger.js';
11
11
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
- import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv } from './backend.js';
14
+ import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv, setBackendGiveUpHandler } from './backend.js';
15
15
  import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
16
16
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
17
17
  import {
@@ -662,6 +662,32 @@ export async function startSupervisor() {
662
662
  res.setHeader('Content-Type', 'application/json');
663
663
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
664
664
 
665
+ // ── Loopback-only guard for channel MUTATION endpoints ──
666
+ // These are only ever called by the local agent over loopback (curl localhost:7400),
667
+ // never legitimately from the public Cloudflare tunnel. Without this, an unauthenticated
668
+ // remote request could set mode/admins or flip allowOthersToTrigger and seize the agent
669
+ // (it can run Bash/edit files). Same guard the Agent API uses: cloudflared forwards over
670
+ // loopback so the IP check alone is a no-op behind the relay — we also reject any request
671
+ // carrying cloudflared's cf-connecting-ip/cf-ray (tunnel-origin) headers. Reads (status,
672
+ // qr, qr-page) and alexa/handle (relay-origin, secret-gated) deliberately stay public.
673
+ const WA_MUTATION_ROUTES = new Set([
674
+ 'POST /api/channels/whatsapp/configure',
675
+ 'POST /api/channels/whatsapp/connect',
676
+ 'POST /api/channels/whatsapp/disconnect',
677
+ 'POST /api/channels/whatsapp/logout',
678
+ 'POST /api/channels/whatsapp/pairing-code',
679
+ 'POST /api/channels/send',
680
+ ]);
681
+ if (WA_MUTATION_ROUTES.has(`${req.method} ${channelPath}`)) {
682
+ const remoteIp = req.socket.remoteAddress || '';
683
+ const isLoopback = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
684
+ if (!isLoopback || req.headers['cf-connecting-ip'] || req.headers['cf-ray']) {
685
+ res.writeHead(403);
686
+ res.end(JSON.stringify({ ok: false, error: 'This channel endpoint is localhost-only.' }));
687
+ return;
688
+ }
689
+ }
690
+
665
691
  // GET /api/channels/status — all channel statuses
666
692
  if (req.method === 'GET' && channelPath === '/api/channels/status') {
667
693
  res.writeHead(200);
@@ -952,6 +978,7 @@ ${!connected ? `<script>
952
978
  if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
953
979
  if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
954
980
  if (data.allowGroups !== undefined) cfg.channels.whatsapp.allowGroups = !!data.allowGroups;
981
+ if (data.allowOthersToTrigger !== undefined) cfg.channels.whatsapp.allowOthersToTrigger = !!data.allowOthersToTrigger;
955
982
  saveConfig(cfg);
956
983
  res.writeHead(200);
957
984
  res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
@@ -1327,11 +1354,31 @@ mint();
1327
1354
  return;
1328
1355
  }
1329
1356
 
1330
- // POST /api/env — write env vars to workspace .env (used by chat EnvForm)
1357
+ // POST /api/env — write env vars to workspace .env (used by chat EnvForm).
1331
1358
  if (req.method === 'POST' && req.url === '/api/env') {
1359
+ // This route is intercepted before the /api worker gate, so gate it here: writing the
1360
+ // workspace .env (which the backend loads) is a privileged action — require the portal token
1361
+ // when a password is set. Internal supervisor calls (x-internal) bypass. The chat EnvForm
1362
+ // sends the token via authFetch.
1363
+ if (req.headers['x-internal'] !== internalSecret && await isAuthRequired()) {
1364
+ const authHeader = req.headers['authorization'];
1365
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
1366
+ if (!token || !(await validateToken(token))) {
1367
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1368
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1369
+ return;
1370
+ }
1371
+ }
1332
1372
  let body = '';
1333
- req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1373
+ let bodyBytes = 0;
1374
+ let tooLarge = false;
1375
+ req.on('data', (chunk: Buffer) => {
1376
+ bodyBytes += chunk.length;
1377
+ if (bodyBytes > 1_000_000) { tooLarge = true; req.destroy(); return; } // .env is tiny; 1MB is generous
1378
+ body += chunk.toString();
1379
+ });
1334
1380
  req.on('end', () => {
1381
+ if (tooLarge) return;
1335
1382
  try {
1336
1383
  const { vars } = JSON.parse(body) as { vars: Record<string, string> };
1337
1384
  if (!vars || typeof vars !== 'object') {
@@ -1348,7 +1395,20 @@ mint();
1348
1395
 
1349
1396
  for (const [rawKey, rawValue] of Object.entries(vars)) {
1350
1397
  const key = rawKey.trim();
1398
+ // Validate the key as a real env var name; reject anything that could inject extra
1399
+ // lines or break .env parsing.
1400
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1401
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1402
+ res.end(JSON.stringify({ error: `Invalid env var name: ${rawKey}` }));
1403
+ return;
1404
+ }
1351
1405
  const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
1406
+ // Reject embedded newlines/CR in the value — they'd inject arbitrary extra .env lines.
1407
+ if (typeof value === 'string' && /[\r\n]/.test(value)) {
1408
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1409
+ res.end(JSON.stringify({ error: `Invalid value for ${key}: must not contain newlines` }));
1410
+ return;
1411
+ }
1352
1412
  // Find existing line for this key (supports KEY=val, KEY="val", KEY='val')
1353
1413
  const idx = lines.findIndex((l) => {
1354
1414
  const trimmed = l.trim();
@@ -1380,20 +1440,29 @@ mint();
1380
1440
  if (req.url?.startsWith('/api/agent/')) {
1381
1441
  const agentPath = req.url.split('?')[0];
1382
1442
 
1383
- // Localhost-only guard
1443
+ // Localhost-only guard. NOTE: cloudflared connects over loopback, so a tunneled request also
1444
+ // arrives as 127.0.0.1 — this IP check alone is a no-op behind the relay. We additionally
1445
+ // reject any request carrying cloudflared's cf-connecting-ip header (tunnel-origin traffic);
1446
+ // the real defense is the 256-bit agent secret below. The Agent API is only ever called by
1447
+ // the local workspace backend (loopback), never legitimately from the public tunnel.
1384
1448
  const remoteIp = req.socket.remoteAddress || '';
1385
- if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
1449
+ const isLoopback = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
1450
+ if (!isLoopback || req.headers['cf-connecting-ip'] || req.headers['cf-ray']) {
1386
1451
  res.setHeader('Content-Type', 'application/json');
1387
1452
  res.writeHead(403);
1388
1453
  res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
1389
1454
  return;
1390
1455
  }
1391
1456
 
1392
- // Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers)
1457
+ // Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers).
1458
+ // Constant-time compare (length-guarded) so the secret can't be recovered via timing.
1393
1459
  const urlObj = new URL(req.url, `http://${req.headers.host}`);
1394
1460
  const headerSecret = req.headers['x-agent-secret'];
1395
1461
  const querySecret = urlObj.searchParams.get('secret');
1396
- if (headerSecret !== agentSecret && querySecret !== agentSecret) {
1462
+ const presented = typeof headerSecret === 'string' ? headerSecret : (querySecret || '');
1463
+ const secretOk = presented.length === agentSecret.length &&
1464
+ crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(agentSecret));
1465
+ if (!secretOk) {
1397
1466
  res.setHeader('Content-Type', 'application/json');
1398
1467
  res.writeHead(401);
1399
1468
  res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret.' }));
@@ -1787,6 +1856,28 @@ mint();
1787
1856
  };
1788
1857
  }
1789
1858
 
1859
+ // Same for Codex OAuth: the app-server reads ~/.codex/auth.json at spawn, so a
1860
+ // running subprocess only adopts a new identity on re-spawn. End live conversations
1861
+ // after a successful exchange so the next message cold-starts with the fresh token.
1862
+ // (Wraps only the one-shot /exchange; the device-code /status route latches
1863
+ // success on every poll and must not re-fire teardown — handled at re-spawn instead.)
1864
+ if (req.method === 'POST' && req.url === '/api/auth/codex/exchange') {
1865
+ const origEnd = res.end.bind(res);
1866
+ (res as any).end = function (this: typeof res, ...args: any[]) {
1867
+ try {
1868
+ const body = typeof args[0] === 'string' ? args[0] : args[0]?.toString();
1869
+ if (body) {
1870
+ const json = JSON.parse(body);
1871
+ if (json.success) {
1872
+ log.info('[orchestrator] Codex re-auth succeeded — restarting conversations with fresh token');
1873
+ endAllConversations();
1874
+ }
1875
+ }
1876
+ } catch {}
1877
+ return origEnd(...args);
1878
+ };
1879
+ }
1880
+
1790
1881
  workerApp(req, res);
1791
1882
  return;
1792
1883
  }
@@ -1929,6 +2020,11 @@ mint();
1929
2020
  // An 'error' event with no listener is rethrown by Node as an uncaught exception,
1930
2021
  // which would crash the whole supervisor. ws still tears down + fires 'close'.
1931
2022
  ws.on('error', (err: any) => console.warn(`[app-ws] socket error: ${err?.message || err}`));
2023
+ // Liveness: a half-open socket (mobile/Wi-Fi drop behind the tunnel) never fires 'close', so
2024
+ // its chat subscription + maps would leak and broadcastBloby would keep writing to it. The
2025
+ // heartbeat below pings; a peer that misses a pong is terminated (which fires 'close' → cleanup).
2026
+ (ws as any).isAlive = true;
2027
+ ws.on('pong', () => { (ws as any).isAlive = true; });
1932
2028
 
1933
2029
  // Per-WS chat subscription: when the client opts in, this WS joins chatSubscribers
1934
2030
  // and receives every bot:* / chat:* event the dashboard widget does. SSE through the
@@ -2203,6 +2299,8 @@ mint();
2203
2299
  // See appWss above: a listener-less 'error' event would crash the supervisor and kill
2204
2300
  // chat for everyone (G1). ws still fires 'close' afterward, so map cleanup still runs.
2205
2301
  ws.on('error', (err: any) => log.warn(`[bloby-ws] socket error: ${err?.message || err}`));
2302
+ (ws as any).isAlive = true;
2303
+ ws.on('pong', () => { (ws as any).isAlive = true; });
2206
2304
  let convId = Math.random().toString(36).slice(2) + Date.now().toString(36);
2207
2305
  conversations.set(ws, []);
2208
2306
 
@@ -2730,6 +2828,12 @@ mint();
2730
2828
  }
2731
2829
  }
2732
2830
 
2831
+ // Tell the live chat when the backend gives up — the dashboard interstitial covers page loads,
2832
+ // but an already-open chat client gets an explicit event it can surface ("ask me to fix the backend").
2833
+ setBackendGiveUpHandler(() => {
2834
+ broadcastBloby('backend:failed', { message: 'The workspace backend crashed and could not restart. Ask Bloby to fix it.' });
2835
+ });
2836
+
2733
2837
  // Spawn backend (worker runs in-process)
2734
2838
  spawnBackend(backendPort);
2735
2839
 
@@ -2902,6 +3006,20 @@ mint();
2902
3006
  armBackendWatcher();
2903
3007
  armWorkspaceWatcher();
2904
3008
 
3009
+ // WebSocket liveness heartbeat — ping the app + chat WS clients every 30s and terminate any
3010
+ // that missed the previous pong (half-open sockets that never fired 'close'). Terminating fires
3011
+ // 'close', which runs the existing map/subscription cleanup. Scoped to our two WSS only (Vite's
3012
+ // HMR socket is separate and managed by Vite). Cleared in shutdown().
3013
+ const wsHeartbeat = setInterval(() => {
3014
+ for (const wss of [blobyWss, appWss]) {
3015
+ for (const ws of wss.clients) {
3016
+ if ((ws as any).isAlive === false) { try { ws.terminate(); } catch {} continue; }
3017
+ (ws as any).isAlive = false;
3018
+ try { ws.ping(); } catch {}
3019
+ }
3020
+ }
3021
+ }, 30_000);
3022
+
2905
3023
  // Tunnel
2906
3024
  let tunnelUrl: string | null = null;
2907
3025
 
@@ -3064,6 +3182,7 @@ mint();
3064
3182
  stopScheduler();
3065
3183
  backendWatcher?.close();
3066
3184
  workspaceWatcher?.close();
3185
+ clearInterval(wsHeartbeat);
3067
3186
  if (backendRestartTimer) clearTimeout(backendRestartTimer);
3068
3187
  if (watchdogInterval) clearInterval(watchdogInterval);
3069
3188
  stopHeartbeat();