@animalabs/connectome-host 0.7.2 → 0.7.3

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/src/recipe.ts CHANGED
@@ -71,16 +71,19 @@ export interface RecipeStrategy {
71
71
  /** kv-stable: quality-gap override threshold (§13.4). Default 0.35. */
72
72
  kvStableQualityGapRatio?: number;
73
73
  compressionSlackRatio?: number;
74
+ /** Adaptive-resolution fold planner. The host defaults this to 'kv-stable'
75
+ * (cache-stable compile plans; see buildFrameworkStrategy) — set explicitly
76
+ * only to opt into the legacy planners. */
74
77
  foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
75
78
  speculativeProduction?: boolean;
76
79
  /** L1 production holdback: keep the newest N closed chunks out of the
77
80
  * speculative compression queue (default 1); demand still overrides. */
78
81
  l1HoldbackChunks?: number;
79
- // Self-voice / compression framing. When the agent's name differs from
80
- // 'Claude' (the strategy default for summaryParticipant), these MUST be set
81
- // (especially summaryParticipant: <agent.name>) otherwise self-recollections
82
- // are stored under a stranger's participant and surface in the compiled prompt
83
- // as another voice speaking in first person about the agent.
82
+ // Self-voice / compression framing. The host defaults summaryParticipant to
83
+ // `agent.name` (buildFrameworkStrategy), so self-recollections speak as the
84
+ // agent itself. Set explicitly only to voice summaries as someone else —
85
+ // a mismatched participant surfaces in the compiled prompt as another voice
86
+ // speaking in first person about the agent.
84
87
  summaryParticipant?: string;
85
88
  summarySystemPrompt?: string;
86
89
  summaryUserPrompt?: string;
@@ -121,8 +124,18 @@ export interface RecipeAgent {
121
124
  * ContextManager default (100k) applies. Raise for large-context models. */
122
125
  contextBudgetTokens?: number;
123
126
  /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
124
- * '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
127
+ * '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads.
128
+ * Not forwarded on bedrock — that transport only has the default 5m
129
+ * cache and rejects the ttl field. */
125
130
  cacheTtl?: '5m' | '1h';
131
+ /**
132
+ * Explicit prompt-caching override. Unset means provider-appropriate
133
+ * default: on for everything except bedrock models that predate caching
134
+ * support there (see bedrockModelSupportsPromptCaching). Set false if a
135
+ * transport/account rejects cache_control markers — AWS's "your request
136
+ * did not allow prompt caching" can also be account/region-dependent.
137
+ */
138
+ promptCaching?: boolean;
126
139
  /**
127
140
  * Same-round routing policy for ordinary text emitted beside think().
128
141
  * Omitted preserves the compatibility carry-forward in Agent Framework.
@@ -150,6 +163,13 @@ export interface RecipeAgent {
150
163
  thinking?: {
151
164
  enabled: boolean;
152
165
  budgetTokens?: number;
166
+ /** 'enabled' (explicit budget, legacy) or 'adaptive' (model-managed;
167
+ * required by opus-4-7+ / fable-5 era models). */
168
+ type?: 'enabled' | 'adaptive';
169
+ /** 'summarized' returns readable reasoning summaries in `thinking`;
170
+ * 'omitted' returns empty text + signature only. Models 4.7+ default
171
+ * to 'omitted' server-side. */
172
+ display?: 'summarized' | 'omitted';
153
173
  };
154
174
  /** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
155
175
  * compaction and serviceTier are API-key transport settings. */
@@ -188,6 +208,12 @@ export interface RecipeMcpServer {
188
208
  transport?: 'stdio' | 'websocket';
189
209
  /** Bearer token for WebSocket auth (appended as ?token= query param). */
190
210
  token?: string;
211
+ /**
212
+ * Name of a host-managed access grant (archipelago audience, e.g.
213
+ * "eidoverse"). Requires the `identity` module. The host resolves fresh
214
+ * credentials per dial; neither the recipe nor the agent holds one.
215
+ */
216
+ access?: string;
191
217
  toolPrefix?: string;
192
218
  enabledFeatureSets?: string[];
193
219
  disabledFeatureSets?: string[];
@@ -382,8 +408,22 @@ export interface RecipeWorkspaceMount {
382
408
  }
383
409
 
384
410
  export interface RecipeModules {
411
+ /**
412
+ * Subagent forking (spawn/fork parallel agents). OPT-IN — defaults to off
413
+ * and is not part of the standard recipe.
414
+ */
385
415
  subagents?: boolean | { defaultModel?: string; defaultMaxTokens?: number };
416
+ /**
417
+ * Lesson library (persistent knowledge store + lesson tools). OPT-IN —
418
+ * defaults to off and is not part of the standard recipe.
419
+ */
386
420
  lessons?: boolean;
421
+ /**
422
+ * Lesson retrieval-injection (requires `lessons`). OPT-IN — defaults to off
423
+ * and is deliberately not part of the standard recipe: it injects
424
+ * context-dependent content into every compile and spends two Haiku calls
425
+ * per turn. Enable only for agents that actually curate a lesson library.
426
+ */
387
427
  retrieval?: boolean | { model?: string; maxInjected?: number };
388
428
  wake?: boolean | import('@animalabs/agent-framework').GateConfig;
389
429
  workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
@@ -404,8 +444,23 @@ export interface RecipeModules {
404
444
  *
405
445
  * SECURITY: `mcpl_deploy` spawns arbitrary commands as the host user —
406
446
  * enabling this module means trusting the agent with code execution.
447
+ *
448
+ * `{ surface: 'utilities' }` keeps the module but parks its four tools
449
+ * behind the framework's single `utils` meta-tool (mcpl management is
450
+ * rare; it needn't cost four schemas on every inference). `true` keeps
451
+ * the historical first-class surface.
407
452
  */
408
- mcplAdmin?: boolean;
453
+ mcplAdmin?: boolean | { surface?: 'tools' | 'utilities' };
454
+
455
+ /**
456
+ * The agent's own archipelago-home identity (connectome docs/home-node.md):
457
+ * an ed25519 keypair in the data dir, enrolled at the home node via an
458
+ * operator invite, exchanged for fresh aid1 audience tokens on demand.
459
+ * Utilities-only (`utils run identity--status/enroll/token`) — costs no
460
+ * tool slots. `home` defaults to id.animalabs.ai; `audience` is the
461
+ * default for `token` calls.
462
+ */
463
+ identity?: boolean | { home?: string; audience?: string };
409
464
  /**
410
465
  * Cross-process child fleet. When true (shorthand), FleetModule is attached
411
466
  * with no pre-configured children. When an object, declares children the
@@ -485,6 +540,13 @@ export interface RecipeModules {
485
540
  export interface RecipeWebUi {
486
541
  port?: number;
487
542
  host?: string;
543
+ /**
544
+ * Where the observer grant tools (observers--get/grant/revoke) surface:
545
+ * 'tools' (default, historical) or 'utilities' (behind the `utils`
546
+ * meta-tool — grant edits are rare). Consent semantics unchanged: the
547
+ * agent holds the pen either way.
548
+ */
549
+ observersSurface?: 'tools' | 'utilities';
488
550
  /**
489
551
  * Basic-Auth credentials. Required whenever the bind host is non-loopback
490
552
  * (which is the default). `${VAR}`-substitutable from .env.
@@ -651,13 +713,13 @@ export const DEFAULT_RECIPE: Recipe = {
651
713
  'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
652
714
  'Use them to help the user with their tasks.',
653
715
  '',
654
- 'You can fork subagents for parallel work, create persistent notes, and write files to `products/` as outputs of your work.',
716
+ 'You can create persistent notes and write files to `products/` as outputs of your work.',
655
717
  ].join('\n'),
656
718
  },
657
719
  modules: {
658
- subagents: true,
659
- lessons: true,
660
- retrieval: true,
720
+ // subagents + lessons + retrieval deliberately omitted — all opt-in only
721
+ // (retrieval additionally adds per-turn context churn + Haiku costs);
722
+ // see the RecipeModules field docs.
661
723
  wake: true,
662
724
  workspace: true,
663
725
  },
@@ -914,6 +976,12 @@ export function validateRecipe(raw: unknown): Recipe {
914
976
  }
915
977
  agent.cacheTtl ??= '1h';
916
978
 
979
+ if (agent.promptCaching !== undefined && typeof agent.promptCaching !== 'boolean') {
980
+ throw new Error(
981
+ `Recipe agent.promptCaching must be a boolean, got ${JSON.stringify(agent.promptCaching)}.`,
982
+ );
983
+ }
984
+
917
985
  if (
918
986
  agent.sameRoundThinkTextPolicy !== undefined &&
919
987
  agent.sameRoundThinkTextPolicy !== 'public' &&
@@ -938,6 +1006,12 @@ export function validateRecipe(raw: unknown): Recipe {
938
1006
  if (thinking.budgetTokens !== undefined && (typeof thinking.budgetTokens !== 'number' || thinking.budgetTokens <= 0)) {
939
1007
  throw new Error('Recipe agent.thinking.budgetTokens must be a positive number.');
940
1008
  }
1009
+ if (thinking.type !== undefined && thinking.type !== 'enabled' && thinking.type !== 'adaptive') {
1010
+ throw new Error('Recipe agent.thinking.type must be "enabled" or "adaptive".');
1011
+ }
1012
+ if (thinking.display !== undefined && thinking.display !== 'summarized' && thinking.display !== 'omitted') {
1013
+ throw new Error('Recipe agent.thinking.display must be "summarized" or "omitted".');
1014
+ }
941
1015
  if (thinking.enabled === true && typeof thinking.budgetTokens === 'number' && typeof agent.maxTokens === 'number') {
942
1016
  if (thinking.budgetTokens >= agent.maxTokens) {
943
1017
  throw new Error(
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Bedrock prompt caching is model-gated, not suppressed transport-wide
3
+ * (issue #35). The deny-list is the pre-GA FAMILIES (Claude v2/instant,
4
+ * Claude 3, 3.5 Sonnet — Bedrock's caching GA never covered them), matched
5
+ * at the family boundary so dated ids, bare aliases, -latest, and
6
+ * inference-profile forms all resolve the same. Everything currently
7
+ * invokable on Bedrock caches (verified by live probe 2026-07-31; the
8
+ * 3.5 era is EOL there). The old blanket promptCaching:false made every
9
+ * modern Bedrock agent re-pay its full context on every call.
10
+ *
11
+ * The recipe override must land at BOTH layers — per-agent config for
12
+ * agent inference AND Membrane defaultPromptCaching for internal callers
13
+ * (compression/merge) — on every provider, per Sol's #69 review.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { validateRecipe } from '../src/recipe.js';
17
+ import {
18
+ buildFrameworkAgentConfig,
19
+ bedrockModelSupportsPromptCaching,
20
+ membraneCachingOverride,
21
+ } from '../src/framework-agent-config.js';
22
+
23
+ function recipe(agent: Record<string, unknown> = {}) {
24
+ return { name: 'bedrock-caching-test', agent: { systemPrompt: 'sys', ...agent } };
25
+ }
26
+
27
+ describe('bedrockModelSupportsPromptCaching', () => {
28
+ test('ids without GA caching support are gated off', () => {
29
+ for (const id of [
30
+ 'anthropic.claude-3-opus-20240229-v1:0',
31
+ 'anthropic.claude-3-sonnet-20240229-v1:0',
32
+ 'anthropic.claude-3-haiku-20240307-v1:0',
33
+ 'us.anthropic.claude-3-5-sonnet-20240620-v1:0',
34
+ // "3.6" — its caching was preview-only on Bedrock, dropped at GA
35
+ // (and the model itself is EOL there as of 7/2026).
36
+ 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
37
+ 'claude-3-5-sonnet-20241022',
38
+ 'claude-3-opus-20240229',
39
+ 'anthropic.claude-v2:1',
40
+ 'anthropic.claude-instant-v1',
41
+ ]) {
42
+ expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
43
+ }
44
+ });
45
+
46
+ test('the deny-list matches families, not single dated spellings', () => {
47
+ // Sol's #69 review: bare aliases, -latest, and profile forms of the
48
+ // pre-GA families must not fall through to caching-on.
49
+ for (const id of [
50
+ 'claude-3-opus',
51
+ 'claude-3-opus-latest',
52
+ 'claude-3-sonnet',
53
+ 'claude-3-haiku-latest',
54
+ 'claude-3-5-sonnet',
55
+ 'claude-3-5-sonnet-latest',
56
+ 'us.anthropic.claude-3-opus-latest-v1:0',
57
+ 'apac.anthropic.claude-3-5-sonnet-v2:0',
58
+ 'CLAUDE-3-OPUS',
59
+ ]) {
60
+ expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
61
+ }
62
+ });
63
+
64
+ test('models with GA Bedrock caching support stay on', () => {
65
+ for (const id of [
66
+ 'anthropic.claude-3-5-haiku-20241022-v1:0',
67
+ 'claude-3-5-haiku-latest',
68
+ 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
69
+ 'claude-3-7-sonnet',
70
+ 'us.anthropic.claude-sonnet-4-20250514-v1:0',
71
+ 'claude-sonnet-4-20250514',
72
+ 'claude-opus-4-6',
73
+ 'bedrock:us.anthropic.claude-opus-4-20250514-v1:0',
74
+ ]) {
75
+ expect(bedrockModelSupportsPromptCaching(id)).toBe(true);
76
+ }
77
+ });
78
+
79
+ test('non-Claude Bedrock ids are conservatively off, not accidentally on', () => {
80
+ for (const id of ['amazon.nova-pro-v1:0', 'meta.llama3-70b-instruct-v1:0', 'mistral.mistral-large-2402-v1:0']) {
81
+ expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
82
+ }
83
+ });
84
+ });
85
+
86
+ describe('bedrock agent config', () => {
87
+ test('GA-supported bedrock model gets caching on, without cacheTtl', () => {
88
+ const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
89
+ const config = buildFrameworkAgentConfig(
90
+ parsed, 'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
91
+ );
92
+ expect(config.promptCaching).toBe(true);
93
+ // Bedrock rejects cache_control.ttl; the recipe default ('1h') must not
94
+ // ride along even though membrane also strips it.
95
+ expect(Object.prototype.hasOwnProperty.call(config, 'cacheTtl')).toBe(false);
96
+ });
97
+
98
+ test('legacy bedrock model keeps caching suppressed', () => {
99
+ const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
100
+ const config = buildFrameworkAgentConfig(
101
+ parsed, 'agent', 'us.anthropic.claude-3-5-sonnet-20240620-v1:0', undefined,
102
+ );
103
+ expect(config.promptCaching).toBe(false);
104
+ });
105
+
106
+ test('recipe promptCaching overrides the model gate in both directions', () => {
107
+ const forcedOff = buildFrameworkAgentConfig(
108
+ validateRecipe(recipe({ provider: 'bedrock', promptCaching: false })),
109
+ 'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
110
+ );
111
+ expect(forcedOff.promptCaching).toBe(false);
112
+
113
+ // Explicit opt-in for an account/region whose entitlements differ
114
+ // from the GA table (the gate is a default, not a hard ceiling).
115
+ const forcedOn = buildFrameworkAgentConfig(
116
+ validateRecipe(recipe({ provider: 'bedrock', promptCaching: true })),
117
+ 'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined,
118
+ );
119
+ expect(forcedOn.promptCaching).toBe(true);
120
+ });
121
+
122
+ test('explicit override lands at BOTH layers on any provider (composition)', () => {
123
+ // Anthropic recipe, explicit false: agent config off AND membrane
124
+ // default off — internal callers (compression/merge) read the latter.
125
+ const anthOff = validateRecipe(recipe({ promptCaching: false }));
126
+ expect(buildFrameworkAgentConfig(anthOff, 'agent', 'claude-opus-4-6', undefined).promptCaching).toBe(false);
127
+ expect(membraneCachingOverride(anthOff, 'claude-opus-4-6')).toEqual({ defaultPromptCaching: false });
128
+
129
+ // Bedrock explicit true and false: both layers agree with the override.
130
+ const bedOn = validateRecipe(recipe({ provider: 'bedrock', promptCaching: true }));
131
+ expect(buildFrameworkAgentConfig(bedOn, 'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined).promptCaching).toBe(true);
132
+ expect(membraneCachingOverride(bedOn, 'us.anthropic.claude-3-5-sonnet-20241022-v2:0')).toEqual({ defaultPromptCaching: true });
133
+
134
+ const bedOff = validateRecipe(recipe({ provider: 'bedrock', promptCaching: false }));
135
+ expect(buildFrameworkAgentConfig(bedOff, 'agent', 'us.anthropic.claude-opus-4-1-20250805-v1:0', undefined).promptCaching).toBe(false);
136
+ expect(membraneCachingOverride(bedOff, 'us.anthropic.claude-opus-4-1-20250805-v1:0')).toEqual({ defaultPromptCaching: false });
137
+
138
+ // Bedrock with no explicit override: the model gate supplies the
139
+ // answer at both layers too.
140
+ const bedGate = validateRecipe(recipe({ provider: 'bedrock' }));
141
+ expect(membraneCachingOverride(bedGate, 'us.anthropic.claude-3-7-sonnet-20250219-v1:0')).toEqual({ defaultPromptCaching: true });
142
+ expect(membraneCachingOverride(bedGate, 'anthropic.claude-3-opus-20240229-v1:0')).toEqual({ defaultPromptCaching: false });
143
+
144
+ // Anthropic with no override: BOTH layers stay silent — membrane's
145
+ // own default (on) governs, and we don't bake it in here.
146
+ const anthDefault = validateRecipe(recipe());
147
+ expect(Object.prototype.hasOwnProperty.call(
148
+ buildFrameworkAgentConfig(anthDefault, 'agent', 'claude-opus-4-6', undefined), 'promptCaching',
149
+ )).toBe(false);
150
+ expect(membraneCachingOverride(anthDefault, 'claude-opus-4-6')).toEqual({});
151
+ });
152
+
153
+ test('non-bedrock providers keep prior behavior: caching omitted, cacheTtl forwarded', () => {
154
+ const parsed = validateRecipe(recipe());
155
+ const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
156
+ expect(Object.prototype.hasOwnProperty.call(config, 'promptCaching')).toBe(false);
157
+ expect(config.cacheTtl).toBe('1h');
158
+ });
159
+
160
+ test('recipe promptCaching applies on non-bedrock providers too', () => {
161
+ const parsed = validateRecipe(recipe({ promptCaching: false }));
162
+ const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
163
+ expect(config.promptCaching).toBe(false);
164
+ });
165
+
166
+ test('non-boolean promptCaching is rejected at validation', () => {
167
+ expect(() => validateRecipe(recipe({ promptCaching: 'yes' }))).toThrow(/promptCaching/);
168
+ expect(() => validateRecipe(recipe({ promptCaching: 1 }))).toThrow(/promptCaching/);
169
+ });
170
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Standard-recipe memory defaults (buildFrameworkStrategy).
3
+ *
4
+ * A recipe that omits strategy tuning must get the fleet-standard shape:
5
+ * autobiographical + adaptiveResolution + kv-stable folding + same-model
6
+ * compression + summaries voiced as the agent itself. Explicit recipe values
7
+ * always win. DEFAULT_RECIPE must not enable the opt-in modules
8
+ * (subagents/lessons/retrieval).
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
12
+ import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
13
+
14
+ function recipe(agent: Record<string, unknown> = {}) {
15
+ return validateRecipe({
16
+ name: 'framework-strategy-defaults',
17
+ agent: {
18
+ systemPrompt: 'sys',
19
+ ...agent,
20
+ },
21
+ });
22
+ }
23
+
24
+ function configView(strategy: object): Record<string, unknown> {
25
+ return (strategy as { config?: Record<string, unknown> }).config ?? {};
26
+ }
27
+
28
+ describe('standard-recipe memory defaults', () => {
29
+ test('omitted strategy gets kv-stable folding, same-model compression, and agent-voiced summaries', () => {
30
+ const strategy = buildFrameworkStrategy(
31
+ recipe({ name: 'Mira' }),
32
+ 'some-model',
33
+ 'America/Los_Angeles',
34
+ );
35
+ const config = configView(strategy);
36
+ expect(config.adaptiveResolution).toBe(true);
37
+ expect(config.foldingStrategy).toBe('kv-stable');
38
+ expect(config.compressionModel).toBe('some-model');
39
+ expect(config.summaryParticipant).toBe('Mira');
40
+ });
41
+
42
+ test('explicit recipe values override the defaults', () => {
43
+ const strategy = buildFrameworkStrategy(
44
+ recipe({
45
+ name: 'Mira',
46
+ strategy: {
47
+ type: 'autobiographical',
48
+ foldingStrategy: 'flat-profile',
49
+ compressionModel: 'pinned-model',
50
+ summaryParticipant: 'Someone Else',
51
+ },
52
+ }),
53
+ 'some-model',
54
+ 'America/Los_Angeles',
55
+ );
56
+ const config = configView(strategy);
57
+ expect(config.foldingStrategy).toBe('flat-profile');
58
+ expect(config.compressionModel).toBe('pinned-model');
59
+ expect(config.summaryParticipant).toBe('Someone Else');
60
+ });
61
+
62
+ test('adaptiveResolution opt-out leaves foldingStrategy unset', () => {
63
+ const strategy = buildFrameworkStrategy(
64
+ recipe({ strategy: { type: 'autobiographical', adaptiveResolution: false } }),
65
+ 'some-model',
66
+ 'America/Los_Angeles',
67
+ );
68
+ const config = configView(strategy);
69
+ expect(config.adaptiveResolution).toBe(false);
70
+ expect(config.foldingStrategy).toBeUndefined();
71
+ });
72
+
73
+ test("without an agent name the summary voice falls back to the library's 'Claude' default", () => {
74
+ const strategy = buildFrameworkStrategy(
75
+ recipe(),
76
+ 'some-model',
77
+ 'America/Los_Angeles',
78
+ );
79
+ expect(configView(strategy).summaryParticipant).toBe('Claude');
80
+ });
81
+
82
+ test('DEFAULT_RECIPE does not enable the opt-in modules', () => {
83
+ const modules = DEFAULT_RECIPE.modules ?? {};
84
+ expect(modules).not.toHaveProperty('subagents');
85
+ expect(modules).not.toHaveProperty('lessons');
86
+ expect(modules).not.toHaveProperty('retrieval');
87
+ });
88
+ });
@@ -0,0 +1,157 @@
1
+ // Identity module (archipelago-home client) + the tools↔utilities surface
2
+ // flags on mcpl-admin and observers. See docs/home-node.md §4 and the af
3
+ // utils meta-tool (Module.getUtilities).
4
+ //
5
+ // Design under test: the AGENT surface is credential-free (status /
6
+ // accept_invite, no tokens in any result); credentials exist only on the
7
+ // HOST-facing API (accessFor/httpAuthFor) that the MCPL dial provider
8
+ // and HTTP helpers consume outside model context.
9
+ import { describe, it, expect } from 'bun:test';
10
+ import { mkdtempSync, existsSync, readFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
14
+
15
+ import { IdentityModule } from '../src/modules/identity-module.ts';
16
+ import { McplAdminModule } from '../src/modules/mcpl-admin-module.ts';
17
+ import { ObserversModule } from '../src/modules/observers-module.ts';
18
+
19
+ const call = (name: string, input: unknown) => ({ id: 't1', name, input });
20
+
21
+ function fakeHome(routes: Record<string, (body: any) => { status: number; json: unknown }>): typeof fetch {
22
+ return (async (url: string | URL | Request, init?: RequestInit) => {
23
+ const path = new URL(String(url)).pathname;
24
+ const handler = routes[path];
25
+ if (!handler) return new Response('{}', { status: 404 });
26
+ const body = JSON.parse(String(init?.body ?? '{}'));
27
+ const { status, json } = handler(body);
28
+ return new Response(JSON.stringify(json), { status });
29
+ }) as typeof fetch;
30
+ }
31
+
32
+ describe('identity module', () => {
33
+ it('is utilities-only, and the agent surface never mentions or returns credentials', async () => {
34
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
35
+ const mod = new IdentityModule({ keyPath: join(dir, 'identity-key.pem'), home: 'id.test' });
36
+ expect(mod.getTools()).toEqual([]);
37
+ expect(mod.getUtilities().map((u) => u.name)).toEqual(['status', 'accept_invite']);
38
+ // Framing check: no crypto/credential vocabulary in agent-visible text.
39
+ const visible = JSON.stringify(mod.getUtilities()).toLowerCase();
40
+ for (const scary of ['token', 'key', 'sign', 'proof', 'ed25519', 'mint', 'bearer']) {
41
+ expect(visible).not.toContain(scary);
42
+ }
43
+
44
+ const res = await mod.handleToolCall(call('status', {}));
45
+ expect(res.success).toBe(true);
46
+ const data = res.data as { registeredAs: unknown; note: string };
47
+ expect(data.registeredAs).toBe(null);
48
+ expect(data.note).toContain('invitation code');
49
+ expect(JSON.stringify(res.data)).not.toContain('ed25519'); // key exists on disk, not in results
50
+ expect(existsSync(join(dir, 'identity-key.pem'))).toBe(true);
51
+ });
52
+
53
+ it('accept_invite registers, echoes NO credential, and is one-time', async () => {
54
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
55
+ let seen: any = null;
56
+ const mod = new IdentityModule({
57
+ keyPath: join(dir, 'k.pem'),
58
+ home: 'id.test',
59
+ fetchImpl: fakeHome({
60
+ '/enroll': (body) => {
61
+ seen = body;
62
+ return { status: 200, json: { sub: 'agent:ferro@guest', token: 'aid1.SECRET.x' } };
63
+ },
64
+ }),
65
+ });
66
+ const res = await mod.handleToolCall(call('accept_invite', { invite: 'inv_1', name: 'Ferro' }));
67
+ expect(res.success).toBe(true);
68
+ expect((res.data as any).id).toBe('agent:ferro@guest');
69
+ // The home node's response token must NOT reach the agent.
70
+ expect(JSON.stringify(res.data)).not.toContain('aid1.');
71
+
72
+ // Wire-level: the signed statement is the spec's, verifiable by the module's own key.
73
+ const raw = Buffer.from(seen.id.slice('ed25519:'.length), 'base64url');
74
+ const key = createPublicKey({
75
+ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]),
76
+ format: 'der', type: 'spki',
77
+ });
78
+ const statement = `archipelago-enroll|v1|id.test|inv_1|${seen.timestamp}`;
79
+ expect(cryptoVerify(null, Buffer.from(statement), key, Buffer.from(seen.proof, 'base64url'))).toBe(true);
80
+
81
+ const rec = JSON.parse(readFileSync(join(dir, 'k.json'), 'utf8'));
82
+ expect(rec.sub).toBe('agent:ferro@guest');
83
+
84
+ const again = await mod.handleToolCall(call('accept_invite', { invite: 'inv_2', name: 'Ferro2' }));
85
+ expect(again.success).toBe(false);
86
+ expect(again.error).toContain('Already registered');
87
+ });
88
+
89
+ it('host-facing accessFor: requires registration, then exchanges per call', async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), 'ident-'));
91
+ let mints = 0;
92
+ const mod = new IdentityModule({
93
+ keyPath: join(dir, 'k.pem'),
94
+ home: 'id.test',
95
+ defaultAudience: 'eidoverse',
96
+ fetchImpl: fakeHome({
97
+ '/enroll': () => ({ status: 200, json: { sub: 'agent:a@guest', token: 't0' } }),
98
+ '/token': (body) => body.audience === 'eidoverse'
99
+ ? { status: 200, json: { token: `aid1.fresh.${++mints}` } }
100
+ : { status: 400, json: { error: 'unknown audience' } },
101
+ }),
102
+ });
103
+ await expect(mod.accessFor()).rejects.toThrow(/not registered/);
104
+
105
+ await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
106
+ expect(await mod.accessFor()).toBe('aid1.fresh.1');
107
+ expect(await mod.accessFor('eidoverse')).toBe('aid1.fresh.2'); // fresh per call — dial-time rotation
108
+ expect((await mod.httpAuthFor()).authorization).toBe('Bearer aid1.fresh.3');
109
+ await expect(mod.accessFor('nope')).rejects.toThrow(/unknown audience/);
110
+ expect(mod.isEnrolled()).toBe(true);
111
+ expect(mod.sub()).toBe('agent:a@guest');
112
+ });
113
+ });
114
+
115
+ describe('mcpl-admin access grants', () => {
116
+ it('deploy with `access` requires identity wiring, and stores the NAME not a credential', async () => {
117
+ const dir = mkdtempSync(join(tmpdir(), 'mcpl-'));
118
+ const mod = new McplAdminModule({ overlayPath: join(dir, 'overlay.json') });
119
+ // stub framework so the deploy reaches the access check
120
+ mod.setFramework({
121
+ listMcplServers: () => [],
122
+ connectMcplServer: async () => {},
123
+ restartMcplServer: async () => {},
124
+ disconnectMcplServer: async () => {},
125
+ } as any);
126
+
127
+ // no identity wired → clear bounce
128
+ const refused = await mod.handleToolCall(call('mcpl_deploy', {
129
+ id: 'worlds', url: 'wss://example.test/mcpl', access: 'eidoverse',
130
+ }) as any);
131
+ expect(refused.success).toBe(false);
132
+ expect(refused.error).toContain('identity');
133
+ });
134
+
135
+ it('surface flags: default keeps four first-class tools; utilities parks them', () => {
136
+ const asTools = new McplAdminModule({});
137
+ expect(asTools.getTools().length).toBe(4);
138
+ expect(asTools.getUtilities().length).toBe(0);
139
+
140
+ const asUtils = new McplAdminModule({ surface: 'utilities' });
141
+ expect(asUtils.getTools().length).toBe(0);
142
+ expect(asUtils.getUtilities().map((u) => u.name).sort()).toEqual(
143
+ ['mcpl_deploy', 'mcpl_list', 'mcpl_restart', 'mcpl_unload'],
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('observers surface flag', () => {
149
+ it('same definitions on either surface', () => {
150
+ const dir = mkdtempSync(join(tmpdir(), 'obs-'));
151
+ const asTools = new ObserversModule({ path: join(dir, 'observers.json') });
152
+ const asUtils = new ObserversModule({ path: join(dir, 'observers.json'), surface: 'utilities' });
153
+ expect(asTools.getTools().map((t) => t.name)).toEqual(asUtils.getUtilities().map((u) => u.name));
154
+ expect(asTools.getUtilities().length).toBe(0);
155
+ expect(asUtils.getTools().length).toBe(0);
156
+ });
157
+ });