@animalabs/connectome-host 0.7.3 → 0.8.0

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 (97) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +401 -10
  7. package/CONTRIBUTING.md +47 -19
  8. package/HEADLESS-FLEET-PLAN.md +22 -0
  9. package/README.md +39 -1
  10. package/bun.lock +27 -31
  11. package/changelog.d/README.md +28 -0
  12. package/docs/AGENT-ONBOARDING.md +1 -1
  13. package/docs/debug-context-api.md +2 -2
  14. package/docs/retrieval-traces.md +173 -0
  15. package/docs/webui-deployment.md +2 -1
  16. package/package.json +6 -6
  17. package/recipes/SETUP.md +11 -5
  18. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  19. package/recipes/knowledge-miner.json +0 -30
  20. package/recipes/mock-test.json +19 -0
  21. package/recipes/triumvirate.json +6 -1
  22. package/scripts/audit-module-optins.ts +288 -0
  23. package/scripts/release-changelog.ts +210 -21
  24. package/src/cache-keepalive-log.ts +41 -0
  25. package/src/commands.ts +96 -0
  26. package/src/framework-strategy.ts +50 -4
  27. package/src/gate-telemetry.ts +106 -0
  28. package/src/headless.ts +24 -0
  29. package/src/index.ts +179 -64
  30. package/src/mcpl-config.ts +99 -1
  31. package/src/modules/fleet-module.ts +60 -1
  32. package/src/modules/fleet-types.ts +30 -1
  33. package/src/modules/identity-module.ts +310 -2
  34. package/src/modules/instructions-module.ts +265 -0
  35. package/src/modules/mcpl-admin-module.ts +89 -13
  36. package/src/modules/retrieval-module.ts +249 -51
  37. package/src/modules/retrieval-trace-page.ts +254 -0
  38. package/src/modules/retrieval-trace.ts +904 -0
  39. package/src/modules/subagent-module.ts +18 -0
  40. package/src/modules/tts-relay-module.ts +33 -18
  41. package/src/modules/web-ui-module.ts +445 -894
  42. package/src/recipe.ts +787 -29
  43. package/src/retrieval-config.ts +39 -0
  44. package/src/strategies/frontdesk-strategy.ts +34 -125
  45. package/src/tui.ts +325 -54
  46. package/src/web/panel-data.ts +1206 -0
  47. package/src/web/protocol.ts +75 -10
  48. package/src/workspace-mounts.ts +73 -0
  49. package/test/audit-module-optins.test.ts +174 -0
  50. package/test/cache-keepalive-log.test.ts +83 -0
  51. package/test/conversations-recipe.test.ts +142 -0
  52. package/test/fleet-panel-request.test.ts +90 -0
  53. package/test/framework-fkm-composition.test.ts +35 -3
  54. package/test/framework-strategy-defaults.test.ts +41 -0
  55. package/test/frontdesk-strategy.test.ts +25 -37
  56. package/test/gate-telemetry-adapter.test.ts +84 -0
  57. package/test/gate-telemetry.test.ts +91 -0
  58. package/test/headless-panel-request.test.ts +201 -0
  59. package/test/identity-and-surfaces.test.ts +212 -1
  60. package/test/instructions-module.test.ts +258 -0
  61. package/test/mcpl-admin-module.test.ts +64 -0
  62. package/test/mcpl-agent-overlay.test.ts +51 -3
  63. package/test/mcpl-child-env.test.ts +64 -0
  64. package/test/mock-headless-child.ts +14 -0
  65. package/test/nudge-command.test.ts +47 -0
  66. package/test/recipe-cache-keepalive.test.ts +59 -0
  67. package/test/recipe-compression-fallback.test.ts +19 -0
  68. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  69. package/test/recipe-instructions.test.ts +176 -0
  70. package/test/recipe-kv-unified.test.ts +87 -0
  71. package/test/recipe-mcp-source.test.ts +54 -0
  72. package/test/recipe-openai-compatible.test.ts +54 -0
  73. package/test/recipe-path-resolution.test.ts +19 -8
  74. package/test/recipe-provider.test.ts +14 -0
  75. package/test/recipe-save-unresolved.test.ts +244 -0
  76. package/test/recipe-source-only.test.ts +38 -0
  77. package/test/release-changelog.test.ts +202 -0
  78. package/test/retrieval-auth-loopback.test.ts +49 -0
  79. package/test/retrieval-config.test.ts +74 -0
  80. package/test/retrieval-module.test.ts +821 -0
  81. package/test/subagent-prose-routing.test.ts +109 -0
  82. package/test/tui-format.test.ts +106 -0
  83. package/test/web-ui-context-coverage.test.ts +1 -1
  84. package/test/web-ui-module.test.ts +189 -3
  85. package/test/web-ui-observers.test.ts +8 -5
  86. package/test/web-ui-protocol.test.ts +0 -0
  87. package/test/workspace-mounts.test.ts +68 -0
  88. package/web/src/App.tsx +160 -44
  89. package/web/src/Context.tsx +35 -8
  90. package/web/src/ContextDocument.tsx +20 -5
  91. package/web/src/Files.tsx +2 -8
  92. package/web/src/Health.tsx +61 -1
  93. package/web/src/Lessons.tsx +2 -38
  94. package/web/src/Mcpl.tsx +80 -14
  95. package/web/src/Pins.tsx +5 -0
  96. package/web/src/Settings.tsx +5 -0
  97. package/web/vite.config.ts +8 -2
@@ -0,0 +1,258 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import {
6
+ DEFAULT_INSTRUCTIONS_HEADER,
7
+ DEFAULT_INSTRUCTIONS_PATH,
8
+ InstructionsModule,
9
+ type WorkspacePathResolver,
10
+ } from '../src/modules/instructions-module.js';
11
+
12
+ /**
13
+ * Minimal stand-in for WorkspaceModule.resolveAbsolutePath: one mount named
14
+ * `mountName` rooted at `root`, unknown mounts resolve to null (the real
15
+ * module's fail-open contract). Mirrors the real parsePath in resolving a
16
+ * bare mount name to the mount root — the module uses that for its realpath
17
+ * containment check.
18
+ */
19
+ function makeResolver(mountName: string, root: string): WorkspacePathResolver {
20
+ return {
21
+ resolveAbsolutePath(mountPrefixedPath: string): string | null {
22
+ const slashIdx = mountPrefixedPath.indexOf('/');
23
+ const name = slashIdx >= 0 ? mountPrefixedPath.slice(0, slashIdx) : mountPrefixedPath;
24
+ if (name !== mountName) return null;
25
+ const rel = slashIdx >= 0 ? mountPrefixedPath.slice(slashIdx + 1) : '';
26
+ return resolve(root, rel);
27
+ },
28
+ };
29
+ }
30
+
31
+ describe('InstructionsModule', () => {
32
+ let dir: string;
33
+ let warnSpy: ReturnType<typeof spyOn>;
34
+
35
+ beforeEach(() => {
36
+ dir = mkdtempSync(join(tmpdir(), 'instructions-module-'));
37
+ warnSpy = spyOn(console, 'error').mockImplementation(() => {});
38
+ });
39
+
40
+ afterEach(() => {
41
+ warnSpy.mockRestore();
42
+ rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ test('injects file content under the default header at the default path', async () => {
46
+ writeFileSync(join(dir, 'AGENTS.md'), 'Always be excellent.\n');
47
+ const mod = new InstructionsModule();
48
+ mod.setWorkspace(makeResolver('instructions', dir));
49
+
50
+ const injections = await mod.gatherContext('resident');
51
+ expect(injections).toHaveLength(1);
52
+ expect(injections[0].namespace).toBe('instructions');
53
+ expect(injections[0].position).toBe('system');
54
+ expect(injections[0].content).toEqual([{
55
+ type: 'text',
56
+ text: `${DEFAULT_INSTRUCTIONS_HEADER}\n\nAlways be excellent.\n`,
57
+ }]);
58
+ expect(warnSpy).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test('exposes the shared-contract defaults', () => {
62
+ expect(DEFAULT_INSTRUCTIONS_PATH).toBe('instructions/AGENTS.md');
63
+ expect(new InstructionsModule().contextTimeoutMs).toBe(2000);
64
+ });
65
+
66
+ test('honors custom path, header, and position', async () => {
67
+ writeFileSync(join(dir, 'HOUSE-RULES.md'), 'No shouting.');
68
+ const mod = new InstructionsModule({
69
+ path: 'shared/HOUSE-RULES.md',
70
+ header: '## House rules',
71
+ position: 'afterUser',
72
+ });
73
+ mod.setWorkspace(makeResolver('shared', dir));
74
+
75
+ const injections = await mod.gatherContext('ephemeral-worker-1');
76
+ expect(injections).toHaveLength(1);
77
+ expect(injections[0].position).toBe('afterUser');
78
+ expect(injections[0].content).toEqual([{ type: 'text', text: '## House rules\n\nNo shouting.' }]);
79
+ });
80
+
81
+ test('supports position beforeUser', async () => {
82
+ writeFileSync(join(dir, 'AGENTS.md'), 'x');
83
+ const mod = new InstructionsModule({ position: 'beforeUser' });
84
+ mod.setWorkspace(makeResolver('instructions', dir));
85
+ expect((await mod.gatherContext('a'))[0].position).toBe('beforeUser');
86
+ });
87
+
88
+ test('returns the same injection for every agent name', async () => {
89
+ writeFileSync(join(dir, 'AGENTS.md'), 'shared doc');
90
+ const mod = new InstructionsModule();
91
+ mod.setWorkspace(makeResolver('instructions', dir));
92
+
93
+ const forResident = await mod.gatherContext('resident');
94
+ const forEphemeral = await mod.gatherContext('resident_fork_3');
95
+ expect(forEphemeral).toEqual(forResident);
96
+ });
97
+
98
+ test('missing file fails open with a single warning across turns', async () => {
99
+ const mod = new InstructionsModule();
100
+ mod.setWorkspace(makeResolver('instructions', dir));
101
+
102
+ expect(await mod.gatherContext('a')).toEqual([]);
103
+ expect(await mod.gatherContext('a')).toEqual([]);
104
+ expect(warnSpy).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ test('unknown mount fails open with a single warning', async () => {
108
+ const mod = new InstructionsModule({ path: 'nonexistent/AGENTS.md' });
109
+ mod.setWorkspace(makeResolver('instructions', dir));
110
+
111
+ expect(await mod.gatherContext('a')).toEqual([]);
112
+ expect(await mod.gatherContext('a')).toEqual([]);
113
+ expect(warnSpy).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ test('no workspace wired fails open', async () => {
117
+ const mod = new InstructionsModule();
118
+ expect(await mod.gatherContext('a')).toEqual([]);
119
+ expect(warnSpy).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ test('recovers (and injects) once the file appears', async () => {
123
+ const mod = new InstructionsModule();
124
+ mod.setWorkspace(makeResolver('instructions', dir));
125
+
126
+ expect(await mod.gatherContext('a')).toEqual([]);
127
+ writeFileSync(join(dir, 'AGENTS.md'), 'now I exist');
128
+ const injections = await mod.gatherContext('a');
129
+ expect(injections).toHaveLength(1);
130
+ expect((injections[0].content[0] as { text: string }).text).toContain('now I exist');
131
+ });
132
+
133
+ test('caches by (mtime, size) and rereads when the file changes', async () => {
134
+ const file = join(dir, 'AGENTS.md');
135
+ writeFileSync(file, 'version one');
136
+ const mod = new InstructionsModule();
137
+ mod.setWorkspace(makeResolver('instructions', dir));
138
+
139
+ const first = await mod.gatherContext('a');
140
+ const second = await mod.gatherContext('a');
141
+ // Unchanged stat → cache hit → identical array, no reread.
142
+ expect(second).toBe(first);
143
+
144
+ // Content change (different size) → cache miss → new content.
145
+ writeFileSync(file, 'version two, longer');
146
+ const third = await mod.gatherContext('a');
147
+ expect(third).not.toBe(first);
148
+ expect((third[0].content[0] as { text: string }).text).toContain('version two, longer');
149
+
150
+ // mtime bump alone (same size, same content) also invalidates.
151
+ utimesSync(file, new Date(), new Date(Date.now() + 5000));
152
+ const fourth = await mod.gatherContext('a');
153
+ expect(fourth).not.toBe(third);
154
+ expect(fourth).toEqual(third);
155
+ });
156
+
157
+ test('truncates at maxBytes with an explicit marker', async () => {
158
+ writeFileSync(join(dir, 'AGENTS.md'), 'a'.repeat(100));
159
+ const mod = new InstructionsModule({ maxBytes: 10 });
160
+ mod.setWorkspace(makeResolver('instructions', dir));
161
+
162
+ const injections = await mod.gatherContext('a');
163
+ const text = (injections[0].content[0] as { text: string }).text;
164
+ expect(text).toBe(`${DEFAULT_INSTRUCTIONS_HEADER}\n\n${'a'.repeat(10)}\n\n[truncated: first 10 of 100 bytes]`);
165
+ });
166
+
167
+ test('truncation never splits a multibyte character (no U+FFFD)', async () => {
168
+ // '€' is 3 bytes in UTF-8; maxBytes: 4 keeps one full '€' plus the lead
169
+ // byte of the second. The incomplete sequence must be dropped and the
170
+ // marker report the actual kept byte count.
171
+ writeFileSync(join(dir, 'AGENTS.md'), '€€€€');
172
+ const mod = new InstructionsModule({ maxBytes: 4 });
173
+ mod.setWorkspace(makeResolver('instructions', dir));
174
+
175
+ const text = ((await mod.gatherContext('a'))[0].content[0] as { text: string }).text;
176
+ expect(text).toBe(`${DEFAULT_INSTRUCTIONS_HEADER}\n\n€\n\n[truncated: first 3 of 12 bytes]`);
177
+ expect(text).not.toContain('�');
178
+ });
179
+
180
+ test('a multibyte character ending exactly at the cap survives', async () => {
181
+ // '€€' is 6 bytes; maxBytes: 3 keeps exactly one complete '€' — the
182
+ // boundary backup must not chop a sequence that finished at the cap.
183
+ writeFileSync(join(dir, 'AGENTS.md'), '€€');
184
+ const mod = new InstructionsModule({ maxBytes: 3 });
185
+ mod.setWorkspace(makeResolver('instructions', dir));
186
+
187
+ const text = ((await mod.gatherContext('a'))[0].content[0] as { text: string }).text;
188
+ expect(text).toBe(`${DEFAULT_INSTRUCTIONS_HEADER}\n\n€\n\n[truncated: first 3 of 6 bytes]`);
189
+ expect(text).not.toContain('�');
190
+ });
191
+
192
+ test('does not truncate a file exactly at maxBytes', async () => {
193
+ writeFileSync(join(dir, 'AGENTS.md'), 'a'.repeat(10));
194
+ const mod = new InstructionsModule({ maxBytes: 10 });
195
+ mod.setWorkspace(makeResolver('instructions', dir));
196
+
197
+ const text = ((await mod.gatherContext('a'))[0].content[0] as { text: string }).text;
198
+ expect(text).toBe(`${DEFAULT_INSTRUCTIONS_HEADER}\n\n${'a'.repeat(10)}`);
199
+ });
200
+
201
+ test('reads at most maxBytes from an oversized file', async () => {
202
+ // Regression for the unbounded-read finding: a large mounted file must
203
+ // never be loaded whole. The observable contract is that only the first
204
+ // maxBytes influence the injection regardless of file size.
205
+ const big = 'begin-' + 'x'.repeat(512 * 1024) + '-end';
206
+ writeFileSync(join(dir, 'AGENTS.md'), big);
207
+ const mod = new InstructionsModule({ maxBytes: 1000 });
208
+ mod.setWorkspace(makeResolver('instructions', dir));
209
+
210
+ const text = ((await mod.gatherContext('a'))[0].content[0] as { text: string }).text;
211
+ expect(text).toBe(
212
+ `${DEFAULT_INSTRUCTIONS_HEADER}\n\n${big.slice(0, 1000)}\n\n[truncated: first 1000 of ${big.length} bytes]`,
213
+ );
214
+ expect(text).not.toContain('-end');
215
+ });
216
+
217
+ test('rejects an in-mount symlink targeting a file outside the mount', async () => {
218
+ // Regression for the symlink-escape finding: resolveAbsolutePath's
219
+ // containment is lexical, so a symlink inside the mount must not import
220
+ // outside content into the trusted instructions block.
221
+ const outside = mkdtempSync(join(tmpdir(), 'instructions-outside-'));
222
+ try {
223
+ writeFileSync(join(outside, 'secret.txt'), 'OUTSIDE_SECRET');
224
+ symlinkSync(join(outside, 'secret.txt'), join(dir, 'AGENTS.md'));
225
+ const mod = new InstructionsModule();
226
+ mod.setWorkspace(makeResolver('instructions', dir));
227
+
228
+ expect(await mod.gatherContext('a')).toEqual([]);
229
+ expect(await mod.gatherContext('a')).toEqual([]);
230
+ expect(warnSpy).toHaveBeenCalledTimes(1);
231
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain('outside its mount');
232
+ } finally {
233
+ rmSync(outside, { recursive: true, force: true });
234
+ }
235
+ });
236
+
237
+ test('a symlink retargeted outside the mount stops injecting (no stale cache)', async () => {
238
+ const outside = mkdtempSync(join(tmpdir(), 'instructions-outside-'));
239
+ try {
240
+ writeFileSync(join(dir, 'real.md'), 'legit content');
241
+ symlinkSync(join(dir, 'real.md'), join(dir, 'AGENTS.md'));
242
+ const mod = new InstructionsModule();
243
+ mod.setWorkspace(makeResolver('instructions', dir));
244
+
245
+ // In-mount symlink is fine — realpath stays under the mount root.
246
+ const before = await mod.gatherContext('a');
247
+ expect((before[0].content[0] as { text: string }).text).toContain('legit content');
248
+
249
+ // Retarget outside: injection must stop, not serve the cached content.
250
+ writeFileSync(join(outside, 'secret.txt'), 'OUTSIDE_SECRET');
251
+ rmSync(join(dir, 'AGENTS.md'));
252
+ symlinkSync(join(outside, 'secret.txt'), join(dir, 'AGENTS.md'));
253
+ expect(await mod.gatherContext('a')).toEqual([]);
254
+ } finally {
255
+ rmSync(outside, { recursive: true, force: true });
256
+ }
257
+ });
258
+ });
@@ -15,8 +15,19 @@ 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;
26
+ manifestState?: {
27
+ lastValidatedRevision: string | null;
28
+ lastFetchedAt: number | null;
29
+ lastNegotiatedAt: number | null;
30
+ };
20
31
  command?: string;
21
32
  url?: string;
22
33
  }
@@ -32,8 +43,19 @@ function makeStubFramework() {
32
43
  servers.set(config.id, {
33
44
  id: config.id,
34
45
  connected: true,
46
+ retrying: false,
35
47
  toolPrefix: config.toolPrefix ?? `mcpl--${config.id}`,
36
48
  toolCount: 1,
49
+ policyEstablished: true,
50
+ effectiveGrant: ['channels.incoming'],
51
+ maskedCapabilities: ['channels.streaming'],
52
+ deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
53
+ allowHostCommands: false,
54
+ manifestState: {
55
+ lastValidatedRevision: 'sha256:validated',
56
+ lastFetchedAt: Date.parse('2026-08-05T01:02:03.000Z'),
57
+ lastNegotiatedAt: Date.parse('2026-08-05T01:02:04.000Z'),
58
+ },
37
59
  command: config.command,
38
60
  url: config.url,
39
61
  });
@@ -49,8 +71,19 @@ function makeStubFramework() {
49
71
  servers.set(id, {
50
72
  id,
51
73
  connected: true,
74
+ retrying: false,
52
75
  toolPrefix: `mcpl--${id}`,
53
76
  toolCount: 1,
77
+ policyEstablished: true,
78
+ effectiveGrant: ['channels.incoming'],
79
+ maskedCapabilities: ['channels.streaming'],
80
+ deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
81
+ allowHostCommands: false,
82
+ manifestState: {
83
+ lastValidatedRevision: 'sha256:validated',
84
+ lastFetchedAt: Date.parse('2026-08-05T01:02:03.000Z'),
85
+ lastNegotiatedAt: Date.parse('2026-08-05T01:02:04.000Z'),
86
+ },
54
87
  command: config?.command ?? prev?.command,
55
88
  });
56
89
  },
@@ -207,7 +240,38 @@ describe('mcpl_list', () => {
207
240
  const text = String(result.data);
208
241
  expect(text).toContain('discord: CONNECTED');
209
242
  expect(text).toContain('mytool: CONNECTED');
243
+ expect(text).toContain('policy=established');
244
+ expect(text).toContain('grant=[channels.incoming]');
245
+ expect(text).toContain('masked=[channels.streaming]');
246
+ expect(text).toContain('denied=[contextHooks.beforeInference.inject.system]');
247
+ expect(text).toContain('hostCommands=deny');
248
+ expect(text).toContain(
249
+ 'manifest={revision="sha256:validated",' +
250
+ 'fetchedAt=2026-08-05T01:02:03.000Z,' +
251
+ 'negotiatedAt=2026-08-05T01:02:04.000Z}',
252
+ );
210
253
  expect(text).toContain('source=agent-overlay');
211
254
  expect(text).toContain('gone: UNLOADED');
212
255
  });
256
+
257
+ test('distinguishes older-framework unknown and bounds untrusted revisions', async () => {
258
+ const { stub, servers } = makeStubFramework();
259
+ await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise<void> })
260
+ .connectMcplServer({ id: 'discord', command: 'node' });
261
+ const mod = makeModule(stub);
262
+
263
+ const server = servers.get('discord')!;
264
+ delete server.manifestState;
265
+ expect(String((await call(mod, 'mcpl_list')).data)).toContain('manifest=unknown');
266
+
267
+ server.manifestState = {
268
+ lastValidatedRevision: `unsafe\n${'x'.repeat(100)}`,
269
+ lastFetchedAt: null,
270
+ lastNegotiatedAt: null,
271
+ };
272
+ const text = String((await call(mod, 'mcpl_list')).data);
273
+ expect(text).toContain('manifest={revision="unsafe\\n');
274
+ expect(text).not.toContain('unsafe\n');
275
+ expect(text).toContain('...",fetchedAt=none,negotiatedAt=none}');
276
+ });
213
277
  });
@@ -13,6 +13,7 @@ import {
13
13
  saveAgentOverlay,
14
14
  applyAgentOverlay,
15
15
  resolveOverlayEntry,
16
+ AGENT_DEPLOY_DENIED_CAPABILITIES,
16
17
  type AgentOverlayEntry,
17
18
  } from '../src/mcpl-config.js';
18
19
 
@@ -109,18 +110,65 @@ describe('applyAgentOverlay', () => {
109
110
  });
110
111
 
111
112
  describe('resolveOverlayEntry', () => {
113
+ const BASELINE = [...AGENT_DEPLOY_DENIED_CAPABILITIES].sort();
114
+
112
115
  test('tombstones and empty entries resolve to null', () => {
113
116
  expect(resolveOverlayEntry('x', { disabled: true }, '/tmp/o.json')).toBeNull();
114
117
  expect(resolveOverlayEntry('x', {}, '/tmp/o.json')).toBeNull();
115
118
  });
116
119
 
117
- test('url entries pass through with transport fields', () => {
120
+ test('url entries pass through with transport fields (plus the baseline capability mask)', () => {
118
121
  const r = resolveOverlayEntry('ws', { url: 'wss://host/mcpl', transport: 'websocket', token: 't' }, '/tmp/o.json');
119
- expect(r).toEqual({ id: 'ws', url: 'wss://host/mcpl', transport: 'websocket', token: 't' });
122
+ expect(r).toEqual({ id: 'ws', url: 'wss://host/mcpl', transport: 'websocket', token: 't', reconnect: true, disabledCapabilities: BASELINE });
120
123
  });
121
124
 
122
125
  test('disabled flag is stripped from resolved config', () => {
123
126
  const r = resolveOverlayEntry('s', { command: 'node', disabled: false }, '/tmp/o.json');
124
- expect(r).toEqual({ id: 's', command: 'node' });
127
+ expect(r).toEqual({ id: 's', command: 'node', disabledCapabilities: BASELINE });
128
+ });
129
+
130
+ // OpenAI-style strict function calling forces every schema property, so
131
+ // agent tool calls arrive with [] where the caller meant "unspecified" —
132
+ // and a PRESENT-empty allowlist is deny-all under the SPEC 0.5 pin (Mica's
133
+ // silently eventless eidoverse, 2026-08-04). [] must carry no intent.
134
+ test('empty allow/deny lists resolve as absent (strict-schema [] is "unspecified", never deny-all)', () => {
135
+ const r = resolveOverlayEntry('e', {
136
+ url: 'wss://host/mcpl',
137
+ enabledFeatureSets: [],
138
+ disabledFeatureSets: [],
139
+ enabledTools: [],
140
+ disabledTools: [],
141
+ }, '/tmp/o.json');
142
+ expect(r).toEqual({ id: 'e', url: 'wss://host/mcpl', reconnect: true, disabledCapabilities: BASELINE });
143
+ });
144
+
145
+ test('non-empty lists survive resolution', () => {
146
+ const r = resolveOverlayEntry('e', { url: 'wss://x/mcpl', enabledFeatureSets: ['eidoverse.*'], enabledTools: ['*'] }, '/tmp/o.json');
147
+ expect(r?.enabledFeatureSets).toEqual(['eidoverse.*']);
148
+ expect(r?.enabledTools).toEqual(['*']);
149
+ });
150
+
151
+ test('self-deployed servers never get consequential capabilities: baseline mask covers context hooks, server-initiated inference, lifecycle', () => {
152
+ expect(BASELINE).toEqual(['contextHooks', 'inferenceLifecycle', 'inferenceRequest']);
153
+ });
154
+
155
+ // A network server the agent deployed should come back when it bounces:
156
+ // reconnect-defaulted-false left Mythos permanently severed from eidoverse
157
+ // by a routine door deploy (2026-08-04) until his own next restart, days out.
158
+ test('websocket entries default reconnect: true; explicit false is respected; stdio keeps no default', () => {
159
+ expect(resolveOverlayEntry('a', { url: 'wss://x/mcpl' }, '/tmp/o.json')?.reconnect).toBe(true);
160
+ expect(resolveOverlayEntry('b', { url: 'wss://x/mcpl', reconnect: false }, '/tmp/o.json')?.reconnect).toBe(false);
161
+ expect(resolveOverlayEntry('c', { command: 'node' }, '/tmp/o.json')?.reconnect).toBeUndefined();
162
+ });
163
+
164
+ test('entry-supplied disabledCapabilities union with the baseline, never replace it', () => {
165
+ const r = resolveOverlayEntry('e', { url: 'wss://x/mcpl', disabledCapabilities: ['channels.streaming'] } as never, '/tmp/o.json');
166
+ expect(r?.disabledCapabilities).toEqual(['channels.streaming', ...BASELINE].sort());
167
+ });
168
+
169
+ test('enabledCapabilities is dropped — the agent overlay narrows, never widens (a hand-written entry could re-grant a §13.4 deny-by-default path)', () => {
170
+ const r = resolveOverlayEntry('e', { url: 'wss://x/mcpl', enabledCapabilities: ['contextHooks.beforeInference.inject.system'] } as never, '/tmp/o.json');
171
+ expect(r).not.toBeNull();
172
+ expect('enabledCapabilities' in (r as object)).toBe(false);
125
173
  });
126
174
  });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Host composition of stdio MCPL child env (composeMcplChildEnv): the
3
+ * framework's refusal-annotation baseline is injected for never-configured
4
+ * deployments, and an operator's explicit value always supersedes it — the
5
+ * host provides a default, never overrides a decision. The enforced
6
+ * precedence below this (file key incl. [] → legacy operator env → baseline)
7
+ * lives in the Discord adapter; what the host owns is that the injected
8
+ * value is EXACTLY the set the framework stamps (REFUSAL_REACTION_BASELINE,
9
+ * comma-joined) and that it rides in env — process plumbing below the model
10
+ * line, never agent-visible text.
11
+ */
12
+ import { describe, test, expect } from 'bun:test';
13
+ import { REFUSAL_REACTION_BASELINE } from '@animalabs/agent-framework';
14
+ import { composeMcplChildEnv } from '../src/mcpl-config.js';
15
+
16
+ const BASELINE = REFUSAL_REACTION_BASELINE.join(',');
17
+
18
+ describe('composeMcplChildEnv', () => {
19
+ test('injects the framework baseline when the server entry does not set it', () => {
20
+ const env = composeMcplChildEnv({ SOME_VAR: 'x' }, 'UTC');
21
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe(BASELINE);
22
+ expect(env.SOME_VAR).toBe('x');
23
+ });
24
+
25
+ test('injected value round-trips to the exact framework annotation set', () => {
26
+ const env = composeMcplChildEnv(undefined, 'UTC');
27
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE!.split(',')).toEqual([
28
+ ...REFUSAL_REACTION_BASELINE,
29
+ ]);
30
+ expect(REFUSAL_REACTION_BASELINE.length).toBeGreaterThan(0);
31
+ });
32
+
33
+ test('operator-set baseline on the server entry supersedes the house value', () => {
34
+ const env = composeMcplChildEnv(
35
+ { DISCORD_SUPPRESSED_REACTIONS_BASELINE: '🈲' },
36
+ 'UTC',
37
+ );
38
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe('🈲');
39
+ });
40
+
41
+ test('operator empty-string baseline is preserved, not re-defaulted', () => {
42
+ // An operator who explicitly set the var to empty chose "no baseline";
43
+ // the house value must not reappear underneath that decision.
44
+ const env = composeMcplChildEnv(
45
+ { DISCORD_SUPPRESSED_REACTIONS_BASELINE: '' },
46
+ 'UTC',
47
+ );
48
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe('');
49
+ });
50
+
51
+ test('AGENT_TIMEZONE stays host-resolved (recipe wall clock, not a per-server knob)', () => {
52
+ const env = composeMcplChildEnv({ AGENT_TIMEZONE: 'Mars/Olympus' }, 'America/Los_Angeles');
53
+ expect(env.AGENT_TIMEZONE).toBe('America/Los_Angeles');
54
+ });
55
+
56
+ test('adds nothing beyond the two host-owned keys', () => {
57
+ const env = composeMcplChildEnv({ A: '1' }, 'UTC');
58
+ expect(Object.keys(env).sort()).toEqual([
59
+ 'A',
60
+ 'AGENT_TIMEZONE',
61
+ 'DISCORD_SUPPRESSED_REACTIONS_BASELINE',
62
+ ]);
63
+ });
64
+ });
@@ -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);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * /nudge — admin-level: run inference on the agent's CURRENT context without
3
+ * adding any message or event. Thin shim over framework.nudgeAgent(); these
4
+ * tests pin the argument pass-through and the operator-facing wording.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type { AgentFramework } from '@animalabs/agent-framework';
8
+ import { handleCommand } from '../src/commands.js';
9
+
10
+ function app(nudgeAgent: AgentFramework['nudgeAgent']): Parameters<typeof handleCommand>[1] {
11
+ return {
12
+ framework: { nudgeAgent } as AgentFramework,
13
+ sessionManager: {} as never,
14
+ recipe: { name: 'test' } as never,
15
+ branchState: {} as never,
16
+ switchSession: async () => {},
17
+ };
18
+ }
19
+
20
+ describe('/nudge', () => {
21
+ test('nudges the default agent and reports immediate run when idle', () => {
22
+ const calls: Array<[string | undefined, string | undefined]> = [];
23
+ const result = handleCommand('/nudge', app((name, by) => {
24
+ calls.push([name, by]);
25
+ return { ok: true, agentName: 'main', agentStatus: 'idle' };
26
+ }));
27
+ expect(calls).toEqual([[undefined, 'host-console']]);
28
+ expect(result.lines[0]?.text).toMatch(/Nudged main/);
29
+ expect(result.lines[0]?.text).toMatch(/no new events/);
30
+ expect(result.lines[0]?.text).toMatch(/running now/);
31
+ });
32
+
33
+ test('passes an explicit agent name and reports queueing when busy', () => {
34
+ const result = handleCommand('/nudge sidekick', app((name) => {
35
+ expect(name).toBe('sidekick');
36
+ return { ok: true, agentName: 'sidekick', agentStatus: 'streaming' };
37
+ }));
38
+ expect(result.lines[0]?.text).toMatch(/queued — runs when current turn settles \(agent is streaming\)/);
39
+ });
40
+
41
+ test('surfaces framework errors', () => {
42
+ const result = handleCommand('/nudge ghost', app(() => (
43
+ { ok: false, error: 'Unknown agent: ghost' }
44
+ )));
45
+ expect(result.lines[0]?.text).toBe('Nudge failed: Unknown agent: ghost');
46
+ });
47
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tests for recipe.agent.cacheKeepalive validation.
3
+ *
4
+ * The load-bearing case is `refreshAfterMinutes >= the cache TTL`: such a
5
+ * keepalive always fires AFTER the entry has expired, so every poke pays a full
6
+ * 2x cache write instead of a 0.1x read — while still looking like a healthy
7
+ * successful call. That must fail at recipe-load time, not on a bill.
8
+ */
9
+ import { describe, test, expect } from 'bun:test';
10
+ import { validateRecipe } from '../src/recipe.js';
11
+
12
+ function recipeWith(cacheKeepalive?: unknown, cacheTtl?: unknown) {
13
+ return {
14
+ name: 'keepalive-test',
15
+ agent: {
16
+ systemPrompt: 'sys',
17
+ ...(cacheTtl !== undefined && { cacheTtl }),
18
+ ...(cacheKeepalive !== undefined && { cacheKeepalive }),
19
+ },
20
+ };
21
+ }
22
+
23
+ describe('recipe agent.cacheKeepalive validation', () => {
24
+ test('omitting it is valid — keepalive is on by default', () => {
25
+ expect(validateRecipe(recipeWith()).agent.cacheKeepalive).toBeUndefined();
26
+ });
27
+
28
+ test('accepts an explicit opt-out', () => {
29
+ const r = validateRecipe(recipeWith({ enabled: false }));
30
+ expect(r.agent.cacheKeepalive?.enabled).toBe(false);
31
+ });
32
+
33
+ test('accepts sane tuning', () => {
34
+ const r = validateRecipe(recipeWith({ maxIdleHours: 12, refreshAfterMinutes: 50 }));
35
+ expect(r.agent.cacheKeepalive?.maxIdleHours).toBe(12);
36
+ expect(r.agent.cacheKeepalive?.refreshAfterMinutes).toBe(50);
37
+ });
38
+
39
+ test('rejects a refresh interval at or past the 1h TTL (every poke would be a cache WRITE)', () => {
40
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 60 }, '1h'))).toThrow(/less than the 1h cache TTL/);
41
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 90 }, '1h'))).toThrow(/less than the 1h cache TTL/);
42
+ });
43
+
44
+ test('rejects a refresh interval at or past the 5m TTL', () => {
45
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 45 }, '5m'))).toThrow(/less than the 5m cache TTL/);
46
+ expect(validateRecipe(recipeWith({ refreshAfterMinutes: 4 }, '5m')).agent.cacheKeepalive?.refreshAfterMinutes).toBe(4);
47
+ });
48
+
49
+ test('rejects non-positive and non-numeric values', () => {
50
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 0 }))).toThrow(/positive number/);
51
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: '45' }))).toThrow(/positive number/);
52
+ expect(() => validateRecipe(recipeWith({ maxIdleHours: -1 }))).toThrow(/positive number/);
53
+ expect(() => validateRecipe(recipeWith({ maxIdleHours: 'lots' }))).toThrow(/positive number/);
54
+ });
55
+
56
+ test('rejects a non-object', () => {
57
+ expect(() => validateRecipe(recipeWith('yes'))).toThrow(/must be an object/);
58
+ });
59
+ });
@@ -13,9 +13,19 @@ describe('compression recall-curve recipe settings', () => {
13
13
  const parsed = validateRecipe(recipe({
14
14
  compressionRefusalCurveFallbacks: 3,
15
15
  compressionContextBudgetTokens: 200_000,
16
+ compressionRecallBudgetTokens: 40_000,
17
+ compressionSourceOnly: false,
18
+ compressionSourceOnlyFallback: true,
19
+ compressionMergeSourceOnly: false,
20
+ compressionMergeSourceOnlyFallback: true,
16
21
  }));
17
22
  expect(parsed.agent.strategy?.compressionRefusalCurveFallbacks).toBe(3);
18
23
  expect(parsed.agent.strategy?.compressionContextBudgetTokens).toBe(200_000);
24
+ expect(parsed.agent.strategy?.compressionRecallBudgetTokens).toBe(40_000);
25
+ expect(parsed.agent.strategy?.compressionSourceOnly).toBe(false);
26
+ expect(parsed.agent.strategy?.compressionSourceOnlyFallback).toBe(true);
27
+ expect(parsed.agent.strategy?.compressionMergeSourceOnly).toBe(false);
28
+ expect(parsed.agent.strategy?.compressionMergeSourceOnlyFallback).toBe(true);
19
29
  });
20
30
 
21
31
  test('accepts zero as an explicit fallback disable', () => {
@@ -32,5 +42,14 @@ describe('compression recall-curve recipe settings', () => {
32
42
  .toThrow(/compressionContextBudgetTokens/);
33
43
  expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: '200000' })))
34
44
  .toThrow(/compressionContextBudgetTokens/);
45
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: 0 })))
46
+ .toThrow(/compressionRecallBudgetTokens/);
47
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: 1.5 })))
48
+ .toThrow(/compressionRecallBudgetTokens/);
49
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: '40000' })))
50
+ .toThrow(/compressionRecallBudgetTokens/);
51
+ for (const key of ['compressionSourceOnly', 'compressionSourceOnlyFallback', 'compressionMergeSourceOnly', 'compressionMergeSourceOnlyFallback']) {
52
+ expect(() => validateRecipe(recipe({ [key]: 'yes' }))).toThrow(new RegExp(key));
53
+ }
35
54
  });
36
55
  });