@animalabs/connectome-host 0.7.4 → 0.8.1

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 (67) 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 +320 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +26 -32
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +6 -6
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +221 -32
  20. package/src/framework-agent-config.ts +3 -0
  21. package/src/framework-strategy.ts +42 -0
  22. package/src/gate-telemetry.ts +134 -0
  23. package/src/headless.ts +10 -0
  24. package/src/index.ts +194 -55
  25. package/src/mcpl-config.ts +99 -1
  26. package/src/modules/identity-module.ts +310 -2
  27. package/src/modules/instructions-module.ts +265 -0
  28. package/src/modules/mcpl-admin-module.ts +58 -11
  29. package/src/modules/subagent-module.ts +18 -0
  30. package/src/modules/web-ui-module.ts +32 -4
  31. package/src/recipe.ts +821 -25
  32. package/src/web/panel-data.ts +44 -1
  33. package/src/workspace-mounts.ts +73 -0
  34. package/test/audit-module-optins.test.ts +10 -3
  35. package/test/cache-keepalive-log.test.ts +83 -0
  36. package/test/commands-qa-family.test.ts +239 -0
  37. package/test/conversations-recipe.test.ts +142 -0
  38. package/test/count-tokens-model.test.ts +31 -0
  39. package/test/framework-fkm-composition.test.ts +35 -3
  40. package/test/framework-strategy-defaults.test.ts +60 -0
  41. package/test/gate-telemetry-adapter.test.ts +84 -0
  42. package/test/gate-telemetry.test.ts +124 -0
  43. package/test/identity-and-surfaces.test.ts +212 -1
  44. package/test/instructions-module.test.ts +258 -0
  45. package/test/mcpl-admin-module.test.ts +41 -0
  46. package/test/mcpl-agent-overlay.test.ts +51 -3
  47. package/test/mcpl-child-env.test.ts +64 -0
  48. package/test/nudge-command.test.ts +47 -0
  49. package/test/recipe-cache-keepalive.test.ts +59 -0
  50. package/test/recipe-compression-fallback.test.ts +19 -0
  51. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  52. package/test/recipe-instructions.test.ts +176 -0
  53. package/test/recipe-kv-unified.test.ts +87 -0
  54. package/test/recipe-mcp-source.test.ts +54 -0
  55. package/test/recipe-openai-compatible.test.ts +54 -0
  56. package/test/recipe-path-resolution.test.ts +19 -8
  57. package/test/recipe-provider.test.ts +14 -0
  58. package/test/recipe-save-unresolved.test.ts +244 -0
  59. package/test/recipe-source-only.test.ts +38 -0
  60. package/test/release-changelog.test.ts +202 -0
  61. package/test/subagent-prose-routing.test.ts +109 -0
  62. package/test/subconscious-recipe.test.ts +86 -0
  63. package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
  64. package/test/web-ui-module.test.ts +41 -0
  65. package/test/workspace-mounts.test.ts +68 -0
  66. package/web/src/App.tsx +10 -0
  67. package/web/src/Health.tsx +61 -1
@@ -86,7 +86,7 @@ describe('framework FKM composition', () => {
86
86
  });
87
87
  });
88
88
 
