shraga 0.1.111 → 0.1.113
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/README.md +4 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +4 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +112 -4
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +10 -5
- package/src/server/engine/claude-code.ts +190 -32
- package/src/server/engine/claude-resume.ts +205 -0
- package/src/server/engine/types.ts +10 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +51 -9
- package/src/server/shraga-config.ts +3 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-BNAh4GUs.js +0 -1949
- package/dist/client/assets/index-DIMte_k6.css +0 -10
package/src/server/boot.ts
CHANGED
|
@@ -51,8 +51,13 @@ import { startEventDispatcher } from './events/dispatcher.ts';
|
|
|
51
51
|
import { seedOperators } from './contacts.ts';
|
|
52
52
|
import { dataSync } from './data-sync.ts';
|
|
53
53
|
import { mountMcpServer } from './mcp-server.ts';
|
|
54
|
+
import { fromInternal, type Principal } from './security/principal.ts';
|
|
55
|
+
import { initSecurity, security, admitTurn, requestIp } from './security/runtime.ts';
|
|
56
|
+
import { writeDenial } from './security/guard.ts';
|
|
57
|
+
import { requireOwner } from './security/owner-only.ts';
|
|
58
|
+
import { flushEscalations } from './security/escalate.ts';
|
|
54
59
|
import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
|
|
55
|
-
import {
|
|
60
|
+
import { apiKeyRouter } from './api-key-routes.ts';
|
|
56
61
|
import { addUnread, markRead as markUnread, getUnreads } from './unread.ts';
|
|
57
62
|
|
|
58
63
|
import { loadShragaConfig, getPublicOrigin } from './shraga-config.ts';
|
|
@@ -66,6 +71,7 @@ import { claudeAccountDir } from './claude-account.ts';
|
|
|
66
71
|
import { ClaudeLogin, ClaudeLoginError } from './claude-login.ts';
|
|
67
72
|
import { getAll as getAllContacts } from './contacts.ts';
|
|
68
73
|
import { artifactsRouter } from './artifacts/artifacts.routes.ts';
|
|
74
|
+
import { ownerRouter } from './security/owner-routes.ts';
|
|
69
75
|
import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
|
|
70
76
|
import { registerEngine } from './engine/index.ts';
|
|
71
77
|
import { subscribeEvent } from './events/bus.ts';
|
|
@@ -114,6 +120,8 @@ export async function bootServer(__reg: BootRegistrations = {}): Promise<ServerH
|
|
|
114
120
|
const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
|
|
115
121
|
const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
|
|
116
122
|
if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
|
|
123
|
+
// Flipped by activateConsumers() (blue-green promotion). Declared up here: initSecurity reads it synchronously.
|
|
124
|
+
let activated = !PASSIVE;
|
|
117
125
|
|
|
118
126
|
// Local-only prep (git config, gitignore, untracking). The NETWORK half — fetch/merge — is
|
|
119
127
|
// deliberately NOT awaited here; it runs via dataSync.syncOnBoot() AFTER listen(). See syncOnBoot().
|
|
@@ -125,6 +133,9 @@ function bootDataSync(): void {
|
|
|
125
133
|
dataSync.syncOnBoot().catch(err => console.error('[data-sync] boot sync error:', (err as Error).message));
|
|
126
134
|
}
|
|
127
135
|
await loadShragaConfig();
|
|
136
|
+
// Process-wide policy + audit (shadow mode: decide + audit, never deny). Audit writes only on the
|
|
137
|
+
// active instance — a PASSIVE standby shares DATA_DIR. `activated` is declared above.
|
|
138
|
+
initSecurity({ isActive: () => !PASSIVE || activated });
|
|
128
139
|
// Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
|
|
129
140
|
// getAvailableEngines() includes them and a directive can resolve to one immediately.
|
|
130
141
|
for (const e of __reg.engines ?? []) registerEngine(e);
|
|
@@ -175,7 +186,18 @@ app.use(express.urlencoded({
|
|
|
175
186
|
// drown real request logs. Errors and everything else still logs unconditionally.
|
|
176
187
|
const QUIET_POLL_RE = /\/(cwd|ptys|sessions|workspace\/(pty-owners|layout))(\?|$)/;
|
|
177
188
|
|
|
189
|
+
/** Package version, read once. Served as the `X-Shraga-Version` identity header on EVERY response
|
|
190
|
+
* (see the middleware below) — a response WITHOUT it proves the responder is not this server, which
|
|
191
|
+
* is what lets the client tell "shraga 404'd" apart from "something else owns this port". */
|
|
192
|
+
const SHRAGA_VERSION: string = (() => {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')).version ?? 'unknown';
|
|
195
|
+
} catch { return 'unknown'; }
|
|
196
|
+
})();
|
|
197
|
+
|
|
178
198
|
app.use((req, _res, next) => {
|
|
199
|
+
// Set BEFORE any route runs, so it rides on error responses (404/401/5xx) too — not just successes.
|
|
200
|
+
_res.setHeader('X-Shraga-Version', SHRAGA_VERSION);
|
|
179
201
|
const start = Date.now();
|
|
180
202
|
const orig = _res.end.bind(_res);
|
|
181
203
|
(_res as any).end = (...args: any[]) => {
|
|
@@ -193,10 +215,7 @@ const SERVER_BUILD_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}
|
|
|
193
215
|
// ── REST routes ───────────────────────────────────────────────────────────────
|
|
194
216
|
|
|
195
217
|
app.get('/api/version', (_req, res) => {
|
|
196
|
-
|
|
197
|
-
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
|
|
198
|
-
res.json({ version: pkg.version });
|
|
199
|
-
} catch { res.json({ version: 'unknown' }); }
|
|
218
|
+
res.json({ version: SHRAGA_VERSION });
|
|
200
219
|
});
|
|
201
220
|
|
|
202
221
|
// ── Self-upgrade (owner only) ────────────────────────────────────────────────
|
|
@@ -426,7 +445,8 @@ app.get('/api/mcps', requireAuth, (req, res) => {
|
|
|
426
445
|
res.json(entries);
|
|
427
446
|
});
|
|
428
447
|
|
|
429
|
-
|
|
448
|
+
// Owner-only: an MCP entry can carry a stdio `command` the server spawns.
|
|
449
|
+
app.put('/api/mcps', requireAuth, requireOwner('Only an owner can change MCP servers'), (req, res) => {
|
|
430
450
|
const user = (req as any).user;
|
|
431
451
|
const globalNames = new Set(Object.keys(getGlobalMcpConfig()));
|
|
432
452
|
const incoming = req.body as McpConfig;
|
|
@@ -440,14 +460,15 @@ app.put('/api/mcps', requireAuth, (req, res) => {
|
|
|
440
460
|
res.json({ ok: true });
|
|
441
461
|
});
|
|
442
462
|
|
|
443
|
-
app.get('/api/config', requireAuth, (
|
|
463
|
+
app.get('/api/config', requireAuth, (req, res) => {
|
|
444
464
|
// `claudeAuthSource` is derived server state (not persisted config) — the spread always overrides
|
|
445
465
|
// any stale value, so it can never round-trip into agent-config.json even if a client echoes it back.
|
|
446
|
-
|
|
466
|
+
// `isOwner` is the same kind of derived field: the client hides owner-only controls on it (PUT strips it).
|
|
467
|
+
res.json({ ...getAgentConfig(), isOwner: !!(req as any).user?.isOwner, claudeAuthSource: getClaudeAuthSource() });
|
|
447
468
|
});
|
|
448
469
|
|
|
449
|
-
app.put('/api/config', requireAuth, (req, res) => {
|
|
450
|
-
const { claudeAuthSource: _drop, ...config } = (req.body ?? {}) as AgentConfig & { claudeAuthSource?: string };
|
|
470
|
+
app.put('/api/config', requireAuth, requireOwner('Only an owner can change the agent config'), (req, res) => {
|
|
471
|
+
const { claudeAuthSource: _drop, isOwner: _dropOwner, ...config } = (req.body ?? {}) as AgentConfig & { claudeAuthSource?: string; isOwner?: boolean };
|
|
451
472
|
saveAgentConfig(config);
|
|
452
473
|
res.json({ ok: true });
|
|
453
474
|
});
|
|
@@ -472,7 +493,7 @@ app.get('/api/skills/:name', requireAuth, (req, res) => {
|
|
|
472
493
|
res.json(skill);
|
|
473
494
|
});
|
|
474
495
|
|
|
475
|
-
app.put('/api/skills/:name', requireAuth, (req, res) => {
|
|
496
|
+
app.put('/api/skills/:name', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
|
|
476
497
|
try {
|
|
477
498
|
const { content } = req.body as { content: string };
|
|
478
499
|
saveSkill(String(req.params.name), content ?? '');
|
|
@@ -480,14 +501,14 @@ app.put('/api/skills/:name', requireAuth, (req, res) => {
|
|
|
480
501
|
} catch (e: any) { res.status(400).json({ error: e.message }); }
|
|
481
502
|
});
|
|
482
503
|
|
|
483
|
-
app.delete('/api/skills/:name', requireAuth, (req, res) => {
|
|
504
|
+
app.delete('/api/skills/:name', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
|
|
484
505
|
try {
|
|
485
506
|
deleteSkill(String(req.params.name));
|
|
486
507
|
res.json({ ok: true });
|
|
487
508
|
} catch (e: any) { res.status(400).json({ error: e.message }); }
|
|
488
509
|
});
|
|
489
510
|
|
|
490
|
-
app.post('/api/skills/:name/duplicate', requireAuth, (req, res) => {
|
|
511
|
+
app.post('/api/skills/:name/duplicate', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
|
|
491
512
|
try {
|
|
492
513
|
const { newName } = req.body as { newName: string };
|
|
493
514
|
const skill = duplicateSkill(String(req.params.name), newName);
|
|
@@ -495,7 +516,7 @@ app.post('/api/skills/:name/duplicate', requireAuth, (req, res) => {
|
|
|
495
516
|
} catch (e: any) { res.status(400).json({ error: e.message }); }
|
|
496
517
|
});
|
|
497
518
|
|
|
498
|
-
app.post('/api/skills/:name/rename', requireAuth, (req, res) => {
|
|
519
|
+
app.post('/api/skills/:name/rename', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
|
|
499
520
|
try {
|
|
500
521
|
const { newName } = req.body as { newName: string };
|
|
501
522
|
renameSkill(String(req.params.name), newName);
|
|
@@ -507,7 +528,7 @@ app.get('/api/skills-defaults', requireAuth, (_req, res) => {
|
|
|
507
528
|
res.json(getDefaultSkills());
|
|
508
529
|
});
|
|
509
530
|
|
|
510
|
-
app.put('/api/skills-defaults', requireAuth, (req, res) => {
|
|
531
|
+
app.put('/api/skills-defaults', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
|
|
511
532
|
setDefaultSkills(req.body);
|
|
512
533
|
res.json({ ok: true });
|
|
513
534
|
});
|
|
@@ -669,6 +690,7 @@ async function runChatTurn(
|
|
|
669
690
|
userName?: string;
|
|
670
691
|
abortController?: AbortController;
|
|
671
692
|
context?: Record<string, string>;
|
|
693
|
+
principal: Principal;
|
|
672
694
|
},
|
|
673
695
|
hooks?: { onEvent?: (ev: WsEvent) => void },
|
|
674
696
|
): Promise<RunChatTurnResult> {
|
|
@@ -695,6 +717,7 @@ async function runChatTurn(
|
|
|
695
717
|
|
|
696
718
|
try {
|
|
697
719
|
const blocks = await consumeStream(streamChat({
|
|
720
|
+
principal: opts.principal,
|
|
698
721
|
prompt,
|
|
699
722
|
sessionId: sid,
|
|
700
723
|
uid,
|
|
@@ -748,17 +771,26 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
|
|
748
771
|
if (existing) return void res.json({ sessionId: existing, status: 'duplicate' });
|
|
749
772
|
}
|
|
750
773
|
|
|
774
|
+
// Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
|
|
775
|
+
const admission = admitTurn(user.principal, { ip: requestIp(req), channel: 'api' });
|
|
776
|
+
if (!admission.ok) return void writeDenial(res, admission);
|
|
777
|
+
|
|
751
778
|
const sid = reqSid || `api-${crypto.randomUUID()}`;
|
|
752
779
|
if (idemKey) rememberIdempotent(user.uid, idemKey, sid);
|
|
753
780
|
const apiAbortController = new AbortController();
|
|
754
|
-
const run = () =>
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
781
|
+
const run = async () => {
|
|
782
|
+
try {
|
|
783
|
+
return await runChatTurn({
|
|
784
|
+
prompt,
|
|
785
|
+
sessionId: sid,
|
|
786
|
+
uid: user.uid,
|
|
787
|
+
userEmail: user.email,
|
|
788
|
+
principal: user.principal,
|
|
789
|
+
abortController: apiAbortController,
|
|
790
|
+
context: { source: 'api', user: user.email },
|
|
791
|
+
});
|
|
792
|
+
} finally { admission.release(); }
|
|
793
|
+
};
|
|
762
794
|
|
|
763
795
|
if (sync) {
|
|
764
796
|
const result = await run();
|
|
@@ -768,6 +800,7 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
|
|
768
800
|
} else {
|
|
769
801
|
// Reject a duplicate before responding 'accepted' (lock is acquired inside run()).
|
|
770
802
|
if (reqSid && isSessionLocked(sid)) {
|
|
803
|
+
admission.release();
|
|
771
804
|
return void res.status(409).json({ error: 'Session is already processing a request' });
|
|
772
805
|
}
|
|
773
806
|
res.json({ sessionId: sid, status: 'accepted' });
|
|
@@ -842,6 +875,7 @@ app.get('/api/data-sync/log', requireAuth, async (_req, res) => {
|
|
|
842
875
|
});
|
|
843
876
|
|
|
844
877
|
app.use(artifactsRouter);
|
|
878
|
+
app.use(ownerRouter);
|
|
845
879
|
|
|
846
880
|
// Runtime feature flags for the client (env-gated, never persisted to agent-config.json).
|
|
847
881
|
// ── Auth mode + local login (PUBLIC — no requireAuth) ────────────────────────
|
|
@@ -904,22 +938,7 @@ app.post('/api/push/unregister', requireAuth, (req, res) => {
|
|
|
904
938
|
});
|
|
905
939
|
|
|
906
940
|
// ── API Keys ──────────────────────────────────────────────────────────────────
|
|
907
|
-
app.
|
|
908
|
-
res.json({ keys: listApiKeys() });
|
|
909
|
-
});
|
|
910
|
-
app.post('/api/api-keys', requireAuth, (req, res) => {
|
|
911
|
-
const user = (req as any).user as import('./auth.ts').AuthUser;
|
|
912
|
-
const { label } = req.body as { label?: string };
|
|
913
|
-
const key = createApiKey(user.uid, user.email, label || 'Unnamed');
|
|
914
|
-
res.json(key);
|
|
915
|
-
});
|
|
916
|
-
app.delete('/api/api-keys/:id', requireAuth, (req: express.Request<{ id: string }>, res) => {
|
|
917
|
-
const user = (req as any).user as import('./auth.ts').AuthUser;
|
|
918
|
-
const ok = deleteApiKey(req.params.id, user.uid, user.isOwner);
|
|
919
|
-
if (ok === 'not_found') return void res.status(404).json({ error: 'Key not found' });
|
|
920
|
-
if (ok === 'forbidden') return void res.status(403).json({ error: 'Cannot delete another user\'s key' });
|
|
921
|
-
res.json({ ok: true });
|
|
922
|
-
});
|
|
941
|
+
app.use(apiKeyRouter({ base: '/api/api-keys', gate: [requireAuth] }));
|
|
923
942
|
|
|
924
943
|
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
925
944
|
mountMcpServer(app, { runChatTurn });
|
|
@@ -953,6 +972,10 @@ const wss = new WebSocketServer({ noServer: true });
|
|
|
953
972
|
interface WsSession {
|
|
954
973
|
uid: string;
|
|
955
974
|
email: string;
|
|
975
|
+
/** Set on ws auth; undefined until then. */
|
|
976
|
+
principal?: Principal;
|
|
977
|
+
/** Client IP from the upgrade request (TRUSTED_PROXIES-aware). */
|
|
978
|
+
ip?: string;
|
|
956
979
|
busySessions: Set<string>;
|
|
957
980
|
autoApprove: boolean;
|
|
958
981
|
abortControllers: Map<string, AbortController>;
|
|
@@ -1200,7 +1223,7 @@ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket:
|
|
|
1200
1223
|
server.on('upgrade', (req, socket, head) => {
|
|
1201
1224
|
if (req.url === '/ws') {
|
|
1202
1225
|
wss.handleUpgrade(req, socket as any, head, (ws) => {
|
|
1203
|
-
const session: WsSession = { uid: '', email: '', busySessions: new Set(), autoApprove: false, abortControllers: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), steerPending: new Map(), lastSessionId: null, viewingSessionId: null, focused: true };
|
|
1226
|
+
const session: WsSession = { uid: '', email: '', ip: requestIp(req), busySessions: new Set(), autoApprove: false, abortControllers: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), steerPending: new Map(), lastSessionId: null, viewingSessionId: null, focused: true };
|
|
1204
1227
|
handleConnection(ws, session);
|
|
1205
1228
|
});
|
|
1206
1229
|
} else {
|
|
@@ -1325,7 +1348,7 @@ initPolls({
|
|
|
1325
1348
|
// NOTHING until it ended — and an add-on engine's subagent events were dropped outright — so a
|
|
1326
1349
|
// turn that dispatched a worker was indistinguishable from one that only claimed to.
|
|
1327
1350
|
return await consumeStream(
|
|
1328
|
-
streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
|
|
1351
|
+
streamChat({ principal: fromInternal({ uid, email: userEmail, lane: 'wake' }), prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
|
|
1329
1352
|
undefined,
|
|
1330
1353
|
{
|
|
1331
1354
|
maxResultChars: STREAMED_RESULT_MAX,
|
|
@@ -1381,10 +1404,10 @@ registerSpaCatchAll(app, distPath);
|
|
|
1381
1404
|
// ── Runtime promotion (blue-green flip) ──────────────────────────────────────
|
|
1382
1405
|
// A passive instance can be promoted to active once traffic has been flipped to it:
|
|
1383
1406
|
// starts every consumer/writer that passive boot skipped. One-way; idempotent-guarded.
|
|
1384
|
-
let activated = !PASSIVE;
|
|
1385
1407
|
async function activateConsumers() {
|
|
1386
1408
|
activated = true;
|
|
1387
1409
|
console.log('[server] ACTIVATING — starting consumers and background writers');
|
|
1410
|
+
security()?.activate(); // passive Policy never wrote; now re-load from disk as trusted (migrates if still missing)
|
|
1388
1411
|
await dataSync.init();
|
|
1389
1412
|
bootDataSync();
|
|
1390
1413
|
syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
|
|
@@ -1550,6 +1573,7 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
|
|
|
1550
1573
|
try {
|
|
1551
1574
|
let eventCount = 0;
|
|
1552
1575
|
for await (const event of streamChat({
|
|
1576
|
+
principal: session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }),
|
|
1553
1577
|
prompt: promptText,
|
|
1554
1578
|
attachments,
|
|
1555
1579
|
sessionId: sid,
|
|
@@ -1748,6 +1772,8 @@ function handleConnection(ws: WebSocket, session: WsSession) {
|
|
|
1748
1772
|
const user = await verifyBearer(msg.token); // pluggable (local|firebase), not firebase-only
|
|
1749
1773
|
session.uid = user.uid;
|
|
1750
1774
|
session.email = user.email;
|
|
1775
|
+
session.principal = user.principal;
|
|
1776
|
+
security()?.authAllow(user.principal, 'ws:bearer');
|
|
1751
1777
|
session.autoApprove = getAutoApprove(user.uid);
|
|
1752
1778
|
console.log(`[ws] Authenticated: ${user.email} (${user.uid}) autoApprove=${session.autoApprove}`);
|
|
1753
1779
|
send(ws, { type: 'auth_ok', uid: user.uid, email: user.email, buildId: SERVER_BUILD_ID });
|
|
@@ -1763,6 +1789,7 @@ function handleConnection(ws: WebSocket, session: WsSession) {
|
|
|
1763
1789
|
}
|
|
1764
1790
|
}
|
|
1765
1791
|
} catch (err: any) {
|
|
1792
|
+
security()?.authDeny('ws:bearer', 'invalid-bearer');
|
|
1766
1793
|
console.error(`[ws] Auth failed:`, err.message);
|
|
1767
1794
|
send(ws, { type: 'auth_error', message: err.message });
|
|
1768
1795
|
ws.close();
|
|
@@ -1836,12 +1863,21 @@ function handleConnection(ws: WebSocket, session: WsSession) {
|
|
|
1836
1863
|
session.steerPending.set(steerSid, steerText);
|
|
1837
1864
|
ac.abort();
|
|
1838
1865
|
} else {
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1866
|
+
// A takeover starts a NEW turn on this connection — same guard as a normal WS turn, checked BEFORE the abort
|
|
1867
|
+
// so a denied steer leaves the running turn alone.
|
|
1868
|
+
const admission = admitTurn(session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }), { ip: session.ip, channel: 'ws' });
|
|
1869
|
+
if (!admission.ok) {
|
|
1870
|
+
return send(ws, { type: 'error', sessionId: steerSid, reason: admission.reason, retryAfter: admission.retryAfter,
|
|
1871
|
+
message: admission.status === 403 ? 'Access blocked' : `Too many requests — try again in ${admission.retryAfter ?? 60}s` });
|
|
1872
|
+
}
|
|
1873
|
+
try {
|
|
1874
|
+
ac.abort();
|
|
1875
|
+
appendMessage(steerSid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
|
|
1876
|
+
console.log(`[ws] External steer takeover for ${steerSid.slice(0, 8)}`);
|
|
1877
|
+
session.busySessions.add(steerSid);
|
|
1878
|
+
session.lastSessionId = steerSid;
|
|
1879
|
+
await runStream(ws, session, steerSid, steerText, undefined, getMcpConfig(session.uid), true);
|
|
1880
|
+
} finally { admission.release(); }
|
|
1845
1881
|
}
|
|
1846
1882
|
return;
|
|
1847
1883
|
}
|
|
@@ -1851,68 +1887,76 @@ function handleConnection(ws: WebSocket, session: WsSession) {
|
|
|
1851
1887
|
let sid = msg.sessionId || crypto.randomUUID();
|
|
1852
1888
|
const wantsFork = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0 && (session.busySessions.has(sid) || isSessionLocked(sid));
|
|
1853
1889
|
if (!wantsFork && (session.busySessions.has(sid) || isSessionLocked(sid))) return send(ws, { type: 'error', message: 'Already processing a request', sessionId: sid });
|
|
1854
|
-
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
? (msg.turnHints as Record<string, unknown>) : undefined;
|
|
1860
|
-
// Ephemeral user turn (generic hint): a synthetic, add-on-originated opener (e.g. a voice greeting).
|
|
1861
|
-
// Run it, but DON'T persist it as a user message — the reply is the real turn, not a user turn.
|
|
1862
|
-
const ephemeralUser = turnHints?.ephemeralUser === true;
|
|
1863
|
-
const promptText = msg.text ?? '';
|
|
1864
|
-
session.lastSessionId = sid;
|
|
1865
|
-
session.viewingSessionId = sid;
|
|
1866
|
-
session.focused = true;
|
|
1867
|
-
const isNew = !msg.sessionId;
|
|
1868
|
-
if (isNew) {
|
|
1869
|
-
upsertSession(sid, promptText, { uid: session.uid, email: session.email });
|
|
1870
|
-
send(ws, { type: 'session_id', sessionId: sid });
|
|
1871
|
-
// Immediate prompt-derived title so the tab renames off "New Chat" now; the LLM title refines it later.
|
|
1872
|
-
const t0 = getSession(sid)?.title;
|
|
1873
|
-
if (t0) send(ws, { type: 'session_title_updated', sessionId: sid, title: t0 });
|
|
1874
|
-
console.log(`[ws] New session ${sid.slice(0, 8)} for ${session.email}`);
|
|
1890
|
+
// Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
|
|
1891
|
+
const admission = admitTurn(session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }), { ip: session.ip, channel: 'ws' });
|
|
1892
|
+
if (!admission.ok) {
|
|
1893
|
+
return send(ws, { type: 'error', sessionId: sid, reason: admission.reason, retryAfter: admission.retryAfter,
|
|
1894
|
+
message: admission.status === 403 ? 'Access blocked' : `Too many requests — try again in ${admission.retryAfter ?? 60}s` });
|
|
1875
1895
|
}
|
|
1896
|
+
try {
|
|
1897
|
+
if (!wantsFork) session.busySessions.add(sid);
|
|
1898
|
+
const mcpServers = getMcpConfig(session.uid);
|
|
1899
|
+
// Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
|
|
1900
|
+
// turn-context/engine seams and interprets no add-on key (a plain object only — never an array/primitive).
|
|
1901
|
+
const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
|
|
1902
|
+
? (msg.turnHints as Record<string, unknown>) : undefined;
|
|
1903
|
+
// Ephemeral user turn (generic hint): a synthetic, add-on-originated opener (e.g. a voice greeting).
|
|
1904
|
+
// Run it, but DON'T persist it as a user message — the reply is the real turn, not a user turn.
|
|
1905
|
+
const ephemeralUser = turnHints?.ephemeralUser === true;
|
|
1906
|
+
const promptText = msg.text ?? '';
|
|
1907
|
+
session.lastSessionId = sid;
|
|
1908
|
+
session.viewingSessionId = sid;
|
|
1909
|
+
session.focused = true;
|
|
1910
|
+
const isNew = !msg.sessionId;
|
|
1911
|
+
if (isNew) {
|
|
1912
|
+
upsertSession(sid, promptText, { uid: session.uid, email: session.email });
|
|
1913
|
+
send(ws, { type: 'session_id', sessionId: sid });
|
|
1914
|
+
// Immediate prompt-derived title so the tab renames off "New Chat" now; the LLM title refines it later.
|
|
1915
|
+
const t0 = getSession(sid)?.title;
|
|
1916
|
+
if (t0) send(ws, { type: 'session_title_updated', sessionId: sid, title: t0 });
|
|
1917
|
+
console.log(`[ws] New session ${sid.slice(0, 8)} for ${session.email}`);
|
|
1918
|
+
}
|
|
1876
1919
|
|
|
1877
|
-
|
|
1920
|
+
console.log(`[ws] Message from ${session.email}: "${promptText.slice(0, 100)}" session=${sid.slice(0, 8)}`);
|
|
1878
1921
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1922
|
+
// Truncate or fork conversation if replaying/editing a previous message
|
|
1923
|
+
if (typeof msg.truncateAt === 'number' && msg.truncateAt >= 0) {
|
|
1924
|
+
if (wantsFork) {
|
|
1925
|
+
// Session is busy — fork instead of destructive truncate to avoid race conditions
|
|
1926
|
+
let forkedId: string | null = null;
|
|
1927
|
+
if (msg.truncateAt > 0) {
|
|
1928
|
+
// forkSession truncateAtIndex is inclusive (slices to index+1), truncateAt is message count to keep
|
|
1929
|
+
forkedId = forkSession(sid, { uid: session.uid, email: session.email, name: session.email.split('@')[0] }, msg.truncateAt - 1);
|
|
1930
|
+
}
|
|
1931
|
+
if (!forkedId) {
|
|
1932
|
+
// truncateAt=0 (restart from scratch) or forkSession failed — create a fresh session
|
|
1933
|
+
forkedId = crypto.randomUUID();
|
|
1934
|
+
upsertSession(forkedId, promptText, { uid: session.uid, email: session.email });
|
|
1935
|
+
}
|
|
1936
|
+
console.log(`[ws] Forked busy session ${sid.slice(0, 8)} → ${forkedId.slice(0, 8)} (truncateAt=${msg.truncateAt})`);
|
|
1937
|
+
session.busySessions.add(forkedId);
|
|
1938
|
+
sid = forkedId;
|
|
1939
|
+
session.lastSessionId = forkedId;
|
|
1940
|
+
send(ws, { type: 'forked', sourceSessionId: msg.sessionId, sessionId: forkedId });
|
|
1941
|
+
} else {
|
|
1942
|
+
const existing = loadConversation(sid);
|
|
1943
|
+
saveConversation(sid, existing.slice(0, msg.truncateAt));
|
|
1944
|
+
console.log(`[ws] Truncated conversation ${sid.slice(0, 8)} to ${msg.truncateAt} messages`);
|
|
1892
1945
|
}
|
|
1893
|
-
console.log(`[ws] Forked busy session ${sid.slice(0, 8)} → ${forkedId.slice(0, 8)} (truncateAt=${msg.truncateAt})`);
|
|
1894
|
-
session.busySessions.add(forkedId);
|
|
1895
|
-
sid = forkedId;
|
|
1896
|
-
session.lastSessionId = forkedId;
|
|
1897
|
-
send(ws, { type: 'forked', sourceSessionId: msg.sessionId, sessionId: forkedId });
|
|
1898
|
-
} else {
|
|
1899
|
-
const existing = loadConversation(sid);
|
|
1900
|
-
saveConversation(sid, existing.slice(0, msg.truncateAt));
|
|
1901
|
-
console.log(`[ws] Truncated conversation ${sid.slice(0, 8)} to ${msg.truncateAt} messages`);
|
|
1902
1946
|
}
|
|
1903
|
-
}
|
|
1904
1947
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1948
|
+
// Save user message to disk immediately
|
|
1949
|
+
const attachments: AttachmentMeta[] = msg.attachments ?? [];
|
|
1950
|
+
const attBlocks: ConvBlock[] = attachments.map((a: any) =>
|
|
1951
|
+
a.mimeType.startsWith('image/')
|
|
1952
|
+
? { type: 'image' as const, src: a.url }
|
|
1953
|
+
: { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }
|
|
1954
|
+
);
|
|
1955
|
+
if (!ephemeralUser) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
|
|
1956
|
+
|
|
1957
|
+
const wasReset = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0;
|
|
1958
|
+
await runStream(ws, session, sid, promptText, attachments, mcpServers, false, wasReset, turnHints);
|
|
1959
|
+
} finally { admission.release(); }
|
|
1916
1960
|
}
|
|
1917
1961
|
|
|
1918
1962
|
if (msg.type === 'interruption_marker') {
|
|
@@ -2000,6 +2044,8 @@ async function retryWebSession(session: SessionMeta, prompt: string) {
|
|
|
2000
2044
|
];
|
|
2001
2045
|
registerLivePartial(sid, collectPartial);
|
|
2002
2046
|
for await (const ev of streamChat({
|
|
2047
|
+
// Recovery of an interrupted web turn: no live human, the session's owner marked internal.
|
|
2048
|
+
principal: fromInternal({ uid: session.uid, email: session.userEmail, lane: 'web-retry' }),
|
|
2003
2049
|
prompt,
|
|
2004
2050
|
sessionId: sid,
|
|
2005
2051
|
uid: session.uid,
|
|
@@ -2235,6 +2281,14 @@ async function gracefulShutdown(signal: string, opts: { exit?: boolean } = {}) {
|
|
|
2235
2281
|
|
|
2236
2282
|
console.log(`[server] drain complete — ${getActiveLockCount()} stream(s) still active, closing`);
|
|
2237
2283
|
|
|
2284
|
+
// Batched escalation digests live only in memory: hand them to the owners before exiting. Best-effort, bounded.
|
|
2285
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2286
|
+
await Promise.race([
|
|
2287
|
+
flushEscalations().then((n) => { if (n) console.log(`[server] flushed ${n} pending escalation digest(s)`); }),
|
|
2288
|
+
new Promise<void>((r) => { flushTimer = setTimeout(() => { console.warn('[server] escalation flush timed out'); r(); }, 3000); }),
|
|
2289
|
+
]).catch((e) => console.error('[server] escalation flush failed:', e?.message ?? e));
|
|
2290
|
+
clearTimeout(flushTimer);
|
|
2291
|
+
|
|
2238
2292
|
for (const [ws, session] of activeConnections) {
|
|
2239
2293
|
for (const ac of session.abortControllers.values()) ac.abort();
|
|
2240
2294
|
ws.close();
|