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.
- package/README.md +10 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +15 -14
- package/defaults/skills/platform.md +3 -3
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-DwmlZAEF.js +2009 -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 +83 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- 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/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +23 -7
- 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/index.ts +10 -2
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +66 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +161 -104
- package/src/server/claude.ts +102 -26
- package/src/server/data-sync.ts +56 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +78 -17
- package/src/server/engine/types.ts +7 -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 +505 -0
- package/src/server/security/enforce.ts +343 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +337 -0
- package/src/server/security/owner-only.ts +34 -0
- package/src/server/security/owner-routes.ts +285 -0
- package/src/server/security/policy.ts +416 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +226 -0
- package/src/server/sessions.ts +38 -0
- package/src/server/shraga-config.ts +13 -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-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
package/src/server/boot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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, (
|
|
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
|
-
|
|
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 = () =>
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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.
|
|
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
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
|
|
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}`);
|
|
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
|
-
|
|
1923
|
+
console.log(`[ws] Message from ${session.email}: "${promptText.slice(0, 100)}" session=${sid.slice(0, 8)}`);
|
|
1878
1924
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
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();
|