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.
- package/package.json +2 -2
- package/shared/config.ts +5 -0
- package/supervisor/backend.ts +29 -4
- package/supervisor/channels/manager.ts +81 -19
- package/supervisor/channels/types.ts +5 -0
- package/supervisor/chat/src/components/Chat/EnvForm.tsx +2 -1
- package/supervisor/harnesses/claude.ts +12 -2
- package/supervisor/harnesses/codex.ts +289 -43
- package/supervisor/harnesses/pi/index.ts +8 -1
- package/supervisor/index.ts +126 -7
- package/worker/prompts/bloby-system-prompt-codex.txt +778 -0
- package/worker/prompts/bloby-system-prompt-pi.txt +778 -0
- package/worker/prompts/prompt-assembler.ts +49 -14
- package/workspace/skills/alexa/SKILL.md +5 -0
- package/workspace/skills/mac/SKILL.md +5 -0
- package/workspace/skills/plaud/SKILL.md +5 -0
- package/workspace/skills/whatsapp/SKILL.md +30 -2
package/supervisor/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|