@pi-unipi/background-tasks 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,392 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { buildDelegateSeed, verifyDelegateSeedBytes } from '../delegate/seed.js';
7
+ import {
8
+ DELEGATE_CONTEXT_POLICY_ID,
9
+ DELEGATE_SEED_SCHEMA_VERSION,
10
+ DelegateError,
11
+ type DelegateLimits,
12
+ type DelegatePinnedRoute,
13
+ } from '../delegate/types.js';
14
+ import {
15
+ assistantMessage,
16
+ sessionWith,
17
+ toolResultMessage,
18
+ userMessage,
19
+ } from './helpers/fusion-canonical.js';
20
+ import { buildDeterministicFixtureSeed } from './helpers/delegate-deterministic-seed.js';
21
+
22
+ const ROUTE: DelegatePinnedRoute = {
23
+ provider: 'anthropic',
24
+ model: 'claude-test',
25
+ qualified_id: 'anthropic/claude-test',
26
+ context_window_tokens: 200_000,
27
+ thinking_level: 'medium',
28
+ origin: 'parent_current',
29
+ };
30
+
31
+ const LIMITS: DelegateLimits = {
32
+ max_turns: 24,
33
+ max_tool_calls: 120,
34
+ timeout_seconds: 900,
35
+ max_tool_result_bytes: 65_536,
36
+ max_total_tool_output_bytes: 67_108_864,
37
+ max_answer_bytes: 4_194_304,
38
+ allowed_input_tokens: 171_712,
39
+ };
40
+
41
+ function sha256(value: string): string {
42
+ return createHash('sha256').update(value, 'utf8').digest('hex');
43
+ }
44
+
45
+ function build(
46
+ messages: Parameters<typeof sessionWith>[0],
47
+ overrides: Partial<Parameters<typeof buildDelegateSeed>[1]> = {},
48
+ systemPrompt = 'parent system prompt',
49
+ ) {
50
+ return buildFromSession(sessionWith(messages), overrides, systemPrompt);
51
+ }
52
+
53
+ function buildFromSession(
54
+ session: ReturnType<typeof sessionWith>,
55
+ overrides: Partial<Parameters<typeof buildDelegateSeed>[1]> = {},
56
+ systemPrompt = 'parent system prompt',
57
+ ) {
58
+ return buildDelegateSeed(
59
+ { cwd: '/tmp/project', sessionManager: session, getSystemPrompt: () => systemPrompt },
60
+ {
61
+ taskId: 'd0123456789abcdef0123456789abcdef',
62
+ launchNonce: 'ffeeddccbbaa99887766554433221100',
63
+ toolCallId: 'delegate-call-1',
64
+ directive: 'investigate the failing gate',
65
+ capability: 'inspect',
66
+ extensionMode: 'isolated',
67
+ route: ROUTE,
68
+ limits: LIMITS,
69
+ ...overrides,
70
+ },
71
+ );
72
+ }
73
+
74
+ const CONVERSATION = [
75
+ userMessage('VISIBLE_USER_ONE about the failing test'),
76
+ assistantMessage([
77
+ { type: 'text', text: 'VISIBLE_ASSISTANT_ONE here is my reading' },
78
+ { type: 'thinking', thinking: 'SECRET_THINKING_PAYLOAD', thinkingSignature: '' },
79
+ { type: 'toolCall', id: 'c1', name: 'read', arguments: { path: '/SECRET_TOOL_ARGUMENT' } },
80
+ ]),
81
+ toolResultMessage('c1', 'read', [{ type: 'text', text: 'SECRET_TOOL_RESULT_PAYLOAD' }]),
82
+ userMessage('VISIBLE_USER_TWO follow-up'),
83
+ ];
84
+
85
+ void describe('delegate seed construction', () => {
86
+ void it('preserves visible user and assistant text verbatim', () => {
87
+ const built = build(CONVERSATION);
88
+ const texts = built.seed.conversation_projection.entries.flatMap((entry) =>
89
+ entry.kind === 'text' ? [entry.text] : [],
90
+ );
91
+ assert.deepEqual(texts, [
92
+ 'VISIBLE_USER_ONE about the failing test',
93
+ 'VISIBLE_ASSISTANT_ONE here is my reading',
94
+ 'VISIBLE_USER_TWO follow-up',
95
+ ]);
96
+ });
97
+
98
+ void it('excludes thinking and tool payloads from the seed entirely', () => {
99
+ const built = build(CONVERSATION);
100
+ for (const secret of [
101
+ 'SECRET_THINKING_PAYLOAD',
102
+ 'SECRET_TOOL_ARGUMENT',
103
+ 'SECRET_TOOL_RESULT_PAYLOAD',
104
+ ]) {
105
+ assert.ok(
106
+ !built.serialized.includes(secret),
107
+ `seed must not contain omitted payload ${secret}`,
108
+ );
109
+ }
110
+ const omissions = built.seed.conversation_projection.entries.filter(
111
+ (entry) => entry.kind === 'omitted_activity',
112
+ );
113
+ assert.equal(omissions.length, 1, 'contiguous omissions collapse into one receipt');
114
+ });
115
+
116
+ void it('accounts for every omitted payload with a hash rather than dropping it', () => {
117
+ const built = build(CONVERSATION);
118
+ assert.equal(built.ledger.entries.length, 3);
119
+ for (const row of built.ledger.entries) {
120
+ assert.match(row.payload_sha256, /^[0-9a-f]{64}$/);
121
+ assert.ok(row.payload_bytes > 0);
122
+ }
123
+ assert.equal(
124
+ built.seed.conversation_projection.accounting.ledger_root_sha256,
125
+ built.ledger.root_sha256,
126
+ );
127
+ });
128
+
129
+ void it('represents images as markers and never as raw bytes', () => {
130
+ const built = build([
131
+ userMessage([
132
+ { type: 'text', text: 'look at this' },
133
+ { type: 'image', mimeType: 'image/png', data: 'RAWIMAGEBYTES' },
134
+ ]),
135
+ toolResultMessage('c1', 'read', [
136
+ { type: 'image', mimeType: 'image/jpeg', data: 'RAWTOOLIMAGE' },
137
+ ]),
138
+ ]);
139
+ assert.ok(!built.serialized.includes('RAWIMAGEBYTES'));
140
+ assert.ok(!built.serialized.includes('RAWTOOLIMAGE'));
141
+ assert.ok(built.serialized.includes('[Image omitted from fusion text transcript: image/png]'));
142
+ assert.equal(built.seed.conversation_projection.accounting.omitted_tool_result_image_count, 1);
143
+ });
144
+
145
+ void it('excludes the in-flight delegate call and every sibling call in that message', () => {
146
+ const built = build([
147
+ userMessage('KEEP_THIS_USER_TEXT'),
148
+ assistantMessage([{ type: 'text', text: 'KEEP_THIS_ASSISTANT_TEXT' }]),
149
+ assistantMessage([
150
+ { type: 'toolCall', id: 'delegate-call-1', name: 'bg_delegate', arguments: { prompt: 'a' } },
151
+ { type: 'toolCall', id: 'delegate-call-2', name: 'bg_delegate', arguments: { prompt: 'b' } },
152
+ { type: 'toolCall', id: 'sibling-read', name: 'read', arguments: { path: '/SIBLING' } },
153
+ ]),
154
+ ]);
155
+ assert.equal(built.seed.conversation_projection.branch_filter.active_tool_call_leaf_excluded, true);
156
+ assert.ok(!built.serialized.includes('SIBLING'));
157
+ assert.ok(!built.serialized.includes('delegate-call-2'));
158
+ assert.equal(built.seed.conversation_projection.accounting.omitted_tool_call_count, 0);
159
+ const texts = built.seed.conversation_projection.entries.flatMap((entry) =>
160
+ entry.kind === 'text' ? [entry.text] : [],
161
+ );
162
+ assert.deepEqual(texts, ['KEEP_THIS_USER_TEXT', 'KEEP_THIS_ASSISTANT_TEXT']);
163
+ });
164
+
165
+ void it('gives sibling delegates in one message byte-identical projected history', () => {
166
+ // One shared session, exactly as two sibling calls in one assistant message
167
+ // would observe it.
168
+ const session = sessionWith([
169
+ userMessage('SHARED_HISTORY'),
170
+ assistantMessage([
171
+ { type: 'toolCall', id: 'delegate-call-1', name: 'bg_delegate', arguments: { prompt: 'a' } },
172
+ { type: 'toolCall', id: 'delegate-call-2', name: 'bg_delegate', arguments: { prompt: 'b' } },
173
+ ]),
174
+ ]);
175
+ const first = buildFromSession(session, {
176
+ toolCallId: 'delegate-call-1',
177
+ directive: 'first task',
178
+ });
179
+ const second = buildFromSession(session, {
180
+ toolCallId: 'delegate-call-2',
181
+ directive: 'second task',
182
+ });
183
+ assert.equal(
184
+ first.seed.parent_leaf_id,
185
+ second.seed.parent_leaf_id,
186
+ 'siblings must be projected from the same parent leaf',
187
+ );
188
+ assert.deepEqual(
189
+ first.seed.conversation_projection.entries,
190
+ second.seed.conversation_projection.entries,
191
+ 'both siblings must observe the same projected history',
192
+ );
193
+ assert.equal(
194
+ first.seed.conversation_projection.accounting.ledger_root_sha256,
195
+ second.seed.conversation_projection.accounting.ledger_root_sha256,
196
+ );
197
+ // Their directives differ, so the seeds themselves must differ.
198
+ assert.notEqual(first.sha256, second.sha256);
199
+ });
200
+
201
+ void it('treats the prompt as authoritative and preserves it exactly', () => {
202
+ const directive = 'exact directive with "quotes", \\backslash, \u2028 and emoji 👩‍👩‍👧‍👦';
203
+ const built = build(CONVERSATION, { directive });
204
+ assert.equal(built.seed.directive.text, directive);
205
+ assert.equal(built.seed.directive.sha256, sha256(directive));
206
+ assert.equal(built.seed.directive.authority, 'explicit_text');
207
+ });
208
+
209
+ void it('is byte-identical across repeated construction from one session', () => {
210
+ const session = sessionWith(CONVERSATION);
211
+ const first = buildFromSession(session);
212
+ const second = buildFromSession(session);
213
+ const third = buildFromSession(session);
214
+ assert.equal(first.serialized, second.serialized);
215
+ assert.equal(second.serialized, third.serialized);
216
+ assert.equal(first.sha256, third.sha256);
217
+ });
218
+
219
+ void it('is byte-identical across separate processes', () => {
220
+ // Pi assigns random ids to session entries, so `parent_leaf_id` is genuinely
221
+ // session-specific provenance rather than a function of the conversation.
222
+ // The determinism that must hold is of the projection itself: the same
223
+ // messages and the same leaf must always produce the same bytes. The
224
+ // subprocess therefore uses a fixed leaf id, and the in-process comparison
225
+ // below uses the same fixture, so a real byte drift cannot hide behind an
226
+ // id that was expected to differ.
227
+ const script = fileURLToPath(new URL('./helpers/delegate-seed-subprocess.ts', import.meta.url));
228
+ const run = (): string =>
229
+ execFileSync(process.execPath, ['--import', 'tsx', script], {
230
+ encoding: 'utf8',
231
+ env: { ...process.env, NODE_NO_WARNINGS: '1' },
232
+ }).trim();
233
+ const first = run();
234
+ const second = run();
235
+ assert.equal(first, second, 'seed bytes must not vary across processes');
236
+ assert.equal(
237
+ first,
238
+ buildDeterministicFixtureSeed().sha256,
239
+ 'the in-process build must produce the same bytes as a separate process',
240
+ );
241
+ });
242
+
243
+ void it('binds the seed to the exact parent leaf it was projected from', () => {
244
+ const session = sessionWith(CONVERSATION);
245
+ const built = buildFromSession(session);
246
+ assert.equal(built.seed.parent_leaf_id, session.getLeafId());
247
+ });
248
+
249
+ void it('uses the delegate policy id and never claims Fusion provenance', () => {
250
+ const built = build(CONVERSATION);
251
+ assert.equal(built.seed.schema_version, DELEGATE_SEED_SCHEMA_VERSION);
252
+ assert.equal(built.seed.conversation_projection.policy.id, DELEGATE_CONTEXT_POLICY_ID);
253
+ assert.ok(!built.serialized.includes('fusion-input.v4'));
254
+ assert.ok(!built.serialized.includes('fusion-tool-explicit'));
255
+ });
256
+
257
+ void it('never emits a payload preview', () => {
258
+ const built = build(CONVERSATION);
259
+ assert.equal(built.seed.conversation_projection.policy.tool_payload_preview_bytes, 0);
260
+ });
261
+
262
+ void it('refuses a blank prompt loudly with zero side effects', () => {
263
+ for (const directive of ['', ' ', '\n\t']) {
264
+ assert.throws(
265
+ () => build(CONVERSATION, { directive }),
266
+ (error: unknown) =>
267
+ error instanceof DelegateError &&
268
+ error.code === 'invalid_arguments' &&
269
+ error.childCreated === false,
270
+ );
271
+ }
272
+ });
273
+ });
274
+
275
+ void describe('delegate seed receive-side verification', () => {
276
+ void it('accepts the exact persisted bytes', () => {
277
+ const built = build(CONVERSATION);
278
+ const seed = verifyDelegateSeedBytes(built.serialized, {
279
+ sha256: built.sha256,
280
+ taskId: built.seed.task_id,
281
+ launchNonce: built.seed.launch_nonce,
282
+ });
283
+ assert.equal(seed.directive.text, built.seed.directive.text);
284
+ assert.equal(seed.extension_mode, 'isolated');
285
+ assert.equal(seed.route.qualified_id, ROUTE.qualified_id);
286
+ assert.equal(seed.limits.allowed_input_tokens, LIMITS.allowed_input_tokens);
287
+ });
288
+
289
+ void it('rejects a single mutated byte', () => {
290
+ const built = build(CONVERSATION);
291
+ const mutated = built.serialized.replace('VISIBLE_USER_ONE', 'VISIBLE_USER_0NE');
292
+ assert.notEqual(mutated, built.serialized);
293
+ assert.throws(
294
+ () =>
295
+ verifyDelegateSeedBytes(mutated, {
296
+ sha256: built.sha256,
297
+ taskId: built.seed.task_id,
298
+ launchNonce: built.seed.launch_nonce,
299
+ }),
300
+ (error: unknown) => error instanceof DelegateError && error.code === 'seed_hash_mismatch',
301
+ );
302
+ });
303
+
304
+ void it('rejects a seed carrying another task identity', () => {
305
+ const built = build(CONVERSATION);
306
+ assert.throws(
307
+ () =>
308
+ verifyDelegateSeedBytes(built.serialized, {
309
+ sha256: built.sha256,
310
+ taskId: 'dffffffffffffffffffffffffffffffff',
311
+ launchNonce: built.seed.launch_nonce,
312
+ }),
313
+ (error: unknown) => error instanceof DelegateError && error.code === 'seed_hash_mismatch',
314
+ );
315
+ });
316
+
317
+ void it('rejects a structurally malformed seed even when its hash matches', () => {
318
+ const malformed = JSON.stringify({
319
+ schema_version: DELEGATE_SEED_SCHEMA_VERSION,
320
+ task_id: 'd0123456789abcdef0123456789abcdef',
321
+ launch_nonce: 'ffeeddccbbaa99887766554433221100',
322
+ capability: 'inspect',
323
+ // route deliberately absent
324
+ });
325
+ assert.throws(
326
+ () =>
327
+ verifyDelegateSeedBytes(malformed, {
328
+ sha256: sha256(malformed),
329
+ taskId: 'd0123456789abcdef0123456789abcdef',
330
+ launchNonce: 'ffeeddccbbaa99887766554433221100',
331
+ }),
332
+ (error: unknown) => error instanceof DelegateError && error.code === 'seed_hash_mismatch',
333
+ );
334
+ });
335
+
336
+ void it('rejects a directive whose hash does not match its text', () => {
337
+ const built = build(CONVERSATION);
338
+ const tampered = JSON.parse(built.serialized) as Record<string, unknown>;
339
+ const directive = tampered['directive'];
340
+ assert.ok(typeof directive === 'object' && directive !== null);
341
+ Reflect.set(directive, 'text', 'a different directive entirely');
342
+ const serialized = JSON.stringify(tampered);
343
+ assert.throws(
344
+ () =>
345
+ verifyDelegateSeedBytes(serialized, {
346
+ sha256: sha256(serialized),
347
+ taskId: built.seed.task_id,
348
+ launchNonce: built.seed.launch_nonce,
349
+ }),
350
+ (error: unknown) => error instanceof DelegateError && error.code === 'seed_hash_mismatch',
351
+ );
352
+ });
353
+
354
+ void it('binds the selected extension mode into the verified seed', () => {
355
+ const built = build(CONVERSATION, { extensionMode: 'ambient' });
356
+ assert.equal(built.seed.extension_mode, 'ambient');
357
+ assert.match(built.serialized, /"extension_mode":"ambient"/);
358
+ });
359
+
360
+ void it('rejects an unknown extension mode even when its hash matches', () => {
361
+ const built = build(CONVERSATION);
362
+ const tampered = JSON.parse(built.serialized) as Record<string, unknown>;
363
+ tampered['extension_mode'] = 'custom-path';
364
+ const serialized = JSON.stringify(tampered);
365
+ assert.throws(
366
+ () =>
367
+ verifyDelegateSeedBytes(serialized, {
368
+ sha256: sha256(serialized),
369
+ taskId: built.seed.task_id,
370
+ launchNonce: built.seed.launch_nonce,
371
+ }),
372
+ (error: unknown) => error instanceof DelegateError && error.code === 'seed_hash_mismatch',
373
+ );
374
+ });
375
+
376
+ void it('rejects a capability the child cannot enforce', () => {
377
+ const built = build(CONVERSATION);
378
+ const tampered = JSON.parse(built.serialized) as Record<string, unknown>;
379
+ tampered['capability'] = 'write';
380
+ const serialized = JSON.stringify(tampered);
381
+ assert.throws(
382
+ () =>
383
+ verifyDelegateSeedBytes(serialized, {
384
+ sha256: sha256(serialized),
385
+ taskId: built.seed.task_id,
386
+ launchNonce: built.seed.launch_nonce,
387
+ }),
388
+ (error: unknown) =>
389
+ error instanceof DelegateError && error.code === 'delegate_isolation_unsupported',
390
+ );
391
+ });
392
+ });