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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
+
authFetch('/api/context/clear', { method: 'POST' }).catch(() => {});
|
|
278
279
|
}, []);
|
|
279
280
|
|
|
280
281
|
return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, clearContext };
|
package/supervisor/index.ts
CHANGED
|
@@ -237,13 +237,63 @@ self.addEventListener('notificationclick', function(event) {
|
|
|
237
237
|
});
|
|
238
238
|
`;
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
//
|
|
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/
|
|
469
|
-
'POST /api/
|
|
470
|
-
'
|
|
471
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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
|
-
|
|
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
|
|
2948
|
-
workspaceWatcher
|
|
3065
|
+
backendWatcher?.close();
|
|
3066
|
+
workspaceWatcher?.close();
|
|
2949
3067
|
if (backendRestartTimer) clearTimeout(backendRestartTimer);
|
|
2950
3068
|
if (watchdogInterval) clearInterval(watchdogInterval);
|
|
2951
3069
|
stopHeartbeat();
|