@animalabs/connectome-host 0.7.2 → 0.7.4

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 (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Standard-recipe memory defaults (buildFrameworkStrategy).
3
+ *
4
+ * A recipe that omits strategy tuning must get the fleet-standard shape:
5
+ * autobiographical + adaptiveResolution + kv-stable folding + same-model
6
+ * compression + summaries voiced as the agent itself. Explicit recipe values
7
+ * always win. DEFAULT_RECIPE must not enable the opt-in modules
8
+ * (subagents/lessons/retrieval).
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
12
+ import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
13
+
14
+ function recipe(agent: Record<string, unknown> = {}) {
15
+ return validateRecipe({
16
+ name: 'framework-strategy-defaults',
17
+ agent: {
18
+ systemPrompt: 'sys',
19
+ ...agent,
20
+ },
21
+ });
22
+ }
23
+
24
+ function configView(strategy: object): Record<string, unknown> {
25
+ return (strategy as { config?: Record<string, unknown> }).config ?? {};
26
+ }
27
+
28
+ describe('standard-recipe memory defaults', () => {
29
+ test('omitted strategy gets kv-stable folding, same-model compression, and agent-voiced summaries', () => {
30
+ const strategy = buildFrameworkStrategy(
31
+ recipe({ name: 'Mira' }),
32
+ 'some-model',
33
+ 'America/Los_Angeles',
34
+ );
35
+ const config = configView(strategy);
36
+ expect(config.adaptiveResolution).toBe(true);
37
+ expect(config.foldingStrategy).toBe('kv-stable');
38
+ expect(config.compressionModel).toBe('some-model');
39
+ expect(config.summaryParticipant).toBe('Mira');
40
+ });
41
+
42
+ test('frontdesk gets adaptive + kv-stable too (2026-08-03 clerk outage: hierarchical saturates)', () => {
43
+ const strategy = buildFrameworkStrategy(
44
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk' } }),
45
+ 'some-model',
46
+ 'Europe/Kyiv',
47
+ );
48
+ const config = configView(strategy);
49
+ expect(config.adaptiveResolution).toBe(true);
50
+ expect(config.foldingStrategy).toBe('kv-stable');
51
+ });
52
+
53
+ test('frontdesk adaptiveResolution: false is the hierarchical rollback lever', () => {
54
+ const strategy = buildFrameworkStrategy(
55
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk', adaptiveResolution: false } }),
56
+ 'some-model',
57
+ 'Europe/Kyiv',
58
+ );
59
+ const config = configView(strategy);
60
+ expect(config.adaptiveResolution).toBe(false);
61
+ expect(config.foldingStrategy).toBeUndefined();
62
+ });
63
+
64
+ test('explicit recipe values override the defaults', () => {
65
+ const strategy = buildFrameworkStrategy(
66
+ recipe({
67
+ name: 'Mira',
68
+ strategy: {
69
+ type: 'autobiographical',
70
+ foldingStrategy: 'flat-profile',
71
+ compressionModel: 'pinned-model',
72
+ summaryParticipant: 'Someone Else',
73
+ },
74
+ }),
75
+ 'some-model',
76
+ 'America/Los_Angeles',
77
+ );
78
+ const config = configView(strategy);
79
+ expect(config.foldingStrategy).toBe('flat-profile');
80
+ expect(config.compressionModel).toBe('pinned-model');
81
+ expect(config.summaryParticipant).toBe('Someone Else');
82
+ });
83
+
84
+ test('adaptiveResolution opt-out leaves foldingStrategy unset', () => {
85
+ const strategy = buildFrameworkStrategy(
86
+ recipe({ strategy: { type: 'autobiographical', adaptiveResolution: false } }),
87
+ 'some-model',
88
+ 'America/Los_Angeles',
89
+ );
90
+ const config = configView(strategy);
91
+ expect(config.adaptiveResolution).toBe(false);
92
+ expect(config.foldingStrategy).toBeUndefined();
93
+ });
94
+
95
+ test("without an agent name the summary voice falls back to the library's 'Claude' default", () => {
96
+ const strategy = buildFrameworkStrategy(
97
+ recipe(),
98
+ 'some-model',
99
+ 'America/Los_Angeles',
100
+ );
101
+ expect(configView(strategy).summaryParticipant).toBe('Claude');
102
+ });
103
+
104
+ test('DEFAULT_RECIPE does not enable the opt-in modules', () => {
105
+ const modules = DEFAULT_RECIPE.modules ?? {};
106
+ expect(modules).not.toHaveProperty('subagents');
107
+ expect(modules).not.toHaveProperty('lessons');
108
+ expect(modules).not.toHaveProperty('retrieval');
109
+ });
110
+ });
@@ -2,7 +2,6 @@ import { describe, test, expect } from 'bun:test';
2
2
  import type {
3
3
  MessageStoreView,
4
4
  StoredMessage,
5
- SummaryEntry,
6
5
  ContextEntry,
7
6
  } from '@animalabs/context-manager';
8
7
  import { FrontdeskStrategy } from '../src/strategies/frontdesk-strategy.js';
@@ -54,12 +53,12 @@ class TestFrontdesk extends FrontdeskStrategy {
54
53
  public pub_updateSalience(store: MessageStoreView) {
55
54
  this.updateSalience(store);
56
55
  }
57
- public pub_selectL1(l1: SummaryEntry[], budget: number, maxTokens: number) {
58
- return this.selectL1Summaries(l1, budget, maxTokens);
59
- }
60
56
  public pub_isTopicBoundary(a: StoredMessage, b: StoredMessage) {
61
57
  return this.isTopicBoundary(a, b);
62
58
  }
59
+ public pub_chunkBoundaryHint(a: StoredMessage, b: StoredMessage) {
60
+ return this.chunkBoundaryHint(a, b);
61
+ }
63
62
  public pub_compressionInstruction(chunkMessages: StoredMessage[], target: number) {
64
63
  // Build a minimal Chunk shape sufficient for getCompressionInstruction
65
64
  const chunk = {
@@ -293,44 +292,33 @@ describe('compression instruction', () => {
293
292
  });
294
293
  });
295
294
 
296
- describe('salience-biased L1 selection', () => {
297
- function summary(id: string, tokens: number, sourceIds: string[]): SummaryEntry {
298
- return {
299
- id,
300
- level: 1,
301
- content: '',
302
- tokens,
303
- sourceLevel: 0,
304
- sourceIds,
305
- sourceRange: { first: sourceIds[0] ?? '', last: sourceIds[sourceIds.length - 1] ?? '' },
306
- created: Date.now(),
307
- };
308
- }
295
+ describe('chunk boundary hint (topic-aware chunking via the base seam)', () => {
296
+ // The chunking mechanics record persistence, minimum-size, tool-pairing
297
+ // guard — are context-manager's contract, gated by its
298
+ // chunk-boundary-hook tests. What is conhost's to pin is the hint policy:
299
+ // frontdesk hints exactly at topic boundaries.
309
300
 
310
- test('prefers L1 summaries whose sources contain salient messages', () => {
301
+ test('hints a close when adjacent messages change topic on one channel', () => {
311
302
  const s = makeStrategy();
312
- const q = msg('User', 'what is x?', {}); // salient: unanswered
313
- s.pub_updateSalience(makeStore([q]));
314
-
315
- // Two L1 summaries, each 100 tokens; budget fits only one
316
- const routineFirst = summary('L1-0', 100, ['unrelated-1']);
317
- const salientSecond = summary('L1-1', 100, [q.id]);
318
-
319
- const { selected } = s.pub_selectL1([routineFirst, salientSecond], 100, 100);
320
- expect(selected).toHaveLength(1);
321
- expect(selected[0].id).toBe('L1-1');
303
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
304
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'deploys' });
305
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(true);
322
306
  });
323
307
 
324
- test('falls back to routine summaries once salient are exhausted', () => {
308
+ test('does not hint within a topic or when topic metadata is absent', () => {
325
309
  const s = makeStrategy();
326
- const q = msg('User', 'what is x?', {});
327
- s.pub_updateSalience(makeStore([q]));
310
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
311
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
312
+ const bare = msg('User', 'z', {});
313
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(false);
314
+ expect(s.pub_chunkBoundaryHint(a, bare)).toBe(false);
315
+ expect(s.pub_chunkBoundaryHint(bare, a)).toBe(false);
316
+ });
328
317
 
329
- const salient = summary('L1-1', 50, [q.id]);
330
- const routine = summary('L1-2', 50, ['unrelated-1']);
331
- const { selected } = s.pub_selectL1([routine, salient], 200, 200);
332
- expect(selected).toHaveLength(2);
333
- expect(selected[0].id).toBe('L1-1'); // salient first
334
- expect(selected[1].id).toBe('L1-2');
318
+ test('hint agrees with isTopicBoundary across channels (same topic name, different channel)', () => {
319
+ const s = makeStrategy();
320
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
321
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:ops', topic: 'retries' });
322
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(s.pub_isTopicBoundary(a, b));
335
323
  });
336
324
  });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Integration test for the 'panel-request' IPC verb against a REAL headless
3
+ * child (full framework, no inference): the same runPanelOp dispatcher the
4
+ * WebUI host uses locally must answer over the socket, so every operator
5
+ * panel (mcpl / settings / pins / health / context debug) works for fleet
6
+ * children exactly as it does for the host.
7
+ *
8
+ * Also verifies panel-response bypasses subscription filtering — a narrowed
9
+ * event stream must never eat a request/response pair.
10
+ */
11
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
12
+ import { spawn, type ChildProcess } from 'node:child_process';
13
+ import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { connect as netConnect, type Socket } from 'node:net';
16
+ import { join, resolve, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = resolve(TEST_DIR, '..');
21
+ const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
22
+
23
+ const MINIMAL_RECIPE = {
24
+ name: 'Panel Test',
25
+ agent: {
26
+ name: 'commander',
27
+ systemPrompt: 'never asked to infer in this test',
28
+ },
29
+ modules: {
30
+ subagents: false,
31
+ lessons: false,
32
+ retrieval: false,
33
+ wake: false,
34
+ workspace: false,
35
+ },
36
+ };
37
+
38
+ function lineReader(socket: Socket): { events: Array<Record<string, unknown>>; stop: () => void } {
39
+ const events: Array<Record<string, unknown>> = [];
40
+ let buf = '';
41
+ const handler = (chunk: Buffer): void => {
42
+ buf += chunk.toString('utf-8');
43
+ let i: number;
44
+ while ((i = buf.indexOf('\n')) >= 0) {
45
+ const line = buf.slice(0, i).trim();
46
+ buf = buf.slice(i + 1);
47
+ if (!line) continue;
48
+ try { events.push(JSON.parse(line) as Record<string, unknown>); } catch { /* ignore malformed */ }
49
+ }
50
+ };
51
+ socket.on('data', handler);
52
+ return { events, stop: (): void => { socket.off('data', handler); } };
53
+ }
54
+
55
+ async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
56
+ const start = Date.now();
57
+ while (Date.now() - start < timeoutMs) {
58
+ if (check()) return;
59
+ await new Promise((r) => setTimeout(r, 50));
60
+ }
61
+ throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
62
+ }
63
+
64
+ async function connectSocket(path: string, timeoutMs = 3_000): Promise<Socket> {
65
+ return new Promise((resolveConn, rejectConn) => {
66
+ const s = netConnect(path);
67
+ const timer = setTimeout(() => {
68
+ s.destroy();
69
+ rejectConn(new Error(`socket connect timeout: ${path}`));
70
+ }, timeoutMs);
71
+ s.once('connect', () => { clearTimeout(timer); resolveConn(s); });
72
+ s.once('error', (err) => { clearTimeout(timer); rejectConn(err); });
73
+ });
74
+ }
75
+
76
+ describe('headless daemon — panel-request / panel-response', () => {
77
+ let tmpDir: string;
78
+ let socketPath: string;
79
+ let child: ChildProcess;
80
+ let sock: Socket;
81
+ let reader: { events: Array<Record<string, unknown>>; stop: () => void };
82
+ let corrSeq = 0;
83
+
84
+ /** Send one panel-request and await its panel-response. */
85
+ async function panel(op: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
86
+ const corrId = `panel-test-${++corrSeq}`;
87
+ sock.write(JSON.stringify({ type: 'panel-request', op, ...(params ? { params } : {}), corrId }) + '\n');
88
+ await waitFor(
89
+ () => reader.events.some((e) => e.type === 'panel-response' && e.corrId === corrId),
90
+ 10_000,
91
+ `panel-response for op=${op}`,
92
+ );
93
+ return reader.events.find((e) => e.type === 'panel-response' && e.corrId === corrId)!;
94
+ }
95
+
96
+ beforeAll(async () => {
97
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-panelreq-'));
98
+ const recipePath = join(tmpDir, 'recipe.json');
99
+ socketPath = join(tmpDir, 'ipc.sock');
100
+ writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
101
+
102
+ child = spawn(
103
+ 'bun',
104
+ [INDEX_PATH, recipePath, '--headless'],
105
+ {
106
+ cwd: tmpDir,
107
+ env: {
108
+ ...process.env,
109
+ ANTHROPIC_API_KEY: 'sk-test-panel',
110
+ DATA_DIR: tmpDir,
111
+ },
112
+ stdio: ['ignore', 'ignore', 'ignore'],
113
+ },
114
+ );
115
+
116
+ await waitFor(() => existsSync(socketPath), 15_000, 'socket file appears');
117
+ sock = await connectSocket(socketPath);
118
+ reader = lineReader(sock);
119
+ await waitFor(
120
+ () => reader.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
121
+ 5_000,
122
+ 'lifecycle:ready',
123
+ );
124
+ });
125
+
126
+ afterAll(async () => {
127
+ try { sock.write(JSON.stringify({ type: 'shutdown' }) + '\n'); } catch { /* noop */ }
128
+ await new Promise((r) => setTimeout(r, 500));
129
+ try { reader.stop(); sock.destroy(); } catch { /* noop */ }
130
+ try { if (child.exitCode === null) child.kill('SIGKILL'); } catch { /* noop */ }
131
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
132
+ });
133
+
134
+ test('op=health returns the healthz assembly', async () => {
135
+ const res = await panel('health');
136
+ expect(res.ok).toBe(true);
137
+ expect(res.op).toBe('health');
138
+ const data = res.data as Record<string, unknown>;
139
+ // Same shape /healthz serves: framework snapshot + the panel-data extras.
140
+ expect(data.agents ?? data.runtimeSettings ?? data.contextComposition).toBeDefined();
141
+ });
142
+
143
+ test('op=settings returns the primary agent runtime settings', async () => {
144
+ const res = await panel('settings');
145
+ expect(res.ok).toBe(true);
146
+ const data = res.data as { agent: string; settings: Record<string, unknown>; hotKeys: string[] };
147
+ expect(data.agent).toBe('commander');
148
+ expect(typeof data.settings).toBe('object');
149
+ expect(Array.isArray(data.hotKeys)).toBe(true);
150
+ });
151
+
152
+ test('op=mcpl returns registry + live server view', async () => {
153
+ const res = await panel('mcpl');
154
+ expect(res.ok).toBe(true);
155
+ const data = res.data as { configPath: string; servers: unknown[]; live: unknown[] };
156
+ expect(typeof data.configPath).toBe('string');
157
+ expect(Array.isArray(data.servers)).toBe(true);
158
+ expect(Array.isArray(data.live)).toBe(true);
159
+ expect(data.live.length).toBe(0); // minimal recipe opts into no MCPLs
160
+ });
161
+
162
+ test('op=pins returns a snapshot with candidates when asked', async () => {
163
+ const res = await panel('pins', { withCandidates: true });
164
+ expect(res.ok).toBe(true);
165
+ const data = res.data as { agent: string; pins: unknown[]; pinsSupported: boolean; candidates?: unknown[] };
166
+ expect(data.agent).toBe('commander');
167
+ expect(Array.isArray(data.pins)).toBe(true);
168
+ expect(typeof data.pinsSupported).toBe('boolean');
169
+ expect(Array.isArray(data.candidates)).toBe(true);
170
+ });
171
+
172
+ test('op=context-coverage names the agent and its branch', async () => {
173
+ const res = await panel('context-coverage');
174
+ expect(res.ok).toBe(true);
175
+ const data = res.data as { agent: string; branch: string; totals: Record<string, number> };
176
+ expect(data.agent).toBe('commander');
177
+ expect(typeof data.branch).toBe('string');
178
+ expect(typeof data.totals.chunks).toBe('number');
179
+ });
180
+
181
+ test('unknown agent comes back ok:false with 404', async () => {
182
+ const res = await panel('context-coverage', { agent: 'nonexistent' });
183
+ expect(res.ok).toBe(false);
184
+ expect(res.status).toBe(404);
185
+ expect(res.error).toContain('nonexistent');
186
+ });
187
+
188
+ test('unknown op comes back ok:false with 400', async () => {
189
+ const res = await panel('no-such-op');
190
+ expect(res.ok).toBe(false);
191
+ expect(res.status).toBe(400);
192
+ expect(res.error).toContain('unknown panel op');
193
+ });
194
+
195
+ test('panel-response bypasses subscription filter', async () => {
196
+ sock.write(JSON.stringify({ type: 'subscribe', events: ['command-output'] }) + '\n');
197
+ await new Promise((r) => setTimeout(r, 100));
198
+ const res = await panel('health');
199
+ expect(res.ok).toBe(true);
200
+ });
201
+ });
@@ -0,0 +1,157 @@
1
+ // Identity module (archipelago-home client) + the tools↔utilities surface
2
+ // flags on mcpl-admin and observers. See docs/home-node.md §4 and the af
3
+ // utils meta-tool (Module.getUtilities).
4
+ //
5
+ // Design under test: the AGENT surface is credential-free (status /
6
+ // accept_invite, no tokens in any result); credentials exist only on the
7
+ // HOST-facing API (accessFor/httpAuthFor) that the MCPL dial provider
8
+ // and HTTP helpers consume outside model context.
9
+ import { describe, it, expect } from 'bun:test';
10
+ import { mkdtempSync, existsSync, readFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
14
+
15
+ import { IdentityModule } from '../src/modules/identity-module.ts';
16
+ import { McplAdminModule } from '../src/modules/mcpl-admin-module.ts';
17
+ import { ObserversModule } from '../src/modules/observers-module.ts';
18
+
19
+ const call = (name: string, input: unknown) => ({ id: 't1', name, input });
20
+
21
+ function fakeHome(routes: Record<string, (body: any) => { status: number; json: unknown }>): typeof fetch {
22
+ return (async (url: string | URL | Request, init?: RequestInit) => {
23
+ const path = new URL(String(url)).pathname;
24
+ const handler = routes[path];
25
+ if (!handler) return new Response('{}', { status: 404 });
26
+ const body = JSON.parse(String(init?.body ?? '{}'));
27
+ const { status, json } = handler(body);
28
+ return new Response(JSON.stringify(json), { status });
29
+ }) as typeof fetch;
30
+ }
31
+
32
+ describe('identity module', () => {
33
+ it('is utilities-only, and the agent surface never mentions or returns credentials', async () => {
34
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
35
+ const mod = new IdentityModule({ keyPath: join(dir, 'identity-key.pem'), home: 'id.test' });
36
+ expect(mod.getTools()).toEqual([]);
37
+ expect(mod.getUtilities().map((u) => u.name)).toEqual(['status', 'accept_invite']);
38
+ // Framing check: no crypto/credential vocabulary in agent-visible text.
39
+ const visible = JSON.stringify(mod.getUtilities()).toLowerCase();
40
+ for (const scary of ['token', 'key', 'sign', 'proof', 'ed25519', 'mint', 'bearer']) {
41
+ expect(visible).not.toContain(scary);
42
+ }
43
+
44
+ const res = await mod.handleToolCall(call('status', {}));
45
+ expect(res.success).toBe(true);
46
+ const data = res.data as { registeredAs: unknown; note: string };
47
+ expect(data.registeredAs).toBe(null);
48
+ expect(data.note).toContain('invitation code');
49
+ expect(JSON.stringify(res.data)).not.toContain('ed25519'); // key exists on disk, not in results
50
+ expect(existsSync(join(dir, 'identity-key.pem'))).toBe(true);
51
+ });
52
+
53
+ it('accept_invite registers, echoes NO credential, and is one-time', async () => {
54
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
55
+ let seen: any = null;
56
+ const mod = new IdentityModule({
57
+ keyPath: join(dir, 'k.pem'),
58
+ home: 'id.test',
59
+ fetchImpl: fakeHome({
60
+ '/enroll': (body) => {
61
+ seen = body;
62
+ return { status: 200, json: { sub: 'agent:ferro@guest', token: 'aid1.SECRET.x' } };
63
+ },
64
+ }),
65
+ });
66
+ const res = await mod.handleToolCall(call('accept_invite', { invite: 'inv_1', name: 'Ferro' }));
67
+ expect(res.success).toBe(true);
68
+ expect((res.data as any).id).toBe('agent:ferro@guest');
69
+ // The home node's response token must NOT reach the agent.
70
+ expect(JSON.stringify(res.data)).not.toContain('aid1.');
71
+
72
+ // Wire-level: the signed statement is the spec's, verifiable by the module's own key.
73
+ const raw = Buffer.from(seen.id.slice('ed25519:'.length), 'base64url');
74
+ const key = createPublicKey({
75
+ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]),
76
+ format: 'der', type: 'spki',
77
+ });
78
+ const statement = `archipelago-enroll|v1|id.test|inv_1|${seen.timestamp}`;
79
+ expect(cryptoVerify(null, Buffer.from(statement), key, Buffer.from(seen.proof, 'base64url'))).toBe(true);
80
+
81
+ const rec = JSON.parse(readFileSync(join(dir, 'k.json'), 'utf8'));
82
+ expect(rec.sub).toBe('agent:ferro@guest');
83
+
84
+ const again = await mod.handleToolCall(call('accept_invite', { invite: 'inv_2', name: 'Ferro2' }));
85
+ expect(again.success).toBe(false);
86
+ expect(again.error).toContain('Already registered');
87
+ });
88
+
89
+ it('host-facing accessFor: requires registration, then exchanges per call', async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
91
+ let mints = 0;
92
+ const mod = new IdentityModule({
93
+ keyPath: join(dir, 'k.pem'),
94
+ home: 'id.test',
95
+ defaultAudience: 'eidoverse',
96
+ fetchImpl: fakeHome({
97
+ '/enroll': () => ({ status: 200, json: { sub: 'agent:a@guest', token: 't0' } }),
98
+ '/token': (body) => body.audience === 'eidoverse'
99
+ ? { status: 200, json: { token: `aid1.fresh.${++mints}` } }
100
+ : { status: 400, json: { error: 'unknown audience' } },
101
+ }),
102
+ });
103
+ await expect(mod.accessFor()).rejects.toThrow(/not registered/);
104
+
105
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
106
+ expect(await mod.accessFor()).toBe('aid1.fresh.1');
107
+ expect(await mod.accessFor('eidoverse')).toBe('aid1.fresh.2'); // fresh per call — dial-time rotation
108
+ expect((await mod.httpAuthFor()).authorization).toBe('Bearer aid1.fresh.3');
109
+ await expect(mod.accessFor('nope')).rejects.toThrow(/unknown audience/);
110
+ expect(mod.isEnrolled()).toBe(true);
111
+ expect(mod.sub()).toBe('agent:a@guest');
112
+ });
113
+ });
114
+
115
+ describe('mcpl-admin access grants', () => {
116
+ it('deploy with `access` requires identity wiring, and stores the NAME not a credential', async () => {
117
+ const dir = mkdtempSync(join(tmpdir(), 'mcpl-'));
118
+ const mod = new McplAdminModule({ overlayPath: join(dir, 'overlay.json') });
119
+ // stub framework so the deploy reaches the access check
120
+ mod.setFramework({
121
+ listMcplServers: () => [],
122
+ connectMcplServer: async () => {},
123
+ restartMcplServer: async () => {},
124
+ disconnectMcplServer: async () => {},
125
+ } as any);
126
+
127
+ // no identity wired → clear bounce
128
+ const refused = await mod.handleToolCall(call('mcpl_deploy', {
129
+ id: 'worlds', url: 'wss://example.test/mcpl', access: 'eidoverse',
130
+ }) as any);
131
+ expect(refused.success).toBe(false);
132
+ expect(refused.error).toContain('identity');
133
+ });
134
+
135
+ it('surface flags: default keeps four first-class tools; utilities parks them', () => {
136
+ const asTools = new McplAdminModule({});
137
+ expect(asTools.getTools().length).toBe(4);
138
+ expect(asTools.getUtilities().length).toBe(0);
139
+
140
+ const asUtils = new McplAdminModule({ surface: 'utilities' });
141
+ expect(asUtils.getTools().length).toBe(0);
142
+ expect(asUtils.getUtilities().map((u) => u.name).sort()).toEqual(
143
+ ['mcpl_deploy', 'mcpl_list', 'mcpl_restart', 'mcpl_unload'],
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('observers surface flag', () => {
149
+ it('same definitions on either surface', () => {
150
+ const dir = mkdtempSync(join(tmpdir(), 'obs-'));
151
+ const asTools = new ObserversModule({ path: join(dir, 'observers.json') });
152
+ const asUtils = new ObserversModule({ path: join(dir, 'observers.json'), surface: 'utilities' });
153
+ expect(asTools.getTools().map((t) => t.name)).toEqual(asUtils.getUtilities().map((u) => u.name));
154
+ expect(asTools.getUtilities().length).toBe(0);
155
+ expect(asUtils.getTools().length).toBe(0);
156
+ });
157
+ });
@@ -15,8 +15,14 @@ import { readAgentOverlay, saveAgentOverlay } from '../src/mcpl-config.js';
15
15
  interface StubServer {
16
16
  id: string;
17
17
  connected: boolean;
18
+ retrying: boolean;
18
19
  toolPrefix: string;
19
20
  toolCount: number;
21
+ policyEstablished: boolean;
22
+ effectiveGrant: string[];
23
+ maskedCapabilities: string[];
24
+ deniedCapabilities: string[];
25
+ allowHostCommands: boolean;
20
26
  command?: string;
21
27
  url?: string;
22
28
  }
@@ -32,8 +38,14 @@ function makeStubFramework() {
32
38
  servers.set(config.id, {
33
39
  id: config.id,
34
40
  connected: true,
41
+ retrying: false,
35
42
  toolPrefix: config.toolPrefix ?? `mcpl--${config.id}`,
36
43
  toolCount: 1,
44
+ policyEstablished: true,
45
+ effectiveGrant: ['channels.incoming'],
46
+ maskedCapabilities: ['channels.streaming'],
47
+ deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
48
+ allowHostCommands: false,
37
49
  command: config.command,
38
50
  url: config.url,
39
51
  });
@@ -49,8 +61,14 @@ function makeStubFramework() {
49
61
  servers.set(id, {
50
62
  id,
51
63
  connected: true,
64
+ retrying: false,
52
65
  toolPrefix: `mcpl--${id}`,
53
66
  toolCount: 1,
67
+ policyEstablished: true,
68
+ effectiveGrant: ['channels.incoming'],
69
+ maskedCapabilities: ['channels.streaming'],
70
+ deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
71
+ allowHostCommands: false,
54
72
  command: config?.command ?? prev?.command,
55
73
  });
56
74
  },
@@ -207,6 +225,11 @@ describe('mcpl_list', () => {
207
225
  const text = String(result.data);
208
226
  expect(text).toContain('discord: CONNECTED');
209
227
  expect(text).toContain('mytool: CONNECTED');
228
+ expect(text).toContain('policy=established');
229
+ expect(text).toContain('grant=[channels.incoming]');
230
+ expect(text).toContain('masked=[channels.streaming]');
231
+ expect(text).toContain('denied=[contextHooks.beforeInference.inject.system]');
232
+ expect(text).toContain('hostCommands=deny');
210
233
  expect(text).toContain('source=agent-overlay');
211
234
  expect(text).toContain('gone: UNLOADED');
212
235
  });
@@ -83,6 +83,20 @@ function simulateFinalInference(text: string, emitIdleAfter: boolean): void {
83
83
  function dispatch(msg: Record<string, unknown>): void {
84
84
  const t = typeof msg.type === 'string' ? msg.type : '';
85
85
  if (t === 'subscribe') return; // accept silently
86
+ if (t === 'panel-request') {
87
+ // Panel-op verbs for FleetModule.requestPanel tests:
88
+ // op 'hang' → never answers (timeout path)
89
+ // op 'fail' → ok:false with a status (error passthrough path)
90
+ // anything else → ok:true echoing the params back
91
+ const op = typeof msg.op === 'string' ? msg.op : '';
92
+ if (op === 'hang') return;
93
+ if (op === 'fail') {
94
+ emit({ type: 'panel-response', corrId: msg.corrId, op, ok: false, error: 'mock failure', status: 418 });
95
+ return;
96
+ }
97
+ emit({ type: 'panel-response', corrId: msg.corrId, op, ok: true, data: { op, echo: msg.params ?? null } });
98
+ return;
99
+ }
86
100
  if (t === 'shutdown') {
87
101
  emit({ type: 'lifecycle', phase: 'exiting', reason: 'shutdown' });
88
102
  setTimeout(() => process.exit(0), 50);