@animalabs/connectome-host 0.3.1 → 0.3.5
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/.github/workflows/publish.yml +9 -4
- package/package.json +3 -3
- package/src/commands.ts +57 -3
- package/src/index.ts +9 -0
- package/src/modules/fleet-module.ts +4 -2
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/web-ui-module.ts +390 -16
- package/src/modules/web-ui-observers.ts +309 -0
- package/src/web/protocol.ts +52 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +301 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +15 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +68 -0
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +57 -1
|
@@ -63,6 +63,16 @@ import {
|
|
|
63
63
|
DEFAULT_CONFIG_PATH,
|
|
64
64
|
} from '../mcpl-config.js';
|
|
65
65
|
import { loadRecipe } from '../recipe.js';
|
|
66
|
+
import {
|
|
67
|
+
ObserverRegistry,
|
|
68
|
+
ObserverSessions,
|
|
69
|
+
sessionTokenFromRequest,
|
|
70
|
+
traceRequiredScope,
|
|
71
|
+
filterEntryForScopes,
|
|
72
|
+
scopeWelcome,
|
|
73
|
+
type ObserverScope,
|
|
74
|
+
type ObserverHelloIdentity,
|
|
75
|
+
} from './web-ui-observers.js';
|
|
66
76
|
|
|
67
77
|
/**
|
|
68
78
|
* Minimal slice of AppContext the module needs. Defined locally to avoid
|
|
@@ -108,15 +118,44 @@ export interface WebUiModuleConfig {
|
|
|
108
118
|
* the entire host is firewalled off from browsers.
|
|
109
119
|
*/
|
|
110
120
|
allowedOrigins?: string[];
|
|
121
|
+
/**
|
|
122
|
+
* Path to the observer grant file (data/observers.json — see connectome
|
|
123
|
+
* docs/observability.md). When set AND the file holds at least one grant,
|
|
124
|
+
* key-authenticated observers may connect: static assets are served
|
|
125
|
+
* without basic auth (the app shell carries no data), /ws upgrades are
|
|
126
|
+
* accepted unauthenticated and must present a signed observer-hello
|
|
127
|
+
* before anything is sent, and every frame is filtered by the grant's
|
|
128
|
+
* scope mask. With no grants the webui behaves exactly as before.
|
|
129
|
+
*/
|
|
130
|
+
observersPath?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Data stashed on the Bun WS upgrade. */
|
|
134
|
+
interface WsData {
|
|
135
|
+
id: number;
|
|
136
|
+
/** True when the upgrade carried valid basic auth (full access). */
|
|
137
|
+
authed: boolean;
|
|
138
|
+
/** Host header of the upgrade request — the observer statement binds it. */
|
|
139
|
+
host: string;
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
/** Per-connection state. */
|
|
114
143
|
interface ClientState {
|
|
115
144
|
/** Stable id matching ws.data.id; used for routing fleet IPC responses. */
|
|
116
145
|
id: number;
|
|
117
|
-
ws: ServerWebSocket<
|
|
146
|
+
ws: ServerWebSocket<WsData>;
|
|
118
147
|
/** True after we've sent the welcome message. */
|
|
119
148
|
welcomed: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* Authorization state. 'full' = basic-auth (or open loopback) — behavior
|
|
151
|
+
* identical to pre-observer builds, scopes null. 'pending' = upgraded
|
|
152
|
+
* without auth, must observer-hello before any data flows. 'observer' =
|
|
153
|
+
* key-authenticated; `scopes` is the grant's mask.
|
|
154
|
+
*/
|
|
155
|
+
auth: 'full' | 'pending' | 'observer';
|
|
156
|
+
scopes: Set<ObserverScope> | null;
|
|
157
|
+
/** Kills pending connections that never complete the hello. */
|
|
158
|
+
authTimer?: ReturnType<typeof setTimeout>;
|
|
120
159
|
/** Open peek subscriptions for this client, keyed by scope. Each entry
|
|
121
160
|
* carries its detacher so unsubscribe and disconnect both clean up
|
|
122
161
|
* without the framework leaking listeners. */
|
|
@@ -132,6 +171,161 @@ const WELCOME_HISTORY_LIMIT = 200;
|
|
|
132
171
|
const HISTORY_PAGE_DEFAULT = 200;
|
|
133
172
|
const HISTORY_PAGE_MAX = 500;
|
|
134
173
|
|
|
174
|
+
interface CoverageSummary {
|
|
175
|
+
id: string;
|
|
176
|
+
level: number;
|
|
177
|
+
tokens?: number;
|
|
178
|
+
sourceIds?: string[];
|
|
179
|
+
mergedInto?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface CoverageChunk {
|
|
183
|
+
index: number;
|
|
184
|
+
tokens?: number;
|
|
185
|
+
compressed?: boolean;
|
|
186
|
+
summaryId?: string;
|
|
187
|
+
messages?: Array<{ id?: string }>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface CoverageStrategy {
|
|
191
|
+
summaries?: CoverageSummary[];
|
|
192
|
+
chunks?: CoverageChunk[];
|
|
193
|
+
compressionQueue?: number[];
|
|
194
|
+
mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
|
|
195
|
+
resolutions?: Map<string, number>;
|
|
196
|
+
pendingCompression?: Promise<void> | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ContextCoverageSnapshot {
|
|
200
|
+
agent: string;
|
|
201
|
+
branch: string;
|
|
202
|
+
generatedAt: string;
|
|
203
|
+
supported: boolean;
|
|
204
|
+
totals: {
|
|
205
|
+
chunks: number;
|
|
206
|
+
compressedChunks: number;
|
|
207
|
+
coveredMessages: number;
|
|
208
|
+
coveredTokens: number;
|
|
209
|
+
summaries: number;
|
|
210
|
+
};
|
|
211
|
+
levels: Array<{
|
|
212
|
+
level: number;
|
|
213
|
+
summaries: number;
|
|
214
|
+
frontier: number;
|
|
215
|
+
tokens: number;
|
|
216
|
+
coveredChunks: number;
|
|
217
|
+
coveredMessages: number;
|
|
218
|
+
coveredTokens: number;
|
|
219
|
+
}>;
|
|
220
|
+
chunks: Array<{
|
|
221
|
+
index: number;
|
|
222
|
+
messages: number;
|
|
223
|
+
tokens: number;
|
|
224
|
+
compressed: boolean;
|
|
225
|
+
summaryId: string | null;
|
|
226
|
+
maxLevel: number;
|
|
227
|
+
selectedMin: number;
|
|
228
|
+
selectedMax: number;
|
|
229
|
+
queued: boolean;
|
|
230
|
+
}>;
|
|
231
|
+
queue: {
|
|
232
|
+
inFlight: boolean;
|
|
233
|
+
pending: string | null;
|
|
234
|
+
l1: number[];
|
|
235
|
+
merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Build a text-free projection of the autobiographical summary pyramid. */
|
|
240
|
+
export function buildContextCoverageSnapshot(
|
|
241
|
+
agentName: string,
|
|
242
|
+
cm: {
|
|
243
|
+
currentBranch: () => { name: string };
|
|
244
|
+
getStrategy: () => unknown;
|
|
245
|
+
getPendingWork?: () => { description?: string } | null;
|
|
246
|
+
},
|
|
247
|
+
): ContextCoverageSnapshot {
|
|
248
|
+
const strategy = cm.getStrategy() as CoverageStrategy;
|
|
249
|
+
const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
|
|
250
|
+
const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
|
|
251
|
+
const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
|
|
252
|
+
const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
|
|
253
|
+
const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
|
|
254
|
+
const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
|
|
255
|
+
const queuedChunks = new Set(compressionQueue);
|
|
256
|
+
|
|
257
|
+
const projectedChunks = chunks.map((chunk) => {
|
|
258
|
+
let maxLevel = 0;
|
|
259
|
+
let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
|
|
260
|
+
const seen = new Set<string>();
|
|
261
|
+
while (current && !seen.has(current.id)) {
|
|
262
|
+
seen.add(current.id);
|
|
263
|
+
maxLevel = Math.max(maxLevel, current.level);
|
|
264
|
+
current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const selected = (chunk.messages ?? [])
|
|
268
|
+
.map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
|
|
269
|
+
return {
|
|
270
|
+
index: chunk.index,
|
|
271
|
+
messages: chunk.messages?.length ?? 0,
|
|
272
|
+
tokens: Math.max(0, chunk.tokens ?? 0),
|
|
273
|
+
compressed: chunk.compressed === true,
|
|
274
|
+
summaryId: chunk.summaryId ?? null,
|
|
275
|
+
maxLevel,
|
|
276
|
+
selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
|
|
277
|
+
selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
|
|
278
|
+
queued: queuedChunks.has(chunk.index),
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
|
|
283
|
+
.filter(level => Number.isFinite(level) && level > 0)
|
|
284
|
+
.sort((a, b) => a - b);
|
|
285
|
+
const levels = levelNumbers.map((level) => {
|
|
286
|
+
const atLevel = summaries.filter(summary => summary.level === level);
|
|
287
|
+
const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
|
|
288
|
+
return {
|
|
289
|
+
level,
|
|
290
|
+
summaries: atLevel.length,
|
|
291
|
+
frontier: atLevel.filter(summary => !summary.mergedInto).length,
|
|
292
|
+
tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
|
|
293
|
+
coveredChunks: covered.length,
|
|
294
|
+
coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
|
|
295
|
+
coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
|
|
299
|
+
const pending = cm.getPendingWork?.()?.description ?? null;
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
agent: agentName,
|
|
303
|
+
branch: cm.currentBranch().name,
|
|
304
|
+
generatedAt: new Date().toISOString(),
|
|
305
|
+
supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
|
|
306
|
+
totals: {
|
|
307
|
+
chunks: projectedChunks.length,
|
|
308
|
+
compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
|
|
309
|
+
coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
|
|
310
|
+
coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
|
|
311
|
+
summaries: summaries.length,
|
|
312
|
+
},
|
|
313
|
+
levels,
|
|
314
|
+
chunks: projectedChunks,
|
|
315
|
+
queue: {
|
|
316
|
+
inFlight: strategy.pendingCompression != null,
|
|
317
|
+
pending,
|
|
318
|
+
l1: [...compressionQueue],
|
|
319
|
+
merges: mergeQueue.map(merge => ({
|
|
320
|
+
targetLevel: merge.level,
|
|
321
|
+
sourceCount: merge.sourceIds.length,
|
|
322
|
+
firstSource: merge.sourceIds[0] ?? null,
|
|
323
|
+
lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
|
|
324
|
+
})),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
135
329
|
/**
|
|
136
330
|
* Structural view of the windowed-read facade added to
|
|
137
331
|
* @animalabs/context-manager alongside this protocol version. Typed
|
|
@@ -174,6 +368,10 @@ interface SharedServerState {
|
|
|
174
368
|
allowedOrigins: string[];
|
|
175
369
|
clients: Map<number, ClientState>;
|
|
176
370
|
nextClientId: number;
|
|
371
|
+
/** Observer grant registry (hot-reloaded) — null when not configured. */
|
|
372
|
+
observers: ObserverRegistry | null;
|
|
373
|
+
/** Short-lived HTTP session tokens minted after observer WS auth. */
|
|
374
|
+
observerSessions: ObserverSessions;
|
|
177
375
|
/** Aggregate session usage published to clients: parent's own totals plus
|
|
178
376
|
* the most-recent `usage:updated` totals reported by each fleet child.
|
|
179
377
|
* Recomputed from `parentUsage` + `childUsage` on every relevant event. */
|
|
@@ -260,6 +458,8 @@ export class WebUiModule implements Module {
|
|
|
260
458
|
allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
|
|
261
459
|
clients: new Map(),
|
|
262
460
|
nextClientId: 1,
|
|
461
|
+
observers: this.config.observersPath ? new ObserverRegistry(this.config.observersPath) : null,
|
|
462
|
+
observerSessions: new ObserverSessions(),
|
|
263
463
|
latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
264
464
|
parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
265
465
|
childUsage: new Map(),
|
|
@@ -271,14 +471,15 @@ export class WebUiModule implements Module {
|
|
|
271
471
|
fleetEventDetacher: null,
|
|
272
472
|
messageListenerDetacher: null,
|
|
273
473
|
};
|
|
474
|
+
state.observers?.start();
|
|
274
475
|
state.server = Bun.serve({
|
|
275
476
|
port,
|
|
276
477
|
hostname: host,
|
|
277
478
|
fetch: (req, server) => this.handleHttp(req, server),
|
|
278
479
|
websocket: {
|
|
279
|
-
open: (ws) => this.onWsOpen(ws as ServerWebSocket<
|
|
280
|
-
message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<
|
|
281
|
-
close: (ws) => this.onWsClose(ws as ServerWebSocket<
|
|
480
|
+
open: (ws) => this.onWsOpen(ws as ServerWebSocket<WsData>),
|
|
481
|
+
message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<WsData>, msg),
|
|
482
|
+
close: (ws) => this.onWsClose(ws as ServerWebSocket<WsData>),
|
|
282
483
|
},
|
|
283
484
|
});
|
|
284
485
|
// When port=0 is passed (test setups, ephemeral binds), the OS picks a
|
|
@@ -352,7 +553,10 @@ export class WebUiModule implements Module {
|
|
|
352
553
|
const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
|
|
353
554
|
const push: WebUiServerMessage = { type: 'message-appended', entry };
|
|
354
555
|
for (const c of ss.clients.values()) {
|
|
355
|
-
if (c.welcomed)
|
|
556
|
+
if (!c.welcomed) continue;
|
|
557
|
+
if (c.scopes === null) { this.send(c, push); continue; }
|
|
558
|
+
const filtered = filterEntryForScopes(entry, c.scopes);
|
|
559
|
+
if (filtered) this.send(c, { type: 'message-appended', entry: filtered });
|
|
356
560
|
}
|
|
357
561
|
} catch (err) {
|
|
358
562
|
// Never let a viewer-side projection error break the store's
|
|
@@ -577,6 +781,17 @@ export class WebUiModule implements Module {
|
|
|
577
781
|
}
|
|
578
782
|
}
|
|
579
783
|
|
|
784
|
+
// -------------------------------------------------------------------------
|
|
785
|
+
// Observer scope filtering — event-family masks (docs/observability.md §4).
|
|
786
|
+
// Full clients (scopes === null) bypass everything: historical behavior.
|
|
787
|
+
// Pure projections live in web-ui-observers.ts (unit-tested there).
|
|
788
|
+
// -------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
private clientAllowsTrace(client: ClientState, event: { type: string }): boolean {
|
|
791
|
+
if (client.scopes === null) return true;
|
|
792
|
+
return client.scopes.has(traceRequiredScope(event));
|
|
793
|
+
}
|
|
794
|
+
|
|
580
795
|
private fanOutTrace(event: TraceEvent): void {
|
|
581
796
|
// Update cached usage snapshot first so welcomes for late-connecting
|
|
582
797
|
// clients get a current value.
|
|
@@ -615,8 +830,10 @@ export class WebUiModule implements Module {
|
|
|
615
830
|
: null;
|
|
616
831
|
for (const client of sharedServer!.clients.values()) {
|
|
617
832
|
if (!client.welcomed) continue;
|
|
618
|
-
this.send(client, traceMsg);
|
|
619
|
-
if (usageMsg
|
|
833
|
+
if (this.clientAllowsTrace(client, event)) this.send(client, traceMsg);
|
|
834
|
+
if (usageMsg && (client.scopes === null || client.scopes.has('health'))) {
|
|
835
|
+
this.send(client, usageMsg);
|
|
836
|
+
}
|
|
620
837
|
}
|
|
621
838
|
}
|
|
622
839
|
|
|
@@ -733,6 +950,10 @@ export class WebUiModule implements Module {
|
|
|
733
950
|
private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
|
|
734
951
|
const url = new URL(req.url);
|
|
735
952
|
|
|
953
|
+
// Observer feature gate: live only when grants exist. With no grants
|
|
954
|
+
// every path below reduces to the historical basic-auth-only behavior.
|
|
955
|
+
const observersActive = sharedServer!.observers?.active() ?? false;
|
|
956
|
+
|
|
736
957
|
// WebSocket upgrade
|
|
737
958
|
if (url.pathname === '/ws') {
|
|
738
959
|
// Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
|
|
@@ -740,15 +961,61 @@ export class WebUiModule implements Module {
|
|
|
740
961
|
// `new WebSocket(...)` the way they do on fetch, so without an
|
|
741
962
|
// explicit check, any tab the operator opens could connect here.
|
|
742
963
|
if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
|
|
743
|
-
|
|
964
|
+
const basicOk = this.checkAuth(req);
|
|
965
|
+
// Without basic auth the upgrade is allowed only when observer grants
|
|
966
|
+
// exist — and then NOTHING is sent until a signed observer-hello
|
|
967
|
+
// verifies (in-band auth; see onWsOpen/onWsMessage).
|
|
968
|
+
if (!basicOk && !observersActive) return this.unauthorized();
|
|
744
969
|
const id = sharedServer!.nextClientId++;
|
|
745
|
-
const ok = server.upgrade(req, {
|
|
970
|
+
const ok = server.upgrade(req, {
|
|
971
|
+
data: { id, authed: basicOk, host: req.headers.get('host') ?? '' } satisfies WsData,
|
|
972
|
+
});
|
|
746
973
|
if (!ok) return new Response('Upgrade failed', { status: 400 });
|
|
747
974
|
// Bun returns undefined on success; the response is taken over by the upgrade.
|
|
748
975
|
return new Response(null, { status: 101 });
|
|
749
976
|
}
|
|
750
977
|
|
|
751
|
-
|
|
978
|
+
// HTTP auth: basic auth grants everything (historical behavior); an
|
|
979
|
+
// observer session cookie (minted after WS key auth) grants by scope;
|
|
980
|
+
// when observers are active the static app shell is public — it carries
|
|
981
|
+
// no data, and key-only devices must be able to load the SPA to
|
|
982
|
+
// authenticate at all.
|
|
983
|
+
const basicOk = this.checkAuth(req);
|
|
984
|
+
const sessionScopes = basicOk
|
|
985
|
+
? null
|
|
986
|
+
: sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
|
|
987
|
+
const httpAllowed = (scope: ObserverScope): boolean =>
|
|
988
|
+
basicOk || (sessionScopes?.has(scope) ?? false);
|
|
989
|
+
// /auth/basic: deliberate basic-auth challenge point. fetch() never
|
|
990
|
+
// triggers the browser's native credential prompt, but a top-level
|
|
991
|
+
// navigation here does — the SPA's "sign in with password" fallback for
|
|
992
|
+
// devices without an observer grant. Once credentials are cached for
|
|
993
|
+
// the realm, the WS upgrade carries them and the client is 'full'.
|
|
994
|
+
if (url.pathname === '/auth/basic') {
|
|
995
|
+
if (basicOk) return new Response(null, { status: 302, headers: { location: '/' } });
|
|
996
|
+
return this.unauthorized();
|
|
997
|
+
}
|
|
998
|
+
const isStatic = !url.pathname.startsWith('/debug/')
|
|
999
|
+
&& url.pathname !== '/curve'
|
|
1000
|
+
&& url.pathname !== '/healthz'
|
|
1001
|
+
&& !url.pathname.startsWith('/files/');
|
|
1002
|
+
if (!basicOk && !(observersActive && isStatic) && !sessionScopes) {
|
|
1003
|
+
return this.unauthorized();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Per-route scope gates for observer sessions (basic auth passes all).
|
|
1007
|
+
if ((url.pathname.startsWith('/debug/') || url.pathname === '/curve') && !httpAllowed('debug')) {
|
|
1008
|
+
return this.unauthorized();
|
|
1009
|
+
}
|
|
1010
|
+
if (url.pathname === '/healthz' && !httpAllowed('health')) {
|
|
1011
|
+
return this.unauthorized();
|
|
1012
|
+
}
|
|
1013
|
+
if (url.pathname.startsWith('/files/') && !basicOk) {
|
|
1014
|
+
// Workspace files stay basic-auth-only: mount contents are outside the
|
|
1015
|
+
// observer scope model (they are the agent's working files, not wire
|
|
1016
|
+
// events). Revisit if a 'files' scope is ever warranted.
|
|
1017
|
+
return this.unauthorized();
|
|
1018
|
+
}
|
|
752
1019
|
|
|
753
1020
|
// Debug: the membrane-normalized request that WOULD be emitted if the
|
|
754
1021
|
// agent were activated right now — no inference, no state mutation.
|
|
@@ -763,6 +1030,12 @@ export class WebUiModule implements Module {
|
|
|
763
1030
|
if (url.pathname === '/debug/context/curve') {
|
|
764
1031
|
return this.handleContextCurve(url);
|
|
765
1032
|
}
|
|
1033
|
+
if (url.pathname === '/debug/context/coverage') {
|
|
1034
|
+
return this.handleContextCoverage(url);
|
|
1035
|
+
}
|
|
1036
|
+
if (url.pathname === '/debug/context/maintenance') {
|
|
1037
|
+
return this.handleContextMaintenance();
|
|
1038
|
+
}
|
|
766
1039
|
if (url.pathname === '/curve') {
|
|
767
1040
|
return new Response(CURVE_PAGE_HTML, {
|
|
768
1041
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
@@ -798,6 +1071,39 @@ export class WebUiModule implements Module {
|
|
|
798
1071
|
return this.serveStatic(requested);
|
|
799
1072
|
}
|
|
800
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* Counts-only state and bounded history for periodic context maintenance.
|
|
1076
|
+
* Authentication is enforced by handleHttp before this method is reached.
|
|
1077
|
+
* The framework snapshot deliberately contains no message or summary text.
|
|
1078
|
+
*/
|
|
1079
|
+
private handleContextMaintenance(): Response {
|
|
1080
|
+
const app = sharedServer?.app;
|
|
1081
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1082
|
+
const framework = app.framework as unknown as {
|
|
1083
|
+
getContextMaintenanceSnapshot?: () => Record<string, unknown>;
|
|
1084
|
+
};
|
|
1085
|
+
if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
|
|
1086
|
+
return Response.json(
|
|
1087
|
+
{ error: 'framework lacks context-maintenance diagnostics' },
|
|
1088
|
+
{ status: 501 },
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return Response.json(framework.getContextMaintenanceSnapshot());
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Summary-tree coverage and queued work, with no message or summary text. */
|
|
1095
|
+
private handleContextCoverage(url: URL): Response {
|
|
1096
|
+
const app = sharedServer?.app;
|
|
1097
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1098
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
1099
|
+
const agent = app.framework.getAgent(agentName);
|
|
1100
|
+
if (!agent) {
|
|
1101
|
+
return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
|
|
1102
|
+
}
|
|
1103
|
+
const cm = agent.getContextManager();
|
|
1104
|
+
return Response.json(buildContextCoverageSnapshot(agentName, cm));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
801
1107
|
/**
|
|
802
1108
|
* Debug endpoint: return the membrane-normalized request the framework would
|
|
803
1109
|
* hand to the model if the agent were activated right now. Delegates to
|
|
@@ -1116,16 +1422,60 @@ export class WebUiModule implements Module {
|
|
|
1116
1422
|
// WebSocket lifecycle
|
|
1117
1423
|
// -------------------------------------------------------------------------
|
|
1118
1424
|
|
|
1119
|
-
private onWsOpen(ws: ServerWebSocket<
|
|
1425
|
+
private onWsOpen(ws: ServerWebSocket<WsData>): void {
|
|
1120
1426
|
const id = ws.data.id;
|
|
1121
|
-
const client: ClientState = {
|
|
1427
|
+
const client: ClientState = {
|
|
1428
|
+
id, ws, welcomed: false, peeks: new Map(),
|
|
1429
|
+
auth: ws.data.authed ? 'full' : 'pending',
|
|
1430
|
+
scopes: null,
|
|
1431
|
+
};
|
|
1122
1432
|
sharedServer!.clients.set(id, client);
|
|
1123
1433
|
|
|
1434
|
+
if (client.auth === 'pending') {
|
|
1435
|
+
// In-band observer auth: tell the client what host its statement must
|
|
1436
|
+
// bind, and give it a bounded window to present a verifiable hello.
|
|
1437
|
+
// No data flows on this connection until then.
|
|
1438
|
+
this.send(client, { type: 'observer-auth-required', host: ws.data.host });
|
|
1439
|
+
client.authTimer = setTimeout(() => {
|
|
1440
|
+
if (client.auth === 'pending') ws.close(4401, 'observer auth timeout');
|
|
1441
|
+
}, 15_000);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1124
1445
|
if (sharedServer?.app) void this.sendWelcome(client);
|
|
1125
1446
|
// Else: park until setApp() flushes welcomes.
|
|
1126
1447
|
}
|
|
1127
1448
|
|
|
1128
|
-
private
|
|
1449
|
+
private handleObserverHello(client: ClientState, identity: ObserverHelloIdentity): void {
|
|
1450
|
+
const registry = sharedServer!.observers;
|
|
1451
|
+
const result = registry?.verifyHello(identity, client.ws.data.host) ?? null;
|
|
1452
|
+
if (!result) {
|
|
1453
|
+
this.send(client, { type: 'error', message: 'observer auth failed' });
|
|
1454
|
+
client.ws.close(4401, 'observer auth failed');
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (client.authTimer) clearTimeout(client.authTimer);
|
|
1458
|
+
client.auth = 'observer';
|
|
1459
|
+
client.scopes = result.scopes;
|
|
1460
|
+
this.send(client, {
|
|
1461
|
+
type: 'observer-ack',
|
|
1462
|
+
scopes: [...result.scopes],
|
|
1463
|
+
sessionToken: sharedServer!.observerSessions.mint(result.scopes),
|
|
1464
|
+
label: result.grant.label,
|
|
1465
|
+
});
|
|
1466
|
+
console.error(`[webui-observers] observer connected: ${result.grant.label} scopes=[${[...result.scopes].join(',')}]`);
|
|
1467
|
+
if (sharedServer?.app) void this.sendWelcome(client);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Client message types a scoped (non-full) observer may send. */
|
|
1471
|
+
private observerMaySend(client: ClientState, type: string): boolean {
|
|
1472
|
+
if (client.auth !== 'observer') return false;
|
|
1473
|
+
if (type === 'ping') return true;
|
|
1474
|
+
if (type === 'request-history') return client.scopes?.has('messages') ?? false;
|
|
1475
|
+
return false; // observers are read-only: no user-message/command/mcpl/fleet
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private onWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
1129
1479
|
const id = ws.data.id;
|
|
1130
1480
|
const client = sharedServer!.clients.get(id);
|
|
1131
1481
|
if (!client) return;
|
|
@@ -1142,6 +1492,20 @@ export class WebUiModule implements Module {
|
|
|
1142
1492
|
return;
|
|
1143
1493
|
}
|
|
1144
1494
|
|
|
1495
|
+
// Observer auth interlock: a pending connection may ONLY hello; a
|
|
1496
|
+
// key-authenticated observer is read-only within its scopes. Full
|
|
1497
|
+
// (basic-auth) clients skip both gates — historical behavior.
|
|
1498
|
+
if (parsed.type === 'observer-hello') {
|
|
1499
|
+
if (client.auth === 'pending') this.handleObserverHello(client, parsed.identity);
|
|
1500
|
+
// hello on an already-authenticated connection is a no-op
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (client.auth === 'pending') return; // nothing else before auth
|
|
1504
|
+
if (client.auth === 'observer' && !this.observerMaySend(client, parsed.type)) {
|
|
1505
|
+
this.send(client, { type: 'error', message: `forbidden for observer scope (${parsed.type})` });
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1145
1509
|
if (!sharedServer?.app) {
|
|
1146
1510
|
this.send(client, { type: 'error', message: 'host not ready' });
|
|
1147
1511
|
return;
|
|
@@ -1825,10 +2189,11 @@ export class WebUiModule implements Module {
|
|
|
1825
2189
|
}
|
|
1826
2190
|
}
|
|
1827
2191
|
|
|
1828
|
-
private onWsClose(ws: ServerWebSocket<
|
|
2192
|
+
private onWsClose(ws: ServerWebSocket<WsData>): void {
|
|
1829
2193
|
const id = ws.data.id;
|
|
1830
2194
|
const client = sharedServer?.clients.get(id);
|
|
1831
2195
|
if (client) {
|
|
2196
|
+
if (client.authTimer) clearTimeout(client.authTimer);
|
|
1832
2197
|
for (const detach of client.peeks.values()) {
|
|
1833
2198
|
try { detach(); } catch { /* ignore */ }
|
|
1834
2199
|
}
|
|
@@ -1969,9 +2334,12 @@ export class WebUiModule implements Module {
|
|
|
1969
2334
|
|
|
1970
2335
|
private async sendWelcome(client: ClientState): Promise<void> {
|
|
1971
2336
|
if (!sharedServer?.app) return;
|
|
2337
|
+
// Never send data to a connection that hasn't authenticated. Parked
|
|
2338
|
+
// pending connections get their welcome from handleObserverHello.
|
|
2339
|
+
if (client.auth === 'pending') return;
|
|
1972
2340
|
|
|
1973
2341
|
const welcome = await this.buildWelcome();
|
|
1974
|
-
this.send(client, welcome);
|
|
2342
|
+
this.send(client, client.scopes === null ? welcome : scopeWelcome(welcome, client.scopes));
|
|
1975
2343
|
client.welcomed = true;
|
|
1976
2344
|
// Live trace forwarding is driven by the single fan-out listener
|
|
1977
2345
|
// installed in setApp(); membership is implicit in `sharedServer!.clients`.
|
|
@@ -2003,10 +2371,16 @@ export class WebUiModule implements Module {
|
|
|
2003
2371
|
resolveBlobs: false,
|
|
2004
2372
|
alignToBodyGroups: true,
|
|
2005
2373
|
});
|
|
2374
|
+
let entries = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
|
|
2375
|
+
if (client.scopes !== null) {
|
|
2376
|
+
entries = entries
|
|
2377
|
+
.map((e) => filterEntryForScopes(e, client.scopes!))
|
|
2378
|
+
.filter((e): e is WelcomeMessageEntry => e !== null);
|
|
2379
|
+
}
|
|
2006
2380
|
this.send(client, {
|
|
2007
2381
|
type: 'history-page',
|
|
2008
2382
|
corrId: req.corrId,
|
|
2009
|
-
entries
|
|
2383
|
+
entries,
|
|
2010
2384
|
startIndex: win.startIndex,
|
|
2011
2385
|
totalCount: win.totalCount,
|
|
2012
2386
|
});
|