@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.
Files changed (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Observer identity (docs/observability.md M2) — unit tests for the pure
3
+ * layer (verifyHello, scope filters, sessions, file ops) plus an end-to-end
4
+ * WS drill against a real WebUiModule:
5
+ *
6
+ * no grants → everything behaves as pre-observer builds (401s)
7
+ * grant hot-appears → static goes public, unauthenticated WS upgrade OK
8
+ * signed hello → observer-ack with the grant's scopes + session token
9
+ * session cookie → /healthz by scope, /debug still denied without 'debug'
10
+ * wrong key / stale timestamp / wrong host → rejected
11
+ * observer is read-only (user-message → forbidden)
12
+ */
13
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
14
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto';
18
+ import type { ModuleContext } from '@animalabs/agent-framework';
19
+ import {
20
+ WebUiModule,
21
+ __getSharedServerPortForTests,
22
+ __resetSharedServerForTests,
23
+ } from '../src/modules/web-ui-module.js';
24
+ import {
25
+ ObserverRegistry,
26
+ ObserverSessions,
27
+ observerStatement,
28
+ saveObserversFile,
29
+ loadObserversFile,
30
+ filterEntryForScopes,
31
+ traceRequiredScope,
32
+ scopeWelcome,
33
+ type ObserverScope,
34
+ type ObserversFile,
35
+ } from '../src/modules/web-ui-observers.js';
36
+ import type { WelcomeMessage, WelcomeMessageEntry } from '../src/web/protocol.js';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Key helpers — raw ed25519 via node:crypto
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function makeKeypair() {
43
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
44
+ const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer;
45
+ const raw = spki.subarray(spki.length - 32);
46
+ const id = `ed25519:${raw.toString('base64url')}`;
47
+ return { id, privateKey };
48
+ }
49
+
50
+ function helloFor(kp: ReturnType<typeof makeKeypair>, host: string, timestamp = new Date().toISOString()) {
51
+ const proof = cryptoSign(null, Buffer.from(observerStatement(host, timestamp), 'utf8'), kp.privateKey);
52
+ return {
53
+ scheme: 'ed25519' as const,
54
+ id: kp.id,
55
+ proof: proof.toString('base64url'),
56
+ timestamp,
57
+ };
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Unit: registry verification
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('ObserverRegistry.verifyHello', () => {
65
+ const tmp = mkdtempSync(join(tmpdir(), 'observers-unit-'));
66
+ const path = join(tmp, 'observers.json');
67
+ const kp = makeKeypair();
68
+ const HOST = 'agent.example:7342';
69
+
70
+ const file: ObserversFile = {
71
+ observers: [
72
+ { key: kp.id, label: 'test-device', scopes: ['health', 'ops'] },
73
+ { key: 'ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', label: 'expired', scopes: ['health'], expires: '2000-01-01T00:00:00Z' },
74
+ ],
75
+ };
76
+ saveObserversFile(path, file);
77
+ const registry = new ObserverRegistry(path);
78
+ registry.start();
79
+ afterAll(() => { registry.stop(); rmSync(tmp, { recursive: true, force: true }); });
80
+
81
+ test('valid hello → grant + scopes', () => {
82
+ const res = registry.verifyHello(helloFor(kp, HOST), HOST);
83
+ expect(res).not.toBeNull();
84
+ expect(res!.grant.label).toBe('test-device');
85
+ expect([...res!.scopes].sort()).toEqual(['health', 'ops']);
86
+ });
87
+
88
+ test('stale timestamp rejected', () => {
89
+ const old = new Date(Date.now() - 10 * 60_000).toISOString();
90
+ expect(registry.verifyHello(helloFor(kp, HOST, old), HOST)).toBeNull();
91
+ });
92
+
93
+ test('host mismatch rejected (relay-proof)', () => {
94
+ expect(registry.verifyHello(helloFor(kp, 'evil.example:7342'), HOST)).toBeNull();
95
+ });
96
+
97
+ test('unknown key rejected', () => {
98
+ const stranger = makeKeypair();
99
+ expect(registry.verifyHello(helloFor(stranger, HOST), HOST)).toBeNull();
100
+ });
101
+
102
+ test('tampered proof rejected', () => {
103
+ const h = helloFor(kp, HOST);
104
+ const buf = Buffer.from(h.proof, 'base64url');
105
+ buf[0]! ^= 0xff;
106
+ expect(registry.verifyHello({ ...h, proof: buf.toString('base64url') }, HOST)).toBeNull();
107
+ });
108
+
109
+ test('parse-error keeps previous grants (fail-safe)', () => {
110
+ expect(loadObserversFile(path)).not.toBeNull();
111
+ writeFileSync(path, '{not json');
112
+ expect(loadObserversFile(path)).toBeNull();
113
+ // registry still holds the last good state
114
+ expect(registry.verifyHello(helloFor(kp, HOST), HOST)).not.toBeNull();
115
+ saveObserversFile(path, file); // restore
116
+ });
117
+ });
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Unit: scope filters + sessions
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe('scope filters', () => {
124
+ const entry: WelcomeMessageEntry = {
125
+ index: 0,
126
+ participant: 'assistant',
127
+ text: 'hi',
128
+ blocks: [
129
+ { kind: 'thinking', text: 'private' } as never,
130
+ { kind: 'tool_use', id: 't1', name: 'shell', inputJson: '{}' } as never,
131
+ { kind: 'tool_result', toolUseId: 't1', text: 'out' } as never,
132
+ { kind: 'text', text: 'hi' } as never,
133
+ ],
134
+ };
135
+
136
+ test('no messages scope → entry dropped entirely', () => {
137
+ expect(filterEntryForScopes(entry, new Set<ObserverScope>(['health', 'ops']))).toBeNull();
138
+ });
139
+
140
+ test('messages without thinking/tools → those blocks elided', () => {
141
+ const f = filterEntryForScopes(entry, new Set<ObserverScope>(['messages']))!;
142
+ expect(f.blocks.map((b) => b.kind)).toEqual(['text']);
143
+ });
144
+
145
+ test('full interiority scopes → identical entry', () => {
146
+ const f = filterEntryForScopes(entry, new Set<ObserverScope>(['messages', 'thinking', 'tools']));
147
+ expect(f).toEqual(entry);
148
+ });
149
+
150
+ test('trace scope mapping', () => {
151
+ expect(traceRequiredScope({ type: 'ops:alert' })).toBe('ops');
152
+ expect(traceRequiredScope({ type: 'mcpl:server-closed' })).toBe('ops');
153
+ expect(traceRequiredScope({ type: 'usage:updated' })).toBe('health');
154
+ expect(traceRequiredScope({ type: 'inference:tokens', blockType: 'thinking' } as never)).toBe('thinking');
155
+ expect(traceRequiredScope({ type: 'inference:tokens', blockType: 'tool_call' } as never)).toBe('tools');
156
+ expect(traceRequiredScope({ type: 'inference:tokens', blockType: 'text' } as never)).toBe('messages');
157
+ expect(traceRequiredScope({ type: 'tool:started' })).toBe('tools');
158
+ expect(traceRequiredScope({ type: 'inference:completed' })).toBe('messages');
159
+ });
160
+
161
+ test('scopeWelcome without messages empties conversation payload', () => {
162
+ const welcome = {
163
+ type: 'welcome', protocolVersion: 1,
164
+ messages: [entry],
165
+ history: { startIndex: 40, totalCount: 240 },
166
+ localTree: { asOfTs: 1, nodes: [{ secret: true }], callIdIndex: { a: 'b' } },
167
+ childTrees: [{ name: 'c', asOfTs: 1, nodes: [], callIdIndex: {} }],
168
+ usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
169
+ } as unknown as WelcomeMessage;
170
+ const w = scopeWelcome(welcome, new Set<ObserverScope>(['health', 'ops']));
171
+ expect(w.messages).toEqual([]);
172
+ expect(w.localTree.nodes).toEqual([]);
173
+ expect(w.childTrees).toEqual([]);
174
+ expect(w.history.startIndex).toBe(240);
175
+ expect(w.usage.input).toBe(1); // structure/usage survive
176
+ });
177
+
178
+ // Health-gated telemetry must be masked exactly like its live frames:
179
+ // `usage` and `call-ledger` pushes require the 'health' scope, so the
180
+ // welcome cannot hand the same data to a messages-only observer.
181
+ test('scopeWelcome without health strips callLedger/perAgentCost and zeroes usage', () => {
182
+ const welcome = {
183
+ type: 'welcome', protocolVersion: 1,
184
+ messages: [entry],
185
+ history: { startIndex: 40, totalCount: 240 },
186
+ localTree: { asOfTs: 1, nodes: [], callIdIndex: {} },
187
+ childTrees: [],
188
+ usage: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, cost: { total: 5, currency: 'USD' } },
189
+ perAgentCost: [{ name: 'main', usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }],
190
+ callLedger: {
191
+ calls: [{ model: 'secret-model', error: 'raw provider error', costUsd: 0.42 }],
192
+ },
193
+ } as unknown as WelcomeMessage;
194
+
195
+ const w = scopeWelcome(welcome, new Set<ObserverScope>(['messages']));
196
+ expect(w.callLedger).toBeUndefined();
197
+ expect(w.perAgentCost).toBeUndefined();
198
+ expect(w.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
199
+ expect(w.messages.length).toBe(1); // messages scope still gets content
200
+ // No trace of the ledger anywhere in the serialized frame.
201
+ expect(JSON.stringify(w)).not.toContain('secret-model');
202
+ expect(JSON.stringify(w)).not.toContain('raw provider error');
203
+
204
+ // Same masking on the no-messages branch (e.g. ops-only observer).
205
+ const opsOnly = scopeWelcome(welcome, new Set<ObserverScope>(['ops']));
206
+ expect(opsOnly.callLedger).toBeUndefined();
207
+ expect(opsOnly.perAgentCost).toBeUndefined();
208
+ expect(opsOnly.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
209
+
210
+ // A health-scoped observer keeps the telemetry verbatim.
211
+ const h = scopeWelcome(welcome, new Set<ObserverScope>(['health']));
212
+ expect(h.callLedger).toEqual(welcome.callLedger);
213
+ expect(h.perAgentCost).toEqual(welcome.perAgentCost);
214
+ expect(h.usage).toEqual(welcome.usage);
215
+
216
+ // Input welcome is never mutated (shared across clients in sendWelcome).
217
+ expect(welcome.callLedger).toBeDefined();
218
+ expect(welcome.usage.input).toBe(1);
219
+ });
220
+ });
221
+
222
+ describe('ObserverSessions', () => {
223
+ test('mint/lookup round-trip; bad token null', () => {
224
+ const s = new ObserverSessions();
225
+ const scopes = new Set<ObserverScope>(['health']);
226
+ const token = s.mint(scopes);
227
+ expect(token).toMatch(/^[a-f0-9]{64}$/);
228
+ expect(s.lookup(token)).toBe(scopes);
229
+ expect(s.lookup('0'.repeat(64))).toBeNull();
230
+ expect(s.lookup(null)).toBeNull();
231
+ });
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // End-to-end over a live module: empty grants → historical behavior; grant
236
+ // hot-appears → observer flow works.
237
+ // ---------------------------------------------------------------------------
238
+
239
+ const BASIC = `Basic ${Buffer.from('admin:pw').toString('base64')}`;
240
+
241
+ describe('WebUiModule observer flow (e2e)', () => {
242
+ let tmp: string;
243
+ let observersPath: string;
244
+ let port: number;
245
+ let mod: WebUiModule;
246
+ const kp = makeKeypair();
247
+
248
+ beforeAll(async () => {
249
+ tmp = mkdtempSync(join(tmpdir(), 'webui-observers-'));
250
+ const staticRoot = join(tmp, 'web');
251
+ mkdirSync(staticRoot, { recursive: true });
252
+ writeFileSync(join(staticRoot, 'index.html'), '<!doctype html><title>t</title>');
253
+ observersPath = join(tmp, 'observers.json');
254
+ saveObserversFile(observersPath, { observers: [] }); // present but EMPTY
255
+
256
+ mod = new WebUiModule({
257
+ port: 0,
258
+ host: '127.0.0.1',
259
+ basicAuth: { username: 'admin', password: 'pw' },
260
+ staticDir: staticRoot,
261
+ observersPath,
262
+ });
263
+ await mod.start({} as ModuleContext);
264
+ port = __getSharedServerPortForTests()!;
265
+ });
266
+
267
+ afterAll(async () => {
268
+ await mod.stop();
269
+ await __resetSharedServerForTests();
270
+ rmSync(tmp, { recursive: true, force: true });
271
+ });
272
+
273
+ const base = () => `http://127.0.0.1:${port}`;
274
+
275
+ test('no grants: static requires basic auth, unauthenticated WS upgrade 401', async () => {
276
+ expect((await fetch(`${base()}/`)).status).toBe(401);
277
+ const res = await fetch(`${base()}/ws`, {
278
+ headers: { upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-version': '13', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==' },
279
+ });
280
+ expect(res.status).toBe(401);
281
+ });
282
+
283
+ test('grant appears (hot-reload): static public, observer WS flow end-to-end', async () => {
284
+ saveObserversFile(observersPath, {
285
+ observers: [{ key: kp.id, label: 'e2e-device', scopes: ['health', 'ops'] }],
286
+ });
287
+ await new Promise((r) => setTimeout(r, 3600)); // registry poll is 3s
288
+
289
+ // Static app shell now public (carries no data).
290
+ expect((await fetch(`${base()}/`)).status).toBe(200);
291
+ // But data routes still gated.
292
+ expect((await fetch(`${base()}/healthz`)).status).toBe(401);
293
+
294
+ // Unauthenticated WS: auth-required → signed hello → ack.
295
+ const host = `127.0.0.1:${port}`;
296
+ const ws = new WebSocket(`ws://${host}/ws`);
297
+ const frames: Record<string, unknown>[] = [];
298
+ const got = (type: string) =>
299
+ new Promise<Record<string, unknown>>((resolvePromise, reject) => {
300
+ const t = setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), 5000);
301
+ const check = () => {
302
+ const f = frames.find((m) => m.type === type);
303
+ if (f) { clearTimeout(t); resolvePromise(f); return true; }
304
+ return false;
305
+ };
306
+ if (check()) return;
307
+ ws.addEventListener('message', () => { check(); });
308
+ });
309
+ ws.addEventListener('message', (ev) => frames.push(JSON.parse(String(ev.data))));
310
+
311
+ const authReq = await got('observer-auth-required');
312
+ expect(authReq.host).toBe(host);
313
+
314
+ ws.send(JSON.stringify({ type: 'observer-hello', identity: helloFor(kp, host) }));
315
+ const ack = await got('observer-ack');
316
+ expect(ack.label).toBe('e2e-device');
317
+ expect((ack.scopes as string[]).sort()).toEqual(['health', 'ops']);
318
+
319
+ // Read-only: mutating messages are refused.
320
+ ws.send(JSON.stringify({ type: 'user-message', content: 'hi' }));
321
+ const errFrame = await got('error');
322
+ expect(String(errFrame.message)).toContain('forbidden');
323
+
324
+ // Session cookie: health allowed, debug denied (not in scopes).
325
+ const cookie = `fkm_obs=${ack.sessionToken}`;
326
+ // healthz returns 503 (app not bound in this harness) — auth passed.
327
+ expect((await fetch(`${base()}/healthz`, { headers: { cookie } })).status).toBe(503);
328
+ expect((await fetch(`${base()}/debug/context`, { headers: { cookie } })).status).toBe(401);
329
+ // Basic auth still works for everything.
330
+ expect((await fetch(`${base()}/healthz`, { headers: { authorization: BASIC } })).status).toBe(503);
331
+
332
+ ws.close();
333
+ }, 15_000);
334
+
335
+ test('wrong key: hello rejected and socket closed', async () => {
336
+ const stranger = makeKeypair();
337
+ const host = `127.0.0.1:${port}`;
338
+ const ws = new WebSocket(`ws://${host}/ws`);
339
+ const closed = new Promise<number>((r) => ws.addEventListener('close', (ev) => r(ev.code)));
340
+ await new Promise<void>((r) => ws.addEventListener('open', () => r()));
341
+ ws.send(JSON.stringify({ type: 'observer-hello', identity: helloFor(stranger, host) }));
342
+ expect(await closed).toBe(4401);
343
+ }, 10_000);
344
+ });