@animalabs/connectome-host 0.3.1 → 0.3.7
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/.env.example +6 -0
- package/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +60 -1
|
@@ -41,6 +41,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
41
41
|
import type { Recipe } from '../recipe.js';
|
|
42
42
|
import type { SessionManager } from '../session-manager.js';
|
|
43
43
|
import type { BranchState } from '../commands.js';
|
|
44
|
+
import type { CallLedger } from '../call-ledger.js';
|
|
44
45
|
import { handleCommand } from '../commands.js';
|
|
45
46
|
import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
|
|
46
47
|
import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
type WelcomeMessageEntry,
|
|
55
56
|
type RequestHistoryMessage,
|
|
56
57
|
type TokenUsage,
|
|
58
|
+
type CallLedgerSnapshot,
|
|
57
59
|
type McplListMessage,
|
|
58
60
|
type LessonsListMessage,
|
|
59
61
|
} from '../web/protocol.js';
|
|
@@ -63,6 +65,16 @@ import {
|
|
|
63
65
|
DEFAULT_CONFIG_PATH,
|
|
64
66
|
} from '../mcpl-config.js';
|
|
65
67
|
import { loadRecipe } from '../recipe.js';
|
|
68
|
+
import {
|
|
69
|
+
ObserverRegistry,
|
|
70
|
+
ObserverSessions,
|
|
71
|
+
sessionTokenFromRequest,
|
|
72
|
+
traceRequiredScope,
|
|
73
|
+
filterEntryForScopes,
|
|
74
|
+
scopeWelcome,
|
|
75
|
+
type ObserverScope,
|
|
76
|
+
type ObserverHelloIdentity,
|
|
77
|
+
} from './web-ui-observers.js';
|
|
66
78
|
|
|
67
79
|
/**
|
|
68
80
|
* Minimal slice of AppContext the module needs. Defined locally to avoid
|
|
@@ -108,15 +120,46 @@ export interface WebUiModuleConfig {
|
|
|
108
120
|
* the entire host is firewalled off from browsers.
|
|
109
121
|
*/
|
|
110
122
|
allowedOrigins?: string[];
|
|
123
|
+
/**
|
|
124
|
+
* Path to the observer grant file (data/observers.json — see connectome
|
|
125
|
+
* docs/observability.md). When set AND the file holds at least one grant,
|
|
126
|
+
* key-authenticated observers may connect: static assets are served
|
|
127
|
+
* without basic auth (the app shell carries no data), /ws upgrades are
|
|
128
|
+
* accepted unauthenticated and must present a signed observer-hello
|
|
129
|
+
* before anything is sent, and every frame is filtered by the grant's
|
|
130
|
+
* scope mask. With no grants the webui behaves exactly as before.
|
|
131
|
+
*/
|
|
132
|
+
observersPath?: string;
|
|
133
|
+
/** Content-free recent provider-call ledger for spend/cache diagnostics. */
|
|
134
|
+
callLedger?: CallLedger;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Data stashed on the Bun WS upgrade. */
|
|
138
|
+
interface WsData {
|
|
139
|
+
id: number;
|
|
140
|
+
/** True when the upgrade carried valid basic auth (full access). */
|
|
141
|
+
authed: boolean;
|
|
142
|
+
/** Host header of the upgrade request — the observer statement binds it. */
|
|
143
|
+
host: string;
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
/** Per-connection state. */
|
|
114
147
|
interface ClientState {
|
|
115
148
|
/** Stable id matching ws.data.id; used for routing fleet IPC responses. */
|
|
116
149
|
id: number;
|
|
117
|
-
ws: ServerWebSocket<
|
|
150
|
+
ws: ServerWebSocket<WsData>;
|
|
118
151
|
/** True after we've sent the welcome message. */
|
|
119
152
|
welcomed: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Authorization state. 'full' = basic-auth (or open loopback) — behavior
|
|
155
|
+
* identical to pre-observer builds, scopes null. 'pending' = upgraded
|
|
156
|
+
* without auth, must observer-hello before any data flows. 'observer' =
|
|
157
|
+
* key-authenticated; `scopes` is the grant's mask.
|
|
158
|
+
*/
|
|
159
|
+
auth: 'full' | 'pending' | 'observer';
|
|
160
|
+
scopes: Set<ObserverScope> | null;
|
|
161
|
+
/** Kills pending connections that never complete the hello. */
|
|
162
|
+
authTimer?: ReturnType<typeof setTimeout>;
|
|
120
163
|
/** Open peek subscriptions for this client, keyed by scope. Each entry
|
|
121
164
|
* carries its detacher so unsubscribe and disconnect both clean up
|
|
122
165
|
* without the framework leaking listeners. */
|
|
@@ -132,6 +175,161 @@ const WELCOME_HISTORY_LIMIT = 200;
|
|
|
132
175
|
const HISTORY_PAGE_DEFAULT = 200;
|
|
133
176
|
const HISTORY_PAGE_MAX = 500;
|
|
134
177
|
|
|
178
|
+
interface CoverageSummary {
|
|
179
|
+
id: string;
|
|
180
|
+
level: number;
|
|
181
|
+
tokens?: number;
|
|
182
|
+
sourceIds?: string[];
|
|
183
|
+
mergedInto?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface CoverageChunk {
|
|
187
|
+
index: number;
|
|
188
|
+
tokens?: number;
|
|
189
|
+
compressed?: boolean;
|
|
190
|
+
summaryId?: string;
|
|
191
|
+
messages?: Array<{ id?: string }>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface CoverageStrategy {
|
|
195
|
+
summaries?: CoverageSummary[];
|
|
196
|
+
chunks?: CoverageChunk[];
|
|
197
|
+
compressionQueue?: number[];
|
|
198
|
+
mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
|
|
199
|
+
resolutions?: Map<string, number>;
|
|
200
|
+
pendingCompression?: Promise<void> | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ContextCoverageSnapshot {
|
|
204
|
+
agent: string;
|
|
205
|
+
branch: string;
|
|
206
|
+
generatedAt: string;
|
|
207
|
+
supported: boolean;
|
|
208
|
+
totals: {
|
|
209
|
+
chunks: number;
|
|
210
|
+
compressedChunks: number;
|
|
211
|
+
coveredMessages: number;
|
|
212
|
+
coveredTokens: number;
|
|
213
|
+
summaries: number;
|
|
214
|
+
};
|
|
215
|
+
levels: Array<{
|
|
216
|
+
level: number;
|
|
217
|
+
summaries: number;
|
|
218
|
+
frontier: number;
|
|
219
|
+
tokens: number;
|
|
220
|
+
coveredChunks: number;
|
|
221
|
+
coveredMessages: number;
|
|
222
|
+
coveredTokens: number;
|
|
223
|
+
}>;
|
|
224
|
+
chunks: Array<{
|
|
225
|
+
index: number;
|
|
226
|
+
messages: number;
|
|
227
|
+
tokens: number;
|
|
228
|
+
compressed: boolean;
|
|
229
|
+
summaryId: string | null;
|
|
230
|
+
maxLevel: number;
|
|
231
|
+
selectedMin: number;
|
|
232
|
+
selectedMax: number;
|
|
233
|
+
queued: boolean;
|
|
234
|
+
}>;
|
|
235
|
+
queue: {
|
|
236
|
+
inFlight: boolean;
|
|
237
|
+
pending: string | null;
|
|
238
|
+
l1: number[];
|
|
239
|
+
merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Build a text-free projection of the autobiographical summary pyramid. */
|
|
244
|
+
export function buildContextCoverageSnapshot(
|
|
245
|
+
agentName: string,
|
|
246
|
+
cm: {
|
|
247
|
+
currentBranch: () => { name: string };
|
|
248
|
+
getStrategy: () => unknown;
|
|
249
|
+
getPendingWork?: () => { description?: string } | null;
|
|
250
|
+
},
|
|
251
|
+
): ContextCoverageSnapshot {
|
|
252
|
+
const strategy = cm.getStrategy() as CoverageStrategy;
|
|
253
|
+
const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
|
|
254
|
+
const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
|
|
255
|
+
const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
|
|
256
|
+
const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
|
|
257
|
+
const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
|
|
258
|
+
const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
|
|
259
|
+
const queuedChunks = new Set(compressionQueue);
|
|
260
|
+
|
|
261
|
+
const projectedChunks = chunks.map((chunk) => {
|
|
262
|
+
let maxLevel = 0;
|
|
263
|
+
let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
while (current && !seen.has(current.id)) {
|
|
266
|
+
seen.add(current.id);
|
|
267
|
+
maxLevel = Math.max(maxLevel, current.level);
|
|
268
|
+
current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const selected = (chunk.messages ?? [])
|
|
272
|
+
.map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
|
|
273
|
+
return {
|
|
274
|
+
index: chunk.index,
|
|
275
|
+
messages: chunk.messages?.length ?? 0,
|
|
276
|
+
tokens: Math.max(0, chunk.tokens ?? 0),
|
|
277
|
+
compressed: chunk.compressed === true,
|
|
278
|
+
summaryId: chunk.summaryId ?? null,
|
|
279
|
+
maxLevel,
|
|
280
|
+
selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
|
|
281
|
+
selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
|
|
282
|
+
queued: queuedChunks.has(chunk.index),
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
|
|
287
|
+
.filter(level => Number.isFinite(level) && level > 0)
|
|
288
|
+
.sort((a, b) => a - b);
|
|
289
|
+
const levels = levelNumbers.map((level) => {
|
|
290
|
+
const atLevel = summaries.filter(summary => summary.level === level);
|
|
291
|
+
const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
|
|
292
|
+
return {
|
|
293
|
+
level,
|
|
294
|
+
summaries: atLevel.length,
|
|
295
|
+
frontier: atLevel.filter(summary => !summary.mergedInto).length,
|
|
296
|
+
tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
|
|
297
|
+
coveredChunks: covered.length,
|
|
298
|
+
coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
|
|
299
|
+
coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
|
|
303
|
+
const pending = cm.getPendingWork?.()?.description ?? null;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
agent: agentName,
|
|
307
|
+
branch: cm.currentBranch().name,
|
|
308
|
+
generatedAt: new Date().toISOString(),
|
|
309
|
+
supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
|
|
310
|
+
totals: {
|
|
311
|
+
chunks: projectedChunks.length,
|
|
312
|
+
compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
|
|
313
|
+
coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
|
|
314
|
+
coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
|
|
315
|
+
summaries: summaries.length,
|
|
316
|
+
},
|
|
317
|
+
levels,
|
|
318
|
+
chunks: projectedChunks,
|
|
319
|
+
queue: {
|
|
320
|
+
inFlight: strategy.pendingCompression != null,
|
|
321
|
+
pending,
|
|
322
|
+
l1: [...compressionQueue],
|
|
323
|
+
merges: mergeQueue.map(merge => ({
|
|
324
|
+
targetLevel: merge.level,
|
|
325
|
+
sourceCount: merge.sourceIds.length,
|
|
326
|
+
firstSource: merge.sourceIds[0] ?? null,
|
|
327
|
+
lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
|
|
328
|
+
})),
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
135
333
|
/**
|
|
136
334
|
* Structural view of the windowed-read facade added to
|
|
137
335
|
* @animalabs/context-manager alongside this protocol version. Typed
|
|
@@ -174,6 +372,10 @@ interface SharedServerState {
|
|
|
174
372
|
allowedOrigins: string[];
|
|
175
373
|
clients: Map<number, ClientState>;
|
|
176
374
|
nextClientId: number;
|
|
375
|
+
/** Observer grant registry (hot-reloaded) — null when not configured. */
|
|
376
|
+
observers: ObserverRegistry | null;
|
|
377
|
+
/** Short-lived HTTP session tokens minted after observer WS auth. */
|
|
378
|
+
observerSessions: ObserverSessions;
|
|
177
379
|
/** Aggregate session usage published to clients: parent's own totals plus
|
|
178
380
|
* the most-recent `usage:updated` totals reported by each fleet child.
|
|
179
381
|
* Recomputed from `parentUsage` + `childUsage` on every relevant event. */
|
|
@@ -191,6 +393,9 @@ interface SharedServerState {
|
|
|
191
393
|
* every usage:updated event so the welcome and live UsageMessage frames
|
|
192
394
|
* carry consistent values. */
|
|
193
395
|
latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
|
|
396
|
+
/** Recent per-call cache diagnostics, updated directly by the adapter. */
|
|
397
|
+
latestCallLedger?: CallLedgerSnapshot;
|
|
398
|
+
callLedgerDetacher: (() => void) | null;
|
|
194
399
|
/** Currently-bound app, refreshed on every setApp() call. WS handlers read
|
|
195
400
|
* from here so the singleton always points at the live framework regardless
|
|
196
401
|
* of which WebUiModule instance is "active". */
|
|
@@ -260,10 +465,14 @@ export class WebUiModule implements Module {
|
|
|
260
465
|
allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
|
|
261
466
|
clients: new Map(),
|
|
262
467
|
nextClientId: 1,
|
|
468
|
+
observers: this.config.observersPath ? new ObserverRegistry(this.config.observersPath) : null,
|
|
469
|
+
observerSessions: new ObserverSessions(),
|
|
263
470
|
latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
264
471
|
parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
265
472
|
childUsage: new Map(),
|
|
266
473
|
latestPerAgentCost: [],
|
|
474
|
+
latestCallLedger: this.config.callLedger?.snapshot(),
|
|
475
|
+
callLedgerDetacher: null,
|
|
267
476
|
pendingFleetRequests: new Map(),
|
|
268
477
|
childRecipeCache: new Map(),
|
|
269
478
|
app: null,
|
|
@@ -271,14 +480,15 @@ export class WebUiModule implements Module {
|
|
|
271
480
|
fleetEventDetacher: null,
|
|
272
481
|
messageListenerDetacher: null,
|
|
273
482
|
};
|
|
483
|
+
state.observers?.start();
|
|
274
484
|
state.server = Bun.serve({
|
|
275
485
|
port,
|
|
276
486
|
hostname: host,
|
|
277
487
|
fetch: (req, server) => this.handleHttp(req, server),
|
|
278
488
|
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<
|
|
489
|
+
open: (ws) => this.onWsOpen(ws as ServerWebSocket<WsData>),
|
|
490
|
+
message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<WsData>, msg),
|
|
491
|
+
close: (ws) => this.onWsClose(ws as ServerWebSocket<WsData>),
|
|
282
492
|
},
|
|
283
493
|
});
|
|
284
494
|
// When port=0 is passed (test setups, ephemeral binds), the OS picks a
|
|
@@ -294,6 +504,19 @@ export class WebUiModule implements Module {
|
|
|
294
504
|
}
|
|
295
505
|
sharedServer = state;
|
|
296
506
|
|
|
507
|
+
// Provider calls include auxiliary compression requests that never emit a
|
|
508
|
+
// framework usage trace, so subscribe at the adapter ledger itself. This
|
|
509
|
+
// keeps the panel live for both conversational and memory-maintenance
|
|
510
|
+
// calls without polling the JSONL log.
|
|
511
|
+
state.callLedgerDetacher = this.config.callLedger?.onUpdate((ledger) => {
|
|
512
|
+
state.latestCallLedger = ledger;
|
|
513
|
+
const msg: WebUiServerMessage = { type: 'call-ledger', ledger };
|
|
514
|
+
for (const client of state.clients.values()) {
|
|
515
|
+
if (!client.welcomed) continue;
|
|
516
|
+
if (client.scopes === null || client.scopes.has('health')) this.send(client, msg);
|
|
517
|
+
}
|
|
518
|
+
}) ?? null;
|
|
519
|
+
|
|
297
520
|
console.log(`[webui] listening on http://${host}:${boundPort}`);
|
|
298
521
|
}
|
|
299
522
|
|
|
@@ -352,7 +575,10 @@ export class WebUiModule implements Module {
|
|
|
352
575
|
const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
|
|
353
576
|
const push: WebUiServerMessage = { type: 'message-appended', entry };
|
|
354
577
|
for (const c of ss.clients.values()) {
|
|
355
|
-
if (c.welcomed)
|
|
578
|
+
if (!c.welcomed) continue;
|
|
579
|
+
if (c.scopes === null) { this.send(c, push); continue; }
|
|
580
|
+
const filtered = filterEntryForScopes(entry, c.scopes);
|
|
581
|
+
if (filtered) this.send(c, { type: 'message-appended', entry: filtered });
|
|
356
582
|
}
|
|
357
583
|
} catch (err) {
|
|
358
584
|
// Never let a viewer-side projection error break the store's
|
|
@@ -577,6 +803,17 @@ export class WebUiModule implements Module {
|
|
|
577
803
|
}
|
|
578
804
|
}
|
|
579
805
|
|
|
806
|
+
// -------------------------------------------------------------------------
|
|
807
|
+
// Observer scope filtering — event-family masks (docs/observability.md §4).
|
|
808
|
+
// Full clients (scopes === null) bypass everything: historical behavior.
|
|
809
|
+
// Pure projections live in web-ui-observers.ts (unit-tested there).
|
|
810
|
+
// -------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
private clientAllowsTrace(client: ClientState, event: { type: string }): boolean {
|
|
813
|
+
if (client.scopes === null) return true;
|
|
814
|
+
return client.scopes.has(traceRequiredScope(event));
|
|
815
|
+
}
|
|
816
|
+
|
|
580
817
|
private fanOutTrace(event: TraceEvent): void {
|
|
581
818
|
// Update cached usage snapshot first so welcomes for late-connecting
|
|
582
819
|
// clients get a current value.
|
|
@@ -615,8 +852,10 @@ export class WebUiModule implements Module {
|
|
|
615
852
|
: null;
|
|
616
853
|
for (const client of sharedServer!.clients.values()) {
|
|
617
854
|
if (!client.welcomed) continue;
|
|
618
|
-
this.send(client, traceMsg);
|
|
619
|
-
if (usageMsg
|
|
855
|
+
if (this.clientAllowsTrace(client, event)) this.send(client, traceMsg);
|
|
856
|
+
if (usageMsg && (client.scopes === null || client.scopes.has('health'))) {
|
|
857
|
+
this.send(client, usageMsg);
|
|
858
|
+
}
|
|
620
859
|
}
|
|
621
860
|
}
|
|
622
861
|
|
|
@@ -733,6 +972,10 @@ export class WebUiModule implements Module {
|
|
|
733
972
|
private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
|
|
734
973
|
const url = new URL(req.url);
|
|
735
974
|
|
|
975
|
+
// Observer feature gate: live only when grants exist. With no grants
|
|
976
|
+
// every path below reduces to the historical basic-auth-only behavior.
|
|
977
|
+
const observersActive = sharedServer!.observers?.active() ?? false;
|
|
978
|
+
|
|
736
979
|
// WebSocket upgrade
|
|
737
980
|
if (url.pathname === '/ws') {
|
|
738
981
|
// Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
|
|
@@ -740,15 +983,61 @@ export class WebUiModule implements Module {
|
|
|
740
983
|
// `new WebSocket(...)` the way they do on fetch, so without an
|
|
741
984
|
// explicit check, any tab the operator opens could connect here.
|
|
742
985
|
if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
|
|
743
|
-
|
|
986
|
+
const basicOk = this.checkAuth(req);
|
|
987
|
+
// Without basic auth the upgrade is allowed only when observer grants
|
|
988
|
+
// exist — and then NOTHING is sent until a signed observer-hello
|
|
989
|
+
// verifies (in-band auth; see onWsOpen/onWsMessage).
|
|
990
|
+
if (!basicOk && !observersActive) return this.unauthorized();
|
|
744
991
|
const id = sharedServer!.nextClientId++;
|
|
745
|
-
const ok = server.upgrade(req, {
|
|
992
|
+
const ok = server.upgrade(req, {
|
|
993
|
+
data: { id, authed: basicOk, host: req.headers.get('host') ?? '' } satisfies WsData,
|
|
994
|
+
});
|
|
746
995
|
if (!ok) return new Response('Upgrade failed', { status: 400 });
|
|
747
996
|
// Bun returns undefined on success; the response is taken over by the upgrade.
|
|
748
997
|
return new Response(null, { status: 101 });
|
|
749
998
|
}
|
|
750
999
|
|
|
751
|
-
|
|
1000
|
+
// HTTP auth: basic auth grants everything (historical behavior); an
|
|
1001
|
+
// observer session cookie (minted after WS key auth) grants by scope;
|
|
1002
|
+
// when observers are active the static app shell is public — it carries
|
|
1003
|
+
// no data, and key-only devices must be able to load the SPA to
|
|
1004
|
+
// authenticate at all.
|
|
1005
|
+
const basicOk = this.checkAuth(req);
|
|
1006
|
+
const sessionScopes = basicOk
|
|
1007
|
+
? null
|
|
1008
|
+
: sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
|
|
1009
|
+
const httpAllowed = (scope: ObserverScope): boolean =>
|
|
1010
|
+
basicOk || (sessionScopes?.has(scope) ?? false);
|
|
1011
|
+
// /auth/basic: deliberate basic-auth challenge point. fetch() never
|
|
1012
|
+
// triggers the browser's native credential prompt, but a top-level
|
|
1013
|
+
// navigation here does — the SPA's "sign in with password" fallback for
|
|
1014
|
+
// devices without an observer grant. Once credentials are cached for
|
|
1015
|
+
// the realm, the WS upgrade carries them and the client is 'full'.
|
|
1016
|
+
if (url.pathname === '/auth/basic') {
|
|
1017
|
+
if (basicOk) return new Response(null, { status: 302, headers: { location: '/' } });
|
|
1018
|
+
return this.unauthorized();
|
|
1019
|
+
}
|
|
1020
|
+
const isStatic = !url.pathname.startsWith('/debug/')
|
|
1021
|
+
&& url.pathname !== '/curve'
|
|
1022
|
+
&& url.pathname !== '/healthz'
|
|
1023
|
+
&& !url.pathname.startsWith('/files/');
|
|
1024
|
+
if (!basicOk && !(observersActive && isStatic) && !sessionScopes) {
|
|
1025
|
+
return this.unauthorized();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Per-route scope gates for observer sessions (basic auth passes all).
|
|
1029
|
+
if ((url.pathname.startsWith('/debug/') || url.pathname === '/curve') && !httpAllowed('debug')) {
|
|
1030
|
+
return this.unauthorized();
|
|
1031
|
+
}
|
|
1032
|
+
if (url.pathname === '/healthz' && !httpAllowed('health')) {
|
|
1033
|
+
return this.unauthorized();
|
|
1034
|
+
}
|
|
1035
|
+
if (url.pathname.startsWith('/files/') && !basicOk) {
|
|
1036
|
+
// Workspace files stay basic-auth-only: mount contents are outside the
|
|
1037
|
+
// observer scope model (they are the agent's working files, not wire
|
|
1038
|
+
// events). Revisit if a 'files' scope is ever warranted.
|
|
1039
|
+
return this.unauthorized();
|
|
1040
|
+
}
|
|
752
1041
|
|
|
753
1042
|
// Debug: the membrane-normalized request that WOULD be emitted if the
|
|
754
1043
|
// agent were activated right now — no inference, no state mutation.
|
|
@@ -763,6 +1052,12 @@ export class WebUiModule implements Module {
|
|
|
763
1052
|
if (url.pathname === '/debug/context/curve') {
|
|
764
1053
|
return this.handleContextCurve(url);
|
|
765
1054
|
}
|
|
1055
|
+
if (url.pathname === '/debug/context/coverage') {
|
|
1056
|
+
return this.handleContextCoverage(url);
|
|
1057
|
+
}
|
|
1058
|
+
if (url.pathname === '/debug/context/maintenance') {
|
|
1059
|
+
return this.handleContextMaintenance();
|
|
1060
|
+
}
|
|
766
1061
|
if (url.pathname === '/curve') {
|
|
767
1062
|
return new Response(CURVE_PAGE_HTML, {
|
|
768
1063
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
@@ -798,6 +1093,39 @@ export class WebUiModule implements Module {
|
|
|
798
1093
|
return this.serveStatic(requested);
|
|
799
1094
|
}
|
|
800
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* Counts-only state and bounded history for periodic context maintenance.
|
|
1098
|
+
* Authentication is enforced by handleHttp before this method is reached.
|
|
1099
|
+
* The framework snapshot deliberately contains no message or summary text.
|
|
1100
|
+
*/
|
|
1101
|
+
private handleContextMaintenance(): Response {
|
|
1102
|
+
const app = sharedServer?.app;
|
|
1103
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1104
|
+
const framework = app.framework as unknown as {
|
|
1105
|
+
getContextMaintenanceSnapshot?: () => Record<string, unknown>;
|
|
1106
|
+
};
|
|
1107
|
+
if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
|
|
1108
|
+
return Response.json(
|
|
1109
|
+
{ error: 'framework lacks context-maintenance diagnostics' },
|
|
1110
|
+
{ status: 501 },
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return Response.json(framework.getContextMaintenanceSnapshot());
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Summary-tree coverage and queued work, with no message or summary text. */
|
|
1117
|
+
private handleContextCoverage(url: URL): Response {
|
|
1118
|
+
const app = sharedServer?.app;
|
|
1119
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1120
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
1121
|
+
const agent = app.framework.getAgent(agentName);
|
|
1122
|
+
if (!agent) {
|
|
1123
|
+
return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
|
|
1124
|
+
}
|
|
1125
|
+
const cm = agent.getContextManager();
|
|
1126
|
+
return Response.json(buildContextCoverageSnapshot(agentName, cm));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
801
1129
|
/**
|
|
802
1130
|
* Debug endpoint: return the membrane-normalized request the framework would
|
|
803
1131
|
* hand to the model if the agent were activated right now. Delegates to
|
|
@@ -1116,16 +1444,60 @@ export class WebUiModule implements Module {
|
|
|
1116
1444
|
// WebSocket lifecycle
|
|
1117
1445
|
// -------------------------------------------------------------------------
|
|
1118
1446
|
|
|
1119
|
-
private onWsOpen(ws: ServerWebSocket<
|
|
1447
|
+
private onWsOpen(ws: ServerWebSocket<WsData>): void {
|
|
1120
1448
|
const id = ws.data.id;
|
|
1121
|
-
const client: ClientState = {
|
|
1449
|
+
const client: ClientState = {
|
|
1450
|
+
id, ws, welcomed: false, peeks: new Map(),
|
|
1451
|
+
auth: ws.data.authed ? 'full' : 'pending',
|
|
1452
|
+
scopes: null,
|
|
1453
|
+
};
|
|
1122
1454
|
sharedServer!.clients.set(id, client);
|
|
1123
1455
|
|
|
1456
|
+
if (client.auth === 'pending') {
|
|
1457
|
+
// In-band observer auth: tell the client what host its statement must
|
|
1458
|
+
// bind, and give it a bounded window to present a verifiable hello.
|
|
1459
|
+
// No data flows on this connection until then.
|
|
1460
|
+
this.send(client, { type: 'observer-auth-required', host: ws.data.host });
|
|
1461
|
+
client.authTimer = setTimeout(() => {
|
|
1462
|
+
if (client.auth === 'pending') ws.close(4401, 'observer auth timeout');
|
|
1463
|
+
}, 15_000);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1124
1467
|
if (sharedServer?.app) void this.sendWelcome(client);
|
|
1125
1468
|
// Else: park until setApp() flushes welcomes.
|
|
1126
1469
|
}
|
|
1127
1470
|
|
|
1128
|
-
private
|
|
1471
|
+
private handleObserverHello(client: ClientState, identity: ObserverHelloIdentity): void {
|
|
1472
|
+
const registry = sharedServer!.observers;
|
|
1473
|
+
const result = registry?.verifyHello(identity, client.ws.data.host) ?? null;
|
|
1474
|
+
if (!result) {
|
|
1475
|
+
this.send(client, { type: 'error', message: 'observer auth failed' });
|
|
1476
|
+
client.ws.close(4401, 'observer auth failed');
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (client.authTimer) clearTimeout(client.authTimer);
|
|
1480
|
+
client.auth = 'observer';
|
|
1481
|
+
client.scopes = result.scopes;
|
|
1482
|
+
this.send(client, {
|
|
1483
|
+
type: 'observer-ack',
|
|
1484
|
+
scopes: [...result.scopes],
|
|
1485
|
+
sessionToken: sharedServer!.observerSessions.mint(result.scopes),
|
|
1486
|
+
label: result.grant.label,
|
|
1487
|
+
});
|
|
1488
|
+
console.error(`[webui-observers] observer connected: ${result.grant.label} scopes=[${[...result.scopes].join(',')}]`);
|
|
1489
|
+
if (sharedServer?.app) void this.sendWelcome(client);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** Client message types a scoped (non-full) observer may send. */
|
|
1493
|
+
private observerMaySend(client: ClientState, type: string): boolean {
|
|
1494
|
+
if (client.auth !== 'observer') return false;
|
|
1495
|
+
if (type === 'ping') return true;
|
|
1496
|
+
if (type === 'request-history') return client.scopes?.has('messages') ?? false;
|
|
1497
|
+
return false; // observers are read-only: no user-message/command/mcpl/fleet
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
private onWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
1129
1501
|
const id = ws.data.id;
|
|
1130
1502
|
const client = sharedServer!.clients.get(id);
|
|
1131
1503
|
if (!client) return;
|
|
@@ -1142,6 +1514,20 @@ export class WebUiModule implements Module {
|
|
|
1142
1514
|
return;
|
|
1143
1515
|
}
|
|
1144
1516
|
|
|
1517
|
+
// Observer auth interlock: a pending connection may ONLY hello; a
|
|
1518
|
+
// key-authenticated observer is read-only within its scopes. Full
|
|
1519
|
+
// (basic-auth) clients skip both gates — historical behavior.
|
|
1520
|
+
if (parsed.type === 'observer-hello') {
|
|
1521
|
+
if (client.auth === 'pending') this.handleObserverHello(client, parsed.identity);
|
|
1522
|
+
// hello on an already-authenticated connection is a no-op
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (client.auth === 'pending') return; // nothing else before auth
|
|
1526
|
+
if (client.auth === 'observer' && !this.observerMaySend(client, parsed.type)) {
|
|
1527
|
+
this.send(client, { type: 'error', message: `forbidden for observer scope (${parsed.type})` });
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1145
1531
|
if (!sharedServer?.app) {
|
|
1146
1532
|
this.send(client, { type: 'error', message: 'host not ready' });
|
|
1147
1533
|
return;
|
|
@@ -1825,10 +2211,11 @@ export class WebUiModule implements Module {
|
|
|
1825
2211
|
}
|
|
1826
2212
|
}
|
|
1827
2213
|
|
|
1828
|
-
private onWsClose(ws: ServerWebSocket<
|
|
2214
|
+
private onWsClose(ws: ServerWebSocket<WsData>): void {
|
|
1829
2215
|
const id = ws.data.id;
|
|
1830
2216
|
const client = sharedServer?.clients.get(id);
|
|
1831
2217
|
if (client) {
|
|
2218
|
+
if (client.authTimer) clearTimeout(client.authTimer);
|
|
1832
2219
|
for (const detach of client.peeks.values()) {
|
|
1833
2220
|
try { detach(); } catch { /* ignore */ }
|
|
1834
2221
|
}
|
|
@@ -1969,9 +2356,12 @@ export class WebUiModule implements Module {
|
|
|
1969
2356
|
|
|
1970
2357
|
private async sendWelcome(client: ClientState): Promise<void> {
|
|
1971
2358
|
if (!sharedServer?.app) return;
|
|
2359
|
+
// Never send data to a connection that hasn't authenticated. Parked
|
|
2360
|
+
// pending connections get their welcome from handleObserverHello.
|
|
2361
|
+
if (client.auth === 'pending') return;
|
|
1972
2362
|
|
|
1973
2363
|
const welcome = await this.buildWelcome();
|
|
1974
|
-
this.send(client, welcome);
|
|
2364
|
+
this.send(client, client.scopes === null ? welcome : scopeWelcome(welcome, client.scopes));
|
|
1975
2365
|
client.welcomed = true;
|
|
1976
2366
|
// Live trace forwarding is driven by the single fan-out listener
|
|
1977
2367
|
// installed in setApp(); membership is implicit in `sharedServer!.clients`.
|
|
@@ -2003,10 +2393,16 @@ export class WebUiModule implements Module {
|
|
|
2003
2393
|
resolveBlobs: false,
|
|
2004
2394
|
alignToBodyGroups: true,
|
|
2005
2395
|
});
|
|
2396
|
+
let entries = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
|
|
2397
|
+
if (client.scopes !== null) {
|
|
2398
|
+
entries = entries
|
|
2399
|
+
.map((e) => filterEntryForScopes(e, client.scopes!))
|
|
2400
|
+
.filter((e): e is WelcomeMessageEntry => e !== null);
|
|
2401
|
+
}
|
|
2006
2402
|
this.send(client, {
|
|
2007
2403
|
type: 'history-page',
|
|
2008
2404
|
corrId: req.corrId,
|
|
2009
|
-
entries
|
|
2405
|
+
entries,
|
|
2010
2406
|
startIndex: win.startIndex,
|
|
2011
2407
|
totalCount: win.totalCount,
|
|
2012
2408
|
});
|
|
@@ -2112,6 +2508,9 @@ export class WebUiModule implements Module {
|
|
|
2112
2508
|
...(sharedServer!.latestPerAgentCost.length > 0
|
|
2113
2509
|
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
2114
2510
|
: {}),
|
|
2511
|
+
...(sharedServer!.latestCallLedger
|
|
2512
|
+
? { callLedger: sharedServer!.latestCallLedger }
|
|
2513
|
+
: {}),
|
|
2115
2514
|
};
|
|
2116
2515
|
}
|
|
2117
2516
|
|