shraga 0.1.112 → 0.1.114

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