@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,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
+ });
@@ -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;