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