bloby-bot 0.53.8 → 0.53.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.53.8",
3
+ "version": "0.53.10",
4
4
  "releaseNotes": [
5
5
  "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
6
  "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
@@ -197,7 +197,7 @@ function BlobyApp() {
197
197
  const reg = await navigator.serviceWorker.ready;
198
198
  const subscription = await reg.pushManager.getSubscription();
199
199
  if (subscription) {
200
- const res = await fetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`);
200
+ const res = await authFetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`);
201
201
  const data = await res.json();
202
202
  setPushState(data.subscribed ? 'subscribed' : 'unsubscribed');
203
203
  } else {
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useRef } from 'react';
2
2
  import type { WsClient } from '../lib/ws-client';
3
+ import { authFetch } from '../lib/auth';
3
4
 
4
5
  export interface StoredAttachment {
5
6
  type: string;
@@ -45,7 +46,7 @@ export function useChat(ws: WsClient | null) {
45
46
  useEffect(() => {
46
47
  if (loaded.current) return;
47
48
  loaded.current = true;
48
- fetch('/api/context/current')
49
+ authFetch('/api/context/current')
49
50
  .then((r) => r.json())
50
51
  .then((data) => {
51
52
  if (data.conversationId) {
@@ -58,7 +59,7 @@ export function useChat(ws: WsClient | null) {
58
59
  // Load messages when conversationId is set
59
60
  useEffect(() => {
60
61
  if (!conversationId) return;
61
- fetch(`/api/conversations/${conversationId}`)
62
+ authFetch(`/api/conversations/${conversationId}`)
62
63
  .then((r) => {
63
64
  if (!r.ok) throw new Error('not found');
64
65
  return r.json();
@@ -105,7 +106,7 @@ export function useChat(ws: WsClient | null) {
105
106
  .catch(() => {
106
107
  // Conversation gone — clear
107
108
  setConversationId(null);
108
- fetch('/api/context/clear', { method: 'POST' }).catch(() => {});
109
+ authFetch('/api/context/clear', { method: 'POST' }).catch(() => {});
109
110
  });
110
111
  }, [conversationId]);
111
112
 
@@ -114,7 +115,7 @@ export function useChat(ws: WsClient | null) {
114
115
  useEffect(() => {
115
116
  if (conversationId && conversationId !== prevConvId.current) {
116
117
  prevConvId.current = conversationId;
117
- fetch('/api/context/set', {
118
+ authFetch('/api/context/set', {
118
119
  method: 'POST',
119
120
  headers: { 'Content-Type': 'application/json' },
120
121
  body: JSON.stringify({ conversationId }),
@@ -274,7 +275,7 @@ export function useChat(ws: WsClient | null) {
274
275
  setTools([]);
275
276
  prevConvId.current = null;
276
277
  loaded.current = false;
277
- fetch('/api/context/clear', { method: 'POST' }).catch(() => {});
278
+ authFetch('/api/context/clear', { method: 'POST' }).catch(() => {});
278
279
  }, []);
279
280
 
280
281
  return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, clearContext };
@@ -237,13 +237,63 @@ self.addEventListener('notificationclick', function(event) {
237
237
  });
238
238
  `;
239
239
 
240
- const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bloby</title>
241
- <style>@keyframes _fs{to{transform:rotate(360deg)}}body{background:#222122;margin:0}</style></head>
242
- <body><div style="background:#222122;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;width:100vw;font-family:system-ui,-apple-system,sans-serif">
243
- <img src="/morphy-icon-192.png" width="56" height="56" style="border-radius:14px;margin-bottom:20px" alt="" />
244
- <div style="width:18px;height:18px;border:2px solid rgba(255,255,255,0.12);border-top-color:rgba(255,255,255,0.7);border-radius:50%;animation:_fs .6s linear infinite"></div>
245
- </div><script>setTimeout(function(){location.reload()},3000)</script>
246
- <script src="/bloby/widget.js"></script></body></html>`;
240
+ // Shown when the dashboard's Vite dev server is briefly unreachable (startup, restart, or a
241
+ // crash). This is a transient "reconnecting" state — no action needed — so it polls and reloads
242
+ // itself the moment Vite is back (no blind 3s reload-into-the-same-error loop). Same branded
243
+ // look as the backend-down interstitial; the chat widget is loaded so the user can still talk to
244
+ // the agent if it doesn't recover on its own.
245
+ const RECOVERING_HTML = `<!DOCTYPE html>
246
+ <html lang="en"><head>
247
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
248
+ <title>Reconnecting · Bloby</title>
249
+ <style>
250
+ *{margin:0;padding:0;box-sizing:border-box}
251
+ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:1.5rem;overflow:hidden}
252
+ .c{text-align:center;max-width:460px;width:100%;animation:fade-up .6s ease-out both}
253
+ .video-wrap{position:relative;width:200px;height:200px;margin:0 auto 1.4rem;display:flex;align-items:center;justify-content:center}
254
+ .video-wrap::before{content:'';position:absolute;inset:-20px;background:radial-gradient(circle,rgba(1,102,255,0.18) 0%,transparent 60%);filter:blur(20px);animation:glow 3s ease-in-out infinite}
255
+ .video-wrap video{position:relative;width:100%;height:100%;object-fit:contain;pointer-events:none;border-radius:50%}
256
+ h1{font-size:1.55rem;font-weight:700;margin-bottom:.6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
257
+ p{color:#a1a1aa;line-height:1.6;margin-bottom:.5rem;font-size:.95rem}
258
+ .lead{color:#e4e4e7;font-size:1rem}
259
+ .sub{font-size:.82rem;color:#71717a;display:inline-flex;align-items:center;gap:.5rem;background:#18181b;border:1px solid #27272a;border-radius:9999px;padding:.35rem .9rem;margin-top:1.1rem}
260
+ .sub .dot{width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#0166FF,#009AFE);box-shadow:0 0 8px rgba(1,102,255,.6);animation:pulse 1.6s ease-in-out infinite}
261
+ .badge{display:block;font-size:.7rem;color:#52525b;margin-top:1.3rem}
262
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.85)}}
263
+ @keyframes glow{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
264
+ @keyframes fade-up{0%{opacity:0;transform:translateY(12px)}100%{opacity:1;transform:translateY(0)}}
265
+ </style></head>
266
+ <body><div class="c">
267
+ <div class="video-wrap"><video autoplay loop muted playsinline>
268
+ <source src="/what-happened.webm" type="video/webm">
269
+ <source src="/what-happened.mp4" type="video/mp4">
270
+ </video></div>
271
+ <h1>Reconnecting…</h1>
272
+ <p class="lead">Hang tight — your app is coming back online.</p>
273
+ <p>This usually happens right after an update or a restart. No action needed; this page refreshes itself the moment it's ready.</p>
274
+ <div class="sub"><span class="dot"></span><span id="statusText">Reconnecting…</span></div>
275
+ <span class="badge">Powered by Bloby</span>
276
+ </div>
277
+ <script>
278
+ (function(){
279
+ var attempt = 0, statusEl = document.getElementById('statusText');
280
+ function retry(){
281
+ attempt++;
282
+ fetch(location.href, { cache:'no-store', redirect:'follow' })
283
+ .then(function(r){ if (r.ok) location.reload(); else schedule(); })
284
+ .catch(schedule);
285
+ }
286
+ function schedule(){
287
+ statusEl.textContent = attempt > 8
288
+ ? 'Still reconnecting — you can ask your agent in the chat.'
289
+ : 'Reconnecting… (attempt ' + attempt + ')';
290
+ setTimeout(retry, Math.min(4000, 1200 + attempt * 300));
291
+ }
292
+ setTimeout(retry, 1800);
293
+ })();
294
+ </script>
295
+ <script src="/bloby/widget.js"></script>
296
+ </body></html>`;
247
297
 
248
298
  /** Interstitial shown (by the supervisor, not the workspace) when the workspace backend has
249
299
  * crash-looped and given up. Replaces proxying the dashboard SPA to Vite — which would 503 on
@@ -356,10 +406,21 @@ export async function startSupervisor() {
356
406
  // The request handler is set up later via server.on('request')
357
407
  const server = http.createServer();
358
408
 
359
- // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly
409
+ // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly.
410
+ // A Vite boot failure must NOT take down the supervisor (G1): chat is served independently and
411
+ // is the lifeline the user needs to ask the agent to fix things. On failure we fall back to a
412
+ // sentinel port — the dashboard proxy then serves the branded "Reconnecting" page (which polls
413
+ // and carries the chat widget) while chat, the worker API, channels, and the tunnel all still
414
+ // come up. Previously this throw reached the top-level catch → process.exit before chat listened.
360
415
  console.log('[supervisor] Starting Vite dev server...');
361
- const vitePorts = await startViteDevServers(config.port, server);
362
- console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
416
+ let vitePorts: { dashboard: number };
417
+ try {
418
+ vitePorts = await startViteDevServers(config.port, server);
419
+ console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
420
+ } catch (err) {
421
+ log.error(`Vite dev server failed to start — dashboard degraded, chat still available: ${err instanceof Error ? err.message : err}`);
422
+ vitePorts = { dashboard: -1 }; // sentinel → dashboard proxy serves RECOVERING_HTML
423
+ }
363
424
  console.log(`[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`);
364
425
 
365
426
  // Ensure file storage dirs exist
@@ -454,36 +515,31 @@ export async function startSupervisor() {
454
515
  }
455
516
  }
456
517
 
457
- const AUTH_EXEMPT_ROUTES = [
458
- 'POST /api/portal/login',
459
- 'GET /api/portal/login',
460
- 'POST /api/portal/validate-token',
461
- 'GET /api/portal/validate-token',
462
- 'GET /api/onboard/status',
518
+ // SECURITY MODEL — public allowlist (secure by default). When a portal password is set, EVERY
519
+ // /api/* route requires a valid Bearer token EXCEPT the ones listed here (the pre-login surface:
520
+ // login/onboarding/health/non-secret config). A new /api route is therefore GATED by default —
521
+ // it can only leak if someone explicitly adds it here. (Previously the gate skipped ALL GET/HEAD,
522
+ // so every data read — conversations, context, wallet, devices — was readable with no token.)
523
+ // Note: /api/agent/* (agent secret) and /api/channels/* are intercepted + returned BEFORE this
524
+ // gate, so they're unaffected; the channel entries below are belt-and-suspenders.
525
+ const PUBLIC_PRELOGIN_ROUTES = [
463
526
  'GET /api/health',
464
- // NOTE: 'POST /api/onboard' is intentionally NOT blanket-exempt. It is gated in the
465
- // request handler below: open on genuine first run (no portal_pass yet), token-required
466
- // afterward. Re-onboard from the dashboard uses the internal x-internal WS path.
527
+ 'GET /api/onboard/status',
528
+ 'GET /api/settings', // secrets already stripped (worker denylist); widget + onboard read flags pre-login
467
529
  'GET /api/push/vapid-public-key',
468
- 'GET /api/push/status',
469
- 'POST /api/auth/claude/start',
470
- 'POST /api/auth/claude/exchange',
471
- 'GET /api/auth/claude/status',
472
- 'POST /api/auth/codex/start',
473
- 'POST /api/auth/codex/cancel',
474
- 'GET /api/auth/codex/status',
475
- 'GET /api/auth/pi/providers',
476
- 'GET /api/auth/pi/status',
477
- 'POST /api/auth/pi/test',
478
- 'POST /api/auth/pi/save',
479
- 'DELETE /api/auth/pi',
480
- 'POST /api/auth/pi/completion',
481
- 'POST /api/portal/totp/setup',
482
- 'POST /api/portal/totp/verify-setup',
483
- 'POST /api/portal/totp/disable',
484
- 'GET /api/portal/totp/status',
530
+ 'GET /api/portal/login',
531
+ 'POST /api/portal/login',
532
+ 'GET /api/portal/validate-token',
533
+ 'POST /api/portal/validate-token',
485
534
  'GET /api/portal/login/totp',
486
- 'POST /api/portal/devices/revoke',
535
+ 'GET /api/portal/totp/status',
536
+ 'POST /api/portal/totp/setup', // self-protected in-handler (Bearer OR password OR first-run)
537
+ 'POST /api/portal/totp/verify-setup', // self-protected in-handler
538
+ 'POST /api/portal/totp/disable', // self-protected (requires password + valid code)
539
+ 'POST /api/portal/verify-password', // verifies the password itself — cannot require a token
540
+ // NOTE: 'POST /api/onboard' is NOT public — gated below: open only on genuine first run
541
+ // (no portal_pass yet), token-required afterward. Dashboard re-onboard uses the x-internal WS path.
542
+ // Channel onboarding (also intercepted earlier; kept for completeness/safety):
487
543
  'GET /api/channels/status',
488
544
  'GET /api/channels/whatsapp/qr',
489
545
  'GET /api/channels/whatsapp/qr-page',
@@ -496,12 +552,23 @@ export async function startSupervisor() {
496
552
  'POST /api/channels/send',
497
553
  'POST /api/channels/alexa/handle',
498
554
  ];
555
+ // Method-specific public PREFIXES — onboarding namespaces with sub-paths / params that carry no
556
+ // private chat data: provider OAuth setup/status (all of /api/auth/*), and handle availability
557
+ // (GET /api/handle/* only — handle register/change are POSTs and stay gated).
558
+ const PUBLIC_PRELOGIN_PREFIXES = [
559
+ 'POST /api/auth/',
560
+ 'GET /api/auth/',
561
+ 'DELETE /api/auth/',
562
+ 'GET /api/handle/',
563
+ ];
499
564
 
500
- function isExemptRoute(method: string, url: string): boolean {
565
+ function isPublicRoute(method: string, url: string): boolean {
566
+ const m = method === 'HEAD' ? 'GET' : method; // a HEAD to a public GET route is public
501
567
  const path = url.split('?')[0];
502
- return AUTH_EXEMPT_ROUTES.some((r) => {
503
- const [m, p] = r.split(' ');
504
- return method === m && path === p;
568
+ if (PUBLIC_PRELOGIN_ROUTES.includes(`${m} ${path}`)) return true;
569
+ return PUBLIC_PRELOGIN_PREFIXES.some((r) => {
570
+ const sp = r.indexOf(' ');
571
+ return m === r.slice(0, sp) && path.startsWith(r.slice(sp + 1));
505
572
  });
506
573
  }
507
574
 
@@ -1678,9 +1745,11 @@ mint();
1678
1745
  const isInternal = req.headers['x-internal'] === internalSecret;
1679
1746
 
1680
1747
  if (!isInternal) {
1681
- // Auth check for mutation routes (POST/PUT/DELETE) GET/HEAD are read-only, skip auth
1748
+ // Require a token for EVERY /api route except the public pre-login allowlist. This now
1749
+ // covers GET data reads (conversations, context, wallet, devices, push status) that
1750
+ // previously skipped auth and leaked over the public relay.
1682
1751
  const method = req.method || 'GET';
1683
- if (method !== 'GET' && method !== 'HEAD' && !isExemptRoute(method, req.url || '')) {
1752
+ if (!isPublicRoute(method, req.url || '')) {
1684
1753
  // POST /api/onboard is open only on genuine first run (no portal_pass yet). Read the
1685
1754
  // setting DIRECTLY rather than the 30s-cached isAuthRequired() so the gate closes the
1686
1755
  // instant onboarding sets a password — no stale-cache window for a takeover. The
@@ -1798,6 +1867,14 @@ mint();
1798
1867
  return;
1799
1868
  }
1800
1869
 
1870
+ // Vite failed to boot (sentinel port) → serve the recovering page directly instead of
1871
+ // proxying to a dead port. Chat (/bloby/*) is served earlier, so the lifeline stays up.
1872
+ if (vitePorts.dashboard < 0) {
1873
+ res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
1874
+ res.end(RECOVERING_HTML);
1875
+ return;
1876
+ }
1877
+
1801
1878
  // Everything else → proxy to dashboard Vite dev server
1802
1879
  console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
1803
1880
  const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
@@ -2738,14 +2815,36 @@ mint();
2738
2815
  }, 1000);
2739
2816
  }
2740
2817
 
2741
- // Watch backend/ for code changes
2742
- const backendWatcher = fs.watch(backendDir, { recursive: true }, (_event, filename) => {
2743
- if (!filename || !filename.match(/\.(ts|js|json)$/)) return;
2744
- scheduleBackendRestart(`Backend file changed: ${filename}`);
2745
- });
2818
+ // Self-healing file watchers. Two failure modes the audit flagged, both of which would hurt G3
2819
+ // (auto-heal) or G1 (chat): (a) fs.watch throws synchronously if its target is missing — at boot
2820
+ // that reached the top-level catch → process.exit before chat listened; (b) a watcher 'error'
2821
+ // event (EMFILE under load, the watched inode removed during a workspace swap) has no listener,
2822
+ // so it crashes the supervisor AND leaves a silently-dead watcher (auto-heal stops with no
2823
+ // signal). Fix: ensure the dir exists, attach an 'error' listener, and re-arm with backoff.
2824
+ let backendWatcher: fs.FSWatcher | null = null;
2825
+ let workspaceWatcher: fs.FSWatcher | null = null;
2826
+
2827
+ function armBackendWatcher() {
2828
+ try {
2829
+ fs.mkdirSync(backendDir, { recursive: true }); // fs.watch throws if the target is missing
2830
+ const w = fs.watch(backendDir, { recursive: true }, (_event, filename) => {
2831
+ if (!filename || !filename.toString().match(/\.(ts|js|json)$/)) return;
2832
+ scheduleBackendRestart(`Backend file changed: ${filename}`);
2833
+ });
2834
+ w.on('error', (err: any) => {
2835
+ log.warn(`[watcher] backend watcher error: ${err?.message || err} — re-arming in 2s`);
2836
+ try { w.close(); } catch {}
2837
+ backendWatcher = null;
2838
+ setTimeout(armBackendWatcher, 2000);
2839
+ });
2840
+ backendWatcher = w;
2841
+ } catch (err: any) {
2842
+ log.warn(`[watcher] backend watcher failed to arm: ${err?.message || err} — retry in 5s`);
2843
+ setTimeout(armBackendWatcher, 5000);
2844
+ }
2845
+ }
2746
2846
 
2747
- // Watch workspace root for .env, dependency, and .restart/.update changes
2748
- const workspaceWatcher = fs.watch(workspaceDir, (_event, filename) => {
2847
+ function onWorkspaceChange(_event: fs.WatchEventType, filename: string | Buffer | null) {
2749
2848
  if (!filename) return;
2750
2849
  if (filename === '.env') {
2751
2850
  scheduleBackendRestart('.env changed');
@@ -2782,7 +2881,26 @@ mint();
2782
2881
  runDeferredUpdate();
2783
2882
  }
2784
2883
  }
2785
- });
2884
+ }
2885
+
2886
+ function armWorkspaceWatcher() {
2887
+ try {
2888
+ const w = fs.watch(workspaceDir, onWorkspaceChange);
2889
+ w.on('error', (err: any) => {
2890
+ log.warn(`[watcher] workspace watcher error: ${err?.message || err} — re-arming in 2s`);
2891
+ try { w.close(); } catch {}
2892
+ workspaceWatcher = null;
2893
+ setTimeout(armWorkspaceWatcher, 2000);
2894
+ });
2895
+ workspaceWatcher = w;
2896
+ } catch (err: any) {
2897
+ log.warn(`[watcher] workspace watcher failed to arm: ${err?.message || err} — retry in 5s`);
2898
+ setTimeout(armWorkspaceWatcher, 5000);
2899
+ }
2900
+ }
2901
+
2902
+ armBackendWatcher();
2903
+ armWorkspaceWatcher();
2786
2904
 
2787
2905
  // Tunnel
2788
2906
  let tunnelUrl: string | null = null;
@@ -2944,8 +3062,8 @@ mint();
2944
3062
  log.info('Shutting down...');
2945
3063
  await channelManager.disconnectAll();
2946
3064
  stopScheduler();
2947
- backendWatcher.close();
2948
- workspaceWatcher.close();
3065
+ backendWatcher?.close();
3066
+ workspaceWatcher?.close();
2949
3067
  if (backendRestartTimer) clearTimeout(backendRestartTimer);
2950
3068
  if (watchdogInterval) clearInterval(watchdogInterval);
2951
3069
  stopHeartbeat();