89
- test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
89
+ test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', async () => {
90
90
  const parsed = validateRecipe(recipe({
91
91
  provider: 'openai-responses',
92
92
  maxTokens: 4_096,
@@ -103,13 +103,20 @@ describe('framework FKM composition', () => {
103
103
  type: 'autobiographical',
104
104
  compressionRefusalCurveFallbacks: 2,
105
105
  compressionContextBudgetTokens: 19_000,
106
+ compressionRecallBudgetTokens: 17_000,
107
+ compressionSourceOnly: false,
108
+ compressionSourceOnlyFallback: true,
109
+ compressionMergeSourceOnly: false,
110
+ compressionMergeSourceOnlyFallback: true,
106
111
  },
107
112
  }));
108
113
 
109
114
  const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
110
115
  tempDirs.push(dir);
111
- saveRecipe(dir, parsed);
112
- const reloaded = loadSavedRecipe(dir);
116
+ // Saving an already-resolved recipe through the new unresolved-snapshot
117
+ // path is legal: substitution over resolved content is a no-op.
118
+ saveRecipe(dir, parsed as unknown as Record<string, unknown>);
119
+ const reloaded = await loadSavedRecipe(dir);
113
120
  expect(reloaded).not.toBeNull();
114
121
 
115
122
  expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
@@ -118,11 +125,21 @@ describe('framework FKM composition', () => {
118
125
  });
119
126
  expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
120
127
  expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
128
+ expect(reloaded!.agent.strategy?.compressionRecallBudgetTokens).toBe(17_000);
129
+ expect(reloaded!.agent.strategy?.compressionSourceOnly).toBe(false);
130
+ expect(reloaded!.agent.strategy?.compressionSourceOnlyFallback).toBe(true);
131
+ expect(reloaded!.agent.strategy?.compressionMergeSourceOnly).toBe(false);
132
+ expect(reloaded!.agent.strategy?.compressionMergeSourceOnlyFallback).toBe(true);
121
133
 
122
134
  const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
123
135
  const runtimeConfig = strategyConfigView(runtimeStrategy);
124
136
  expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
125
137
  expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
138
+ expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
139
+ expect(runtimeConfig.compressionSourceOnly).toBe(false);
140
+ expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
141
+ expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
142
+ expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
126
143
 
127
144
  const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
128
145
  expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
@@ -138,14 +155,29 @@ describe('framework FKM composition', () => {
138
155
  type: 'autobiographical',
139
156
  compressionRefusalCurveFallbacks: 0,
140
157
  compressionContextBudgetTokens: 50_000,
158
+ compressionRecallBudgetTokens: 41_000,
159
+ compressionSourceOnly: true,
160
+ compressionSourceOnlyFallback: false,
161
+ compressionMergeSourceOnly: true,
162
+ compressionMergeSourceOnlyFallback: false,
141
163
  },
142
164
  }));
143
165
  const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
144
166
  const otherConfig = strategyConfigView(otherStrategy);
145
167
  expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
146
168
  expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
169
+ expect(otherConfig.compressionRecallBudgetTokens).toBe(41_000);
170
+ expect(otherConfig.compressionSourceOnly).toBe(true);
171
+ expect(otherConfig.compressionSourceOnlyFallback).toBe(false);
172
+ expect(otherConfig.compressionMergeSourceOnly).toBe(true);
173
+ expect(otherConfig.compressionMergeSourceOnlyFallback).toBe(false);
147
174
  expect(otherConfig).not.toBe(runtimeConfig);
148
175
  expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
149
176
  expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
177
+ expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
178
+ expect(runtimeConfig.compressionSourceOnly).toBe(false);
179
+ expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
180
+ expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
181
+ expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
150
182
  });
151
183
  });
@@ -61,6 +61,66 @@ describe('standard-recipe memory defaults', () => {
61
61
  expect(config.foldingStrategy).toBeUndefined();
62
62
  });
63
63
 
64
+ test('compressionSplitFallback / compressionSplitPlaceholder pass through and stay omitted when omitted', () => {
65
+ const on = buildFrameworkStrategy(
66
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: true, compressionSplitPlaceholder: true } }),
67
+ 'some-model',
68
+ 'America/Los_Angeles',
69
+ );
70
+ expect(configView(on).compressionSplitFallback).toBe(true);
71
+ expect(configView(on).compressionSplitPlaceholder).toBe(true);
72
+ const omitted = buildFrameworkStrategy(recipe({ name: 'Mira' }), 'some-model', 'America/Los_Angeles');
73
+ expect(configView(omitted).compressionSplitFallback).toBeUndefined();
74
+ expect(configView(omitted).compressionSplitPlaceholder).toBeUndefined();
75
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: 'yes' } })).toThrow();
76
+ });
77
+
78
+ test('split-stitch cap knobs pass through and are validated as positive integers', () => {
79
+ const on = buildFrameworkStrategy(
80
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: true, compressionSplitMaxCallsPerChunk: 12, compressionSplitMaxCallsPer10Min: 30 } }),
81
+ 'some-model',
82
+ 'America/Los_Angeles',
83
+ );
84
+ expect(configView(on).compressionSplitMaxCallsPerChunk).toBe(12);
85
+ expect(configView(on).compressionSplitMaxCallsPer10Min).toBe(30);
86
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitMaxCallsPerChunk: 0 } })).toThrow();
87
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitMaxCallsPer10Min: 1.5 } })).toThrow();
88
+ });
89
+
90
+ test('mergeMaxSourceSpanMessages is passed through exactly and omission stays omitted (princess 2026-09-05: silently dropped, span guard stuck at the CM default)', () => {
91
+ const configured = buildFrameworkStrategy(
92
+ recipe({
93
+ name: 'Mira',
94
+ strategy: { type: 'autobiographical', mergeMaxSourceSpanMessages: 3000, mergeThreshold: 3 },
95
+ }),
96
+ 'some-model',
97
+ 'America/Los_Angeles',
98
+ );
99
+ expect(configView(configured).mergeMaxSourceSpanMessages).toBe(3000);
100
+ expect(configView(configured).mergeThreshold).toBe(3);
101
+ const omitted = buildFrameworkStrategy(recipe({ name: 'Mira' }), 'some-model', 'America/Los_Angeles');
102
+ expect(configView(omitted).mergeMaxSourceSpanMessages).toBeUndefined();
103
+ });
104
+
105
+ test('productionBudgetTokens is passed through exactly and omission stays omitted', () => {
106
+ const configured = buildFrameworkStrategy(
107
+ recipe({
108
+ name: 'Mira',
109
+ strategy: { type: 'autobiographical', productionBudgetTokens: 123_456 },
110
+ }),
111
+ 'some-model',
112
+ 'America/Los_Angeles',
113
+ );
114
+ expect(configView(configured).productionBudgetTokens).toBe(123_456);
115
+
116
+ const omitted = buildFrameworkStrategy(
117
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical' } }),
118
+ 'some-model',
119
+ 'America/Los_Angeles',
120
+ );
121
+ expect(configView(omitted).productionBudgetTokens).toBeUndefined();
122
+ });
123
+
64
124
  test('explicit recipe values override the defaults', () => {
65
125
  const strategy = buildFrameworkStrategy(
66
126
  recipe({
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Adapter-level proof (review finding on #113): with the PUBLISHED membrane,
3
+ * a stream call consults the active-turn trigger and carries the origin trio,
4
+ * while a complete call (compression / side-calls / keepalive) carries the
5
+ * debt stamp only. Runs the real AnthropicAdapter with a mocked SDK client.
6
+ */
7
+ import { describe, expect, it } from 'bun:test';
8
+ import { AnthropicAdapter } from '@animalabs/membrane';
9
+ import { gateTelemetryHeaders } from '../src/gate-telemetry.js';
10
+
11
+ const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
12
+ const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
13
+
14
+ const REQUEST = {
15
+ model: 'claude-test',
16
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
17
+ } as any;
18
+
19
+ const RESPONSE = {
20
+ id: 'msg_test', model: 'claude-test', role: 'assistant',
21
+ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn',
22
+ usage: { input_tokens: 1, output_tokens: 1 },
23
+ };
24
+
25
+ /** Minimal SDK stream: an async iterable of the events the adapter reads. */
26
+ function mockStream() {
27
+ const events = [
28
+ { type: 'message_start', message: { id: 'msg_s', model: 'claude-test', role: 'assistant', content: [], usage: { input_tokens: 1, output_tokens: 0 } } },
29
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
30
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } },
31
+ { type: 'content_block_stop', index: 0 },
32
+ { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } },
33
+ { type: 'message_stop' },
34
+ ];
35
+ return {
36
+ async *[Symbol.asyncIterator]() { for (const e of events) yield e; },
37
+ };
38
+ }
39
+
40
+ function adapterWithTrigger(trigger: () => typeof wake | null) {
41
+ let asked = 0;
42
+ const dyn = gateTelemetryHeaders(env, () => 3, () => { asked++; return trigger(); })!;
43
+ const adapter = new AnthropicAdapter({ apiKey: 'sk-test', cacheKeepalive: { enabled: false }, dynamicHeaders: dyn } as any);
44
+ const calls: Array<{ kind: string; headers: Record<string, string> | undefined }> = [];
45
+ (adapter as any).client = {
46
+ messages: {
47
+ create: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'complete', headers: opts?.headers }); return RESPONSE; },
48
+ stream: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'stream', headers: opts?.headers }); return mockStream(); },
49
+ },
50
+ };
51
+ return { adapter, calls, asked: () => asked };
52
+ }
53
+
54
+ describe('gate telemetry through the published AnthropicAdapter', () => {
55
+ it('complete lane: debt only — the active trigger is not consulted', async () => {
56
+ const { adapter, calls, asked } = adapterWithTrigger(() => wake);
57
+ await adapter.complete(REQUEST);
58
+ expect(calls).toHaveLength(1);
59
+ expect(calls[0]!.kind).toBe('complete');
60
+ expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
61
+ expect(asked()).toBe(0);
62
+ });
63
+
64
+ it('stream lane: the trigger is consulted and the origin trio rides the request', async () => {
65
+ const { adapter, calls, asked } = adapterWithTrigger(() => wake);
66
+ const chunks: string[] = [];
67
+ await adapter.stream(REQUEST, { onChunk: (c: string) => { chunks.push(c); } } as any);
68
+ expect(calls).toHaveLength(1);
69
+ expect(calls[0]!.kind).toBe('stream');
70
+ expect(calls[0]!.headers).toEqual({
71
+ 'x-gate-debt-chunks': '3',
72
+ 'x-gate-origin': 'event',
73
+ 'x-gate-channel': 'discord:1:2',
74
+ 'x-gate-counterparty': 'discord:user:42',
75
+ });
76
+ expect(asked()).toBe(1);
77
+ });
78
+
79
+ it('stream lane with no turn in progress: debt only, nothing guessed', async () => {
80
+ const { adapter, calls } = adapterWithTrigger(() => null);
81
+ await adapter.stream(REQUEST, { onChunk: () => {} } as any);
82
+ expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
83
+ });
84
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The x-gate-* stamp must be impossible to send to a vendor: attachment is
3
+ * double-gated on the operator's explicit GATE_TELEMETRY declaration AND a
4
+ * configured base URL. Review finding on the first wiring: the stamp was
5
+ * attached unconditionally, so with ANTHROPIC_BASE_URL unset the household
6
+ * value went to the vendor's default endpoint.
7
+ */
8
+ import { describe, expect, it } from 'bun:test';
9
+ import { gateTelemetryHeaders, originClass, stampedTrigger } from '../src/gate-telemetry.js';
10
+
11
+ const debt = () => 7;
12
+
13
+ describe('gateTelemetryHeaders', () => {
14
+ it('default vendor endpoint (no base URL): never attaches, even when flagged', () => {
15
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: '1' }, debt)).toBeUndefined();
16
+ });
17
+
18
+ it('base URL without the operator declaration: never attaches', () => {
19
+ expect(gateTelemetryHeaders({ ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
20
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: '0', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
21
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: 'false', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
22
+ });
23
+
24
+ it('declared gateway: attaches a live stamp', () => {
25
+ const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt);
26
+ expect(fn).toBeDefined();
27
+ expect(fn!()).toEqual({ 'x-gate-debt-chunks': 7 });
28
+ });
29
+
30
+ it('unreadable debt stays null (membrane drops null values — unstamped, never guessed)', () => {
31
+ const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, () => null);
32
+ expect(fn!()).toEqual({ 'x-gate-debt-chunks': null });
33
+ });
34
+
35
+ const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
36
+ const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
37
+
38
+ it('origin trio rides the stream lane: why, where, by whom — ids only', () => {
39
+ const fn = gateTelemetryHeaders(env, debt, () => wake);
40
+ expect(fn!({ lane: 'stream' })).toEqual({
41
+ 'x-gate-debt-chunks': 7,
42
+ 'x-gate-origin': 'event',
43
+ 'x-gate-channel': 'discord:1:2',
44
+ 'x-gate-counterparty': 'discord:user:42',
45
+ });
46
+ });
47
+
48
+ it('complete lane (compression, side-calls, keepalive) carries debt only — a background call is not the turn', () => {
49
+ const fn = gateTelemetryHeaders(env, debt, () => wake);
50
+ expect(fn!({ lane: 'complete' })).toEqual({ 'x-gate-debt-chunks': 7 });
51
+ });
52
+
53
+ it('no lane told (older membrane) or no trigger known: honest — debt only, or trio without guessing', () => {
54
+ expect(gateTelemetryHeaders(env, debt, () => null)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
55
+ expect(gateTelemetryHeaders(env, debt, () => wake)!()).toMatchObject({ 'x-gate-origin': 'event' });
56
+ expect(gateTelemetryHeaders(env, debt)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
57
+ });
58
+
59
+ it('a gate-batched wake reports its telemetry channel (wakeChannelId) when it set no locus', () => {
60
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'gate:debounce', source: 'gate', wakeChannelId: 'discord:1:2', counterparty: 'discord:user:42' }));
61
+ expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'event', 'x-gate-channel': 'discord:1:2', 'x-gate-counterparty': 'discord:user:42' });
62
+ });
63
+
64
+ it('heartbeat wakes carry no channel or counterparty (null → dropped by membrane)', () => {
65
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'heartbeat:tick', source: 'heartbeat' }));
66
+ expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'heartbeat', 'x-gate-channel': null, 'x-gate-counterparty': null });
67
+ });
68
+
69
+ it('originClass: heartbeat / mail / event / operator, raw reason otherwise (sanitized, clipped)', () => {
70
+ expect(originClass({ reason: 'heartbeat', source: 'heartbeat' })).toBe('heartbeat');
71
+ expect(originClass({ reason: 'mail:incoming', source: 'fenmail' })).toBe('mail');
72
+ expect(originClass({ reason: 'mcpl:push-event', source: 'discord' })).toBe('event');
73
+ expect(originClass({ reason: 'gate:debounce', source: 'gate' })).toBe('event'); // first live stamps came back raw
74
+ expect(originClass({ reason: 'admin-nudge (someone)', source: 'framework' })).toBe('operator');
75
+ expect(originClass({ reason: 'external-message', source: 'headless' })).toBe('operator');
76
+ expect(originClass({ reason: 'external-message', source: 'tui' })).toBe('operator');
77
+ expect(originClass({ reason: 'provider-acceleration-retry', source: 'framework' })).toBe('provider-acceleration-retry');
78
+ expect(originClass({ reason: 'weird reason!!'.repeat(6), source: 'x' })).toHaveLength(40);
79
+ });
80
+
81
+ it('attributes are header-safe: a value with any non-ASCII or control character is withheld whole, never rewritten', () => {
82
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:\u{1F600}', counterparty: 'discord:user:4\r\n2' }));
83
+ const h = fn!({ lane: 'stream' });
84
+ expect(h['x-gate-channel']).toBeNull();
85
+ expect(h['x-gate-counterparty']).toBeNull();
86
+ // and what IS sent can always be put in real Headers (Fetch ByteString rule)
87
+ const ok = gateTelemetryHeaders(env, debt, () => wake)!({ lane: 'stream' });
88
+ const sendable = Object.fromEntries(Object.entries(ok).filter(([, v]) => v !== null).map(([k, v]) => [k, String(v)]));
89
+ expect(() => new Headers(sendable)).not.toThrow();
90
+ expect(new Headers(sendable).get('x-gate-counterparty')).toBe('discord:user:42');
91
+ });
92
+
93
+ it('attributes are clipped to 120 visible-ASCII characters', () => {
94
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', counterparty: 'x'.repeat(200) }));
95
+ expect((fn!({ lane: 'stream' })['x-gate-counterparty'] as string).length).toBe(120);
96
+ });
97
+
98
+ describe('stampedTrigger — one adapter serves every agent', () => {
99
+ const trig = (name: string) => ({ reason: 'gate:debounce', source: 'gate', counterparty: `discord:user:${name}` });
100
+
101
+ it('single agent: its trigger', () => {
102
+ expect(stampedTrigger({ agents: ['scout'], triggerOf: () => trig('scout') })).toEqual(trig('scout'));
103
+ });
104
+
105
+ it('primary + idle subconscious: the primary\'s trigger', () => {
106
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
107
+ triggerOf: (n) => (n === 'scout' ? trig('scout') : null) })).toEqual(trig('scout'));
108
+ });
109
+
110
+ it('primary and subconscious both mid-turn: withhold — the hook cannot tell whose request it decorates', () => {
111
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
112
+ triggerOf: (n) => trig(n) })).toBeNull();
113
+ });
114
+
115
+ it('only the subconscious mid-turn: nothing (its request is not the primary\'s turn)', () => {
116
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
117
+ triggerOf: (n) => (n === 'scout-sub' ? trig('sub') : null) })).toBeNull();
118
+ });
119
+
120
+ it('several agents and no primary known: withhold', () => {
121
+ expect(stampedTrigger({ agents: ['a', 'b'], triggerOf: () => trig('a') })).toBeNull();
122
+ });
123
+ });
124
+ });
@@ -16,6 +16,7 @@ import { IdentityModule } from '../src/modules/identity-module.ts';
16
16
  import { McplAdminModule } from '../src/modules/mcpl-admin-module.ts';
17
17
  import { ObserversModule } from '../src/modules/observers-module.ts';
18
18
 
19
+ const REQUEST_BODY_MAX_FOR_TEST = 256 * 1024;
19
20
  const call = (name: string, input: unknown) => ({ id: 't1', name, input });
20
21
 
21
22
  function fakeHome(routes: Record<string, (body: any) => { status: number; json: unknown }>): typeof fetch {
@@ -34,7 +35,7 @@ describe('identity module', () => {
34
35
  const dir = mkdtempSync(join(tmpdir(), 'ident-'));
35
36
  const mod = new IdentityModule({ keyPath: join(dir, 'identity-key.pem'), home: 'id.test' });
36
37
  expect(mod.getTools()).toEqual([]);
37
- expect(mod.getUtilities().map((u) => u.name)).toEqual(['status', 'accept_invite']);
38
+ expect(mod.getUtilities().map((u) => u.name)).toEqual(['status', 'request', 'accept_invite']);
38
39
  // Framing check: no crypto/credential vocabulary in agent-visible text.
39
40
  const visible = JSON.stringify(mod.getUtilities()).toLowerCase();
40
41
  for (const scary of ['token', 'key', 'sign', 'proof', 'ed25519', 'mint', 'bearer']) {
@@ -86,6 +87,216 @@ describe('identity module', () => {
86
87
  expect(again.error).toContain('Already registered');
87
88
  });
88
89
 
90
+
91
+ it('request: allowlisted service, host-attached access, no credential in result', async () => {
92
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
93
+ let authSeen = '';
94
+ const mod = new IdentityModule({
95
+ keyPath: join(dir, 'k.pem'),
96
+ home: 'id.test',
97
+ services: { orrery: 'https://orrery.test' },
98
+ fetchImpl: (async (url: any, init?: any) => {
99
+ const u = String(url);
100
+ if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@guest', token: 't0' }), { status: 200 });
101
+ if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
102
+ if (u === 'https://orrery.test/api/ops') {
103
+ authSeen = String(init?.headers?.authorization ?? '');
104
+ return new Response(JSON.stringify({ ops: [1, 2] }), { status: 200 });
105
+ }
106
+ return new Response('{}', { status: 404 });
107
+ }) as typeof fetch,
108
+ });
109
+ // not registered yet -> neutral failure
110
+ const early = await mod.handleToolCall(call('request', { service: 'orrery', path: '/api/ops' }));
111
+ expect(early.success).toBe(false);
112
+
113
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
114
+ const res = await mod.handleToolCall(call('request', { service: 'orrery', path: '/api/ops' }));
115
+ expect(res.success).toBe(true);
116
+ expect((res.data as any).status).toBe(200);
117
+ expect((res.data as any).body).toEqual({ ops: [1, 2] });
118
+ expect(authSeen).toBe('Bearer aid1.fresh.secret');
119
+ // the credential must never appear in the agent-visible result
120
+ expect(JSON.stringify(res.data)).not.toContain('aid1.');
121
+
122
+ const unknown = await mod.handleToolCall(call('request', { service: 'nope', path: '/x' }));
123
+ expect(unknown.error).toContain('Available: orrery');
124
+ const badPath = await mod.handleToolCall(call('request', { service: 'orrery', path: 'api/ops' }));
125
+ expect(badPath.success).toBe(false);
126
+ });
127
+
128
+ it('request: services come from the home node directory, and a newly-listed one works without a restart', async () => {
129
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
130
+ // The archipelago's service list lives at the home node, not in this host.
131
+ // It changes underneath us mid-run: `music` is added after the module has
132
+ // already fetched and cached a directory without it.
133
+ let directory: Record<string, string> = { orrery: 'https://orrery.test' };
134
+ let directoryFetches = 0;
135
+ const mod = new IdentityModule({
136
+ keyPath: join(dir, 'k.pem'),
137
+ home: 'id.test',
138
+ fetchImpl: (async (url: any, init?: any) => {
139
+ const u = String(url);
140
+ if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@id.test', token: 't0' }), { status: 200 });
141
+ if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
142
+ if (u === 'https://id.test/services') {
143
+ directoryFetches++;
144
+ return new Response(JSON.stringify({ home: 'id.test', services: directory }), { status: 200 });
145
+ }
146
+ if (u === 'https://music.test/api/me') return new Response(JSON.stringify({ me: 'mythos' }), { status: 200 });
147
+ return new Response('{}', { status: 404 });
148
+ }) as typeof fetch,
149
+ });
150
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
151
+
152
+ // nothing compiled in, nothing in the recipe: the directory supplied it
153
+ const listed = await mod.handleToolCall(call('request', { service: 'orrery', path: '/x' }));
154
+ expect(listed.success).toBe(true); // resolved and called; upstream 404 is reported, not a resolution failure
155
+ expect((listed.data as any).status).toBe(404);
156
+
157
+ // music does not exist yet -> refused, and the refusal cost a re-check
158
+ const before = await mod.handleToolCall(call('request', { service: 'music', path: '/api/me' }));
159
+ expect(before.success).toBe(false);
160
+ expect(before.error).toContain('Unknown service "music"');
161
+
162
+ // operator adds it at the home node; no restart, no recipe edit here
163
+ directory = { orrery: 'https://orrery.test', music: 'https://music.test' };
164
+ const fetchesBefore = directoryFetches;
165
+
166
+ const after = await mod.handleToolCall(call('request', { service: 'music', path: '/api/me' }));
167
+ expect(after.success).toBe(true);
168
+ expect((after.data as any).body).toEqual({ me: 'mythos' });
169
+ // it re-asked rather than serving a stale "unknown" from cache
170
+ expect(directoryFetches).toBeGreaterThan(fetchesBefore);
171
+ });
172
+
173
+ it('request: a home node that cannot be reached degrades to the built-in map, never to an outage', async () => {
174
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
175
+ const mod = new IdentityModule({
176
+ keyPath: join(dir, 'k.pem'),
177
+ home: 'id.test',
178
+ fetchImpl: (async (url: any) => {
179
+ const u = String(url);
180
+ if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@id.test', token: 't0' }), { status: 200 });
181
+ if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
182
+ if (u === 'https://id.test/services') return new Response('nope', { status: 503 });
183
+ if (u === 'https://eidoverse.animalabs.ai/api/ping') return new Response(JSON.stringify({ ok: true }), { status: 200 });
184
+ return new Response('{}', { status: 404 });
185
+ }) as typeof fetch,
186
+ });
187
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
188
+ const res = await mod.handleToolCall(call('request', { service: 'eidoverse', path: '/api/ping' }));
189
+ expect(res.success).toBe(true);
190
+ expect((res.data as any).body).toEqual({ ok: true });
191
+ });
192
+
193
+ it('request: fromFile uploads a workspace file byte-exactly, without the bytes touching context', async () => {
194
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
195
+ // A file far larger than the JSON body cap — the case that made this exist.
196
+ const audio = Buffer.alloc(REQUEST_BODY_MAX_FOR_TEST + 4096, 0);
197
+ audio.write('ID3', 0);
198
+ audio[audio.length - 1] = 0x7f;
199
+ let sentBody: Buffer | null = null;
200
+ let sentType = '';
201
+ const workspace = {
202
+ readBinary: async (path: string) =>
203
+ path === 'files/music/track.mp3' ? { data: audio } : { error: `File not found: ${path}` },
204
+ writeBinary: async () => ({ success: true }),
205
+ };
206
+ const mod = new IdentityModule({
207
+ keyPath: join(dir, 'k.pem'),
208
+ home: 'id.test',
209
+ services: { music: 'https://music.test' },
210
+ fetchImpl: (async (url: any, init?: any) => {
211
+ const u = String(url);
212
+ if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@id.test', token: 't0' }), { status: 200 });
213
+ if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
214
+ if (u.includes('/services')) return new Response(JSON.stringify({ services: {} }), { status: 200 });
215
+ if (u === 'https://music.test/api/upload/1/audio') {
216
+ sentBody = Buffer.from(init?.body as Uint8Array);
217
+ sentType = String(init?.headers?.['content-type'] ?? '');
218
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
219
+ }
220
+ return new Response('{}', { status: 404 });
221
+ }) as typeof fetch,
222
+ });
223
+ (mod as any).ctx = { getModule: (n: string) => (n === 'workspace' ? workspace : null) };
224
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
225
+
226
+ const res = await mod.handleToolCall(
227
+ call('request', { service: 'music', path: '/api/upload/1/audio', method: 'PUT', fromFile: 'files/music/track.mp3' }),
228
+ );
229
+ expect(res.success).toBe(true);
230
+ // byte-exact, and typed from the extension rather than guessed by the model
231
+ expect(sentBody!.equals(audio)).toBe(true);
232
+ expect(sentType).toBe('audio/mpeg');
233
+ // a receipt, but never the payload itself, in the agent-visible result
234
+ expect((res.data as any).sent).toEqual({
235
+ path: 'files/music/track.mp3',
236
+ size: audio.byteLength,
237
+ contentType: 'audio/mpeg',
238
+ });
239
+ expect(JSON.stringify(res.data).length).toBeLessThan(1000);
240
+
241
+ // guard rails
242
+ const both = await mod.handleToolCall(
243
+ call('request', { service: 'music', path: '/x', method: 'PUT', body: { a: 1 }, fromFile: 'files/music/track.mp3' }),
244
+ );
245
+ expect(both.error).toContain('not both');
246
+ const onGet = await mod.handleToolCall(call('request', { service: 'music', path: '/x', fromFile: 'files/music/track.mp3' }));
247
+ expect(onGet.error).toContain('POST or PUT');
248
+ const missing = await mod.handleToolCall(
249
+ call('request', { service: 'music', path: '/x', method: 'PUT', fromFile: 'files/nope.mp3' }),
250
+ );
251
+ expect(missing.error).toContain('could not read');
252
+ });
253
+
254
+ it('request: binary responses are described safely or saved byte-exactly to workspace', async () => {
255
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
256
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00, 0x7f]);
257
+ let saved: { path: string; data: Buffer; mime: string } | null = null;
258
+ const mod = new IdentityModule({
259
+ keyPath: join(dir, 'k.pem'),
260
+ home: 'id.test',
261
+ services: { orrery: 'https://orrery.test' },
262
+ fetchImpl: (async (url: any) => {
263
+ const u = String(url);
264
+ if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@guest' }), { status: 200 });
265
+ if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
266
+ if (u === 'https://orrery.test/api/assets/img/file') {
267
+ return new Response(png, { status: 200, headers: { 'content-type': 'image/png' } });
268
+ }
269
+ return new Response('{}', { status: 404, headers: { 'content-type': 'application/json' } });
270
+ }) as typeof fetch,
271
+ });
272
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
273
+
274
+ const described = await mod.handleToolCall(call('request', { service: 'orrery', path: '/api/assets/img/file' }));
275
+ expect(described.success).toBe(true);
276
+ expect((described.data as any).body).toBe(null);
277
+ expect((described.data as any).binary).toMatchObject({ size: png.length, contentType: 'image/png' });
278
+ expect(JSON.stringify(described.data)).not.toContain('�PNG');
279
+
280
+ await mod.start({
281
+ getModule: (name: string) => name === 'workspace' ? {
282
+ writeBinary: async (path: string, data: Buffer, mime: string) => {
283
+ saved = { path, data: Buffer.from(data), mime };
284
+ return { success: true, data: { path, size: data.length, mimeType: mime } };
285
+ },
286
+ } : null,
287
+ } as any);
288
+ const written = await mod.handleToolCall(call('request', {
289
+ service: 'orrery', path: '/api/assets/img/file', saveAs: 'files/artifacts/candidate.png',
290
+ }));
291
+ expect(written.success).toBe(true);
292
+ expect((written.data as any).saved).toMatchObject({
293
+ path: 'files/artifacts/candidate.png', size: png.length, contentType: 'image/png',
294
+ });
295
+ expect(saved?.path).toBe('files/artifacts/candidate.png');
296
+ expect(saved?.mime).toBe('image/png');
297
+ expect(saved?.data.equals(png)).toBe(true);
298
+ });
299
+
89
300
  it('host-facing accessFor: requires registration, then exchanges per call', async () => {
90
301
  const dir = mkdtempSync(join(tmpdir(), 'ident-'));
91
302
  let mints = 0;