@animalabs/connectome-host 0.7.4 → 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.
- package/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +245 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/package.json +5 -5
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +37 -0
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +167 -55
- package/src/mcpl-config.ts +99 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +58 -11
- package/src/modules/subagent-module.ts +18 -0
- package/src/recipe.ts +732 -25
- package/src/web/panel-data.ts +19 -0
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +10 -3
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +19 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +41 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +1 -0
- package/web/src/Health.tsx +61 -1
|
@@ -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;
|
|
@@ -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
|
+
});
|
|
@@ -23,6 +23,11 @@ interface StubServer {
|
|
|
23
23
|
maskedCapabilities: string[];
|
|
24
24
|
deniedCapabilities: string[];
|
|
25
25
|
allowHostCommands: boolean;
|
|
26
|
+
manifestState?: {
|
|
27
|
+
lastValidatedRevision: string | null;
|
|
28
|
+
lastFetchedAt: number | null;
|
|
29
|
+
lastNegotiatedAt: number | null;
|
|
30
|
+
};
|
|
26
31
|
command?: string;
|
|
27
32
|
url?: string;
|
|
28
33
|
}
|
|
@@ -46,6 +51,11 @@ function makeStubFramework() {
|
|
|
46
51
|
maskedCapabilities: ['channels.streaming'],
|
|
47
52
|
deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
|
|
48
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
|
+
},
|
|
49
59
|
command: config.command,
|
|
50
60
|
url: config.url,
|
|
51
61
|
});
|
|
@@ -69,6 +79,11 @@ function makeStubFramework() {
|
|
|
69
79
|
maskedCapabilities: ['channels.streaming'],
|
|
70
80
|
deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
|
|
71
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
|
+
},
|
|
72
87
|
command: config?.command ?? prev?.command,
|
|
73
88
|
});
|
|
74
89
|
},
|
|
@@ -230,7 +245,33 @@ describe('mcpl_list', () => {
|
|
|
230
245
|
expect(text).toContain('masked=[channels.streaming]');
|
|
231
246
|
expect(text).toContain('denied=[contextHooks.beforeInference.inject.system]');
|
|
232
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
|
+
);
|
|
233
253
|
expect(text).toContain('source=agent-overlay');
|
|
234
254
|
expect(text).toContain('gone: UNLOADED');
|
|
235
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
|
+
});
|
|
236
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
|
});
|