@frockbot/kernel-composition 0.1.4 → 0.2.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.
@@ -9,6 +9,10 @@
9
9
  //
10
10
  // The wrapper is emitted as plain JavaScript because it is a module in the
11
11
  // loaded Worker's module map, not a source file this repository compiles.
12
+ import {
13
+ BOT_ISOLATE_HOOK_EVENTS_V1,
14
+ type BotPackageContextV1,
15
+ } from "@frockbot/kernel-contracts";
12
16
 
13
17
  /**
14
18
  * The deadline guard, shared verbatim between the generated wrapper and the
@@ -35,6 +39,7 @@ export const BOT_ISOLATE_DEADLINE_SOURCE = `function withIsolateDeadline(work, d
35
39
  * the boundary is crossed in both directions and both sides decode.
36
40
  */
37
41
  export const BOT_ISOLATE_INVOCATION_SOURCE = `var TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
42
+ var HOOK_EVENTS = ${JSON.stringify(BOT_ISOLATE_HOOK_EVENTS_V1)};
38
43
  var INVOCATION_KEYS = [
39
44
  "schemaVersion",
40
45
  "tool",
@@ -72,6 +77,42 @@ function decodeInvocation(value) {
72
77
  }
73
78
  }
74
79
  return value;
80
+ }
81
+ var HOOK_INVOCATION_KEYS = [
82
+ "schemaVersion",
83
+ "event",
84
+ "payload",
85
+ "botId",
86
+ "sessionId",
87
+ "runId",
88
+ "turnId",
89
+ "generationId",
90
+ "deadlineMs",
91
+ ];
92
+ function decodeHookInvocation(value) {
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
94
+ throw new Error("isolate hook invocation must be an object");
95
+ }
96
+ if (
97
+ Object.keys(value).length !== HOOK_INVOCATION_KEYS.length ||
98
+ !HOOK_INVOCATION_KEYS.every(function (key) {
99
+ return Object.hasOwn(value, key);
100
+ })
101
+ ) {
102
+ throw new Error("isolate hook invocation has invalid fields");
103
+ }
104
+ if (value.schemaVersion !== 1 || !HOOK_EVENTS.includes(value.event)) {
105
+ throw new Error("isolate hook invocation is unsupported");
106
+ }
107
+ if (!value.payload || typeof value.payload !== "object" || Array.isArray(value.payload)) {
108
+ throw new Error("isolate hook invocation payload is invalid");
109
+ }
110
+ for (const key of ["botId", "sessionId", "runId", "turnId", "generationId"]) {
111
+ if (typeof value[key] !== "string" || value[key].length === 0) {
112
+ throw new Error("isolate hook invocation " + key + " is invalid");
113
+ }
114
+ }
115
+ return value;
75
116
  }`;
76
117
 
77
118
  /** Decodes one NDJSON line of the `invokeModel` byte stream inside the isolate. */
@@ -99,6 +140,86 @@ export const BOT_ISOLATE_MODEL_SOURCE = `async function* modelEvents(stream) {
99
140
  }
100
141
  }`;
101
142
 
143
+ const BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1 = {
144
+ tool: "invocation.tool",
145
+ event: "invocation.event",
146
+ botId: "invocation.botId",
147
+ sessionId: "invocation.sessionId",
148
+ runId: "invocation.runId",
149
+ turnId: "invocation.turnId",
150
+ generationId: "invocation.generationId",
151
+ packageId: "env.IDENTITY.packageId",
152
+ deadlineMs: "invocation.deadlineMs",
153
+ bindings: "Object.keys(env).sort()",
154
+ capabilities: `{
155
+ list: function () {
156
+ return capabilities.list();
157
+ },
158
+ }`,
159
+ model: `{
160
+ invoke: async function (request) {
161
+ const outcome = await capabilities.invokeModel(request);
162
+ if (!outcome || outcome.status !== "streaming") return outcome;
163
+ return {
164
+ status: "streaming",
165
+ requestId: outcome.requestId,
166
+ events: modelEvents(outcome.events),
167
+ };
168
+ },
169
+ }`,
170
+ tools: `{
171
+ invoke: function (request) {
172
+ return capabilities.invokeTool(request);
173
+ },
174
+ }`,
175
+ memory: `{
176
+ read: function (request) {
177
+ return capabilities.memoryRead(request);
178
+ },
179
+ write: function (request) {
180
+ return capabilities.memoryWrite(request);
181
+ },
182
+ forget: function (request) {
183
+ return capabilities.memoryForget(request);
184
+ },
185
+ }`,
186
+ workspace: `{
187
+ read: function (path) {
188
+ return capabilities.workspaceRead(path);
189
+ },
190
+ list: function (request) {
191
+ return capabilities.workspaceList(request);
192
+ },
193
+ stat: function (path) {
194
+ return capabilities.workspaceStat(path);
195
+ },
196
+ write: function (request) {
197
+ return capabilities.workspaceWrite(request);
198
+ },
199
+ delete: function (request) {
200
+ return capabilities.workspaceDelete(request);
201
+ },
202
+ }`,
203
+ connection:
204
+ "function (connectionId) { return capabilities.connection(connectionId); }",
205
+ notify: "function (request) { return capabilities.notify(request); }",
206
+ schedule: "function (request) { return capabilities.schedule(request); }",
207
+ } satisfies Record<keyof BotPackageContextV1, string>;
208
+
209
+ /** The keys the generated wrapper actually places on `ctx`. */
210
+ export const BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1 = Object.keys(
211
+ BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1,
212
+ ) as Array<keyof BotPackageContextV1>;
213
+
214
+ export const BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1 = `function narrowContext(env, invocation) {
215
+ const capabilities = env.CAPABILITIES;
216
+ return {
217
+ ${Object.entries(BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1)
218
+ .map(([key, source]) => ` ${JSON.stringify(key)}: ${source},`)
219
+ .join("\n")}
220
+ };
221
+ }`;
222
+
102
223
  /**
103
224
  * The wrapper module text. Content-addressed with `package.js`; bump
104
225
  * `BOT_ISOLATE_WRAPPER_VERSION` whenever this string changes so the mounted
@@ -108,7 +229,7 @@ export const BOT_ISOLATE_WRAPPER_SOURCE = `// Generated by @frockbot/kernel-comp
108
229
  import { WorkerEntrypoint } from "cloudflare:workers";
109
230
  import * as botPackage from "./package.js";
110
231
 
111
- const CONTRACT_VERSION = 2;
232
+ const CONTRACT_VERSION = 3;
112
233
 
113
234
  ${BOT_ISOLATE_DEADLINE_SOURCE}
114
235
 
@@ -155,38 +276,24 @@ function declaredTools() {
155
276
  });
156
277
  }
157
278
 
158
- function narrowContext(env, invocation) {
159
- const capabilities = env.CAPABILITIES;
160
- return {
161
- tool: invocation.tool,
162
- botId: invocation.botId,
163
- sessionId: invocation.sessionId,
164
- runId: invocation.runId,
165
- turnId: invocation.turnId,
166
- generationId: invocation.generationId,
167
- packageId: env.IDENTITY.packageId,
168
- deadlineMs: invocation.deadlineMs,
169
- // What this isolate holds, by name. Everything else is out of scope:
170
- // globalOutbound is null and no host binding is in env.
171
- bindings: Object.keys(env).sort(),
172
- listCapabilities: function () {
173
- return capabilities.list();
174
- },
175
- requestAuthority: function (request) {
176
- return capabilities.requestAuthority(request);
177
- },
178
- invokeModel: async function (request) {
179
- const outcome = await capabilities.invokeModel(request);
180
- if (!outcome || outcome.status !== "streaming") return outcome;
181
- return {
182
- status: "streaming",
183
- requestId: outcome.requestId,
184
- events: modelEvents(outcome.events),
185
- };
186
- },
187
- };
279
+ function declaredHooks() {
280
+ if (botPackage.hooks === undefined) return [];
281
+ if (!botPackage.hooks || typeof botPackage.hooks !== "object" || Array.isArray(botPackage.hooks)) {
282
+ throw new Error('package.js "hooks" must be an object');
283
+ }
284
+ return Object.keys(botPackage.hooks).map(function (event) {
285
+ if (!HOOK_EVENTS.includes(event)) {
286
+ throw new Error('package.js declared an unsupported hook "' + event + '"');
287
+ }
288
+ if (typeof botPackage.hooks[event] !== "function") {
289
+ throw new Error('package.js hook "' + event + '" must be a function');
290
+ }
291
+ return event;
292
+ });
188
293
  }
189
294
 
295
+ ${BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1}
296
+
190
297
  export default class extends WorkerEntrypoint {
191
298
  async health() {
192
299
  return {
@@ -195,6 +302,7 @@ export default class extends WorkerEntrypoint {
195
302
  packageId: this.env.IDENTITY.packageId,
196
303
  contractVersion: CONTRACT_VERSION,
197
304
  tools: declaredTools(),
305
+ hooks: declaredHooks(),
198
306
  };
199
307
  }
200
308
 
@@ -227,11 +335,26 @@ export default class extends WorkerEntrypoint {
227
335
  };
228
336
  }
229
337
  }
338
+
339
+ async hook(rawInvocation) {
340
+ const invocation = decodeHookInvocation(rawInvocation);
341
+ const hooks = declaredHooks();
342
+ if (!hooks.includes(invocation.event)) {
343
+ throw new Error('package.js did not declare hook "' + invocation.event + '"');
344
+ }
345
+ const context = narrowContext(this.env, invocation);
346
+ const replacement = await withIsolateDeadline(function () {
347
+ return botPackage.hooks[invocation.event](invocation.payload, context);
348
+ }, invocation.deadlineMs);
349
+ return replacement === undefined
350
+ ? { schemaVersion: 1, status: "unchanged" }
351
+ : { schemaVersion: 1, status: "replaced", replacement: replacement };
352
+ }
230
353
  }
231
354
  `;
232
355
 
233
356
  /** Bumped with any change to the wrapper text; folded into the loader id. */
234
- export const BOT_ISOLATE_WRAPPER_VERSION = "wrapper-v2";
357
+ export const BOT_ISOLATE_WRAPPER_VERSION = "wrapper-v4";
235
358
 
236
359
  export const BOT_ISOLATE_MAIN_MODULE = "index.js";
237
360
  export const BOT_ISOLATE_PACKAGE_MODULE = "package.js";
package/src/manifest.ts CHANGED
@@ -1,14 +1,19 @@
1
- import { decodeTurnTypeV1, type TurnTypeV1 } from "@frockbot/kernel-contracts";
1
+ import {
2
+ isBotIsolateHookEventNameV1,
3
+ decodeTurnTypeV1,
4
+ type BotIsolateHookEventNameV1,
5
+ type TurnTypeV1,
6
+ } from "@frockbot/kernel-contracts";
2
7
 
3
8
  /** The Contribution kinds a Package manifest can declare. */
4
9
  export type ManifestContributionKind =
5
10
  "backend" | "runtime" | "client" | "desktop" | "mobile";
6
11
 
7
12
  /**
8
- * Every execution host a Contribution can be mounted in. `bot-isolate` is not
9
- * manifest-declared: it is derived from a Composition member carrying an
10
- * immutable artifact, so a Package's provenance not its manifest decides
11
- * whether it runs in the kernel isolate or a loaded Dynamic Worker.
13
+ * Every execution host a Contribution can be mounted in. A Bot-authored
14
+ * manifest declares `bot-isolate`, but provenance and an immutable artifact
15
+ * still decide whether the host accepts it; a manifest never grants itself
16
+ * that execution authority.
12
17
  */
13
18
  export type ContributionKind = ManifestContributionKind | "bot-isolate";
14
19
 
@@ -19,6 +24,15 @@ export interface BackendContribution {
19
24
 
20
25
  export interface RuntimeContribution {
21
26
  entry: string;
27
+ /** Present only on a Bot-authored manifest; provenance still decides host authority. */
28
+ host?: "bot-isolate";
29
+ }
30
+
31
+ /** A Bot-authored manifest's durable declaration; isolate health supplies details at mount. */
32
+ export interface ManifestToolDeclaration {
33
+ name: string;
34
+ description: string;
35
+ inputSchema: Record<string, unknown>;
22
36
  }
23
37
 
24
38
  export interface ClientMount {
@@ -26,12 +40,37 @@ export interface ClientMount {
26
40
  order?: number;
27
41
  }
28
42
 
29
- export interface ClientContribution {
43
+ /** A reviewed first-party client module compiled into the hosted bundle. */
44
+ export interface ClientModuleContribution {
30
45
  entry: string;
31
46
  mounts: ClientMount[];
32
47
  outlets: string[];
33
48
  }
34
49
 
50
+ /** Immutable HTML bytes rendered only by the first-party sandbox host. */
51
+ export interface ClientIframeArtifactV1 {
52
+ contentHash: string;
53
+ size: number;
54
+ mediaType: "text/html";
55
+ bundlerVersion: string;
56
+ }
57
+
58
+ /** A non-first-party page. It never becomes JavaScript in the app origin. */
59
+ export interface ClientIframeContribution {
60
+ kind: "iframe";
61
+ artifact: ClientIframeArtifactV1;
62
+ mounts: ClientMount[];
63
+ }
64
+
65
+ export type ClientContribution =
66
+ ClientModuleContribution | ClientIframeContribution;
67
+
68
+ export function isClientIframeContribution(
69
+ contribution: ClientContribution,
70
+ ): contribution is ClientIframeContribution {
71
+ return "kind" in contribution && contribution.kind === "iframe";
72
+ }
73
+
35
74
  export interface DesktopContribution {
36
75
  entry: string;
37
76
  execution: "sandboxed-renderer" | "trusted-main";
@@ -146,6 +185,10 @@ export interface FrockBotManifest {
146
185
  };
147
186
  permissions: string[];
148
187
  configuration?: PackageConfiguration;
188
+ /** Present exactly when `contributions.runtime.host` is `bot-isolate`. */
189
+ tools?: ManifestToolDeclaration[];
190
+ /** Bot-isolate waterfalls the immutable artifact declares it exports. */
191
+ hooks?: BotIsolateHookEventNameV1[];
149
192
  }
150
193
 
151
194
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -166,6 +209,88 @@ function exactFields(
166
209
  }
167
210
  }
168
211
 
212
+ function validateManifestJson(value: unknown, label: string, depth = 0): void {
213
+ if (depth > 16) throw new Error(`${label} is too deeply nested`);
214
+ if (
215
+ value === null ||
216
+ typeof value === "string" ||
217
+ typeof value === "boolean" ||
218
+ (typeof value === "number" && Number.isFinite(value))
219
+ ) {
220
+ return;
221
+ }
222
+ if (Array.isArray(value)) {
223
+ for (const entry of value) validateManifestJson(entry, label, depth + 1);
224
+ return;
225
+ }
226
+ if (!isRecord(value)) throw new Error(`${label} must contain only JSON`);
227
+ for (const entry of Object.values(value)) {
228
+ validateManifestJson(entry, label, depth + 1);
229
+ }
230
+ }
231
+
232
+ function decodeManifestTools(
233
+ value: unknown,
234
+ ): ManifestToolDeclaration[] | undefined {
235
+ if (value === undefined) return undefined;
236
+ if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
237
+ throw new Error("manifest tools must be a non-empty bounded array");
238
+ }
239
+ const tools = value.map((candidate, index) => {
240
+ if (!isRecord(candidate)) {
241
+ throw new Error(`manifest tools[${index}] must be an object`);
242
+ }
243
+ exactFields(
244
+ candidate,
245
+ ["name", "description", "inputSchema"],
246
+ `manifest tools[${index}]`,
247
+ );
248
+ const name = requiredString(candidate, "name");
249
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(name)) {
250
+ throw new Error(`manifest tools[${index}] name is invalid`);
251
+ }
252
+ const description = requiredString(candidate, "description");
253
+ if (description.length > 1_024) {
254
+ throw new Error(`manifest tools[${index}] description is too long`);
255
+ }
256
+ if (!isRecord(candidate.inputSchema)) {
257
+ throw new Error(`manifest tools[${index}] inputSchema must be an object`);
258
+ }
259
+ validateManifestJson(
260
+ candidate.inputSchema,
261
+ `manifest tools[${index}] inputSchema`,
262
+ );
263
+ return {
264
+ name,
265
+ description,
266
+ inputSchema: structuredClone(candidate.inputSchema),
267
+ };
268
+ });
269
+ if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
270
+ throw new Error("manifest tools contains duplicate names");
271
+ }
272
+ return tools;
273
+ }
274
+
275
+ function decodeManifestHooks(
276
+ value: unknown,
277
+ ): BotIsolateHookEventNameV1[] | undefined {
278
+ if (value === undefined) return undefined;
279
+ if (!Array.isArray(value) || value.length === 0 || value.length > 16) {
280
+ throw new Error("manifest hooks must be a non-empty bounded array");
281
+ }
282
+ const hooks = value.map((hook, index) => {
283
+ if (!isBotIsolateHookEventNameV1(hook)) {
284
+ throw new Error(`manifest hooks[${index}] is invalid`);
285
+ }
286
+ return hook;
287
+ });
288
+ if (new Set(hooks).size !== hooks.length) {
289
+ throw new Error("manifest hooks contains duplicates");
290
+ }
291
+ return hooks;
292
+ }
293
+
169
294
  function requiredString(record: Record<string, unknown>, key: string): string {
170
295
  const value = record[key];
171
296
  if (typeof value !== "string" || !value.trim()) {
@@ -368,43 +493,102 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
368
493
  }
369
494
  exactFields(
370
495
  value.contributions.runtime,
371
- ["entry"],
496
+ ["entry", ...(isV3OrLater(value) ? ["host"] : [])],
372
497
  "manifest runtime contribution",
373
498
  );
499
+ const host = value.contributions.runtime.host;
500
+ if (host !== undefined && host !== "bot-isolate") {
501
+ throw new Error("manifest runtime host is invalid");
502
+ }
374
503
  contributions.runtime = {
375
504
  entry: relativeEntry(value.contributions.runtime, "entry"),
505
+ ...(host === "bot-isolate" ? { host } : {}),
376
506
  };
377
507
  }
378
508
  if (value.contributions.client !== undefined) {
379
509
  const client = value.contributions.client;
380
510
  if (!isRecord(client))
381
511
  throw new Error("manifest client contribution must be an object");
382
- exactFields(
383
- client,
384
- ["entry", "mounts", "outlets"],
385
- "manifest client contribution",
386
- );
387
512
  const mounts = client.mounts;
388
513
  if (!Array.isArray(mounts)) {
389
514
  throw new Error("manifest client mounts must be an array");
390
515
  }
391
- contributions.client = {
392
- entry: relativeEntry(client, "entry"),
393
- mounts: mounts.map((mount) => {
394
- if (!isRecord(mount))
395
- throw new Error("manifest client mount must be an object");
396
- exactFields(mount, ["slot", "order"], "manifest client mount");
397
- const order = mount.order;
398
- if (
399
- order !== undefined &&
400
- (typeof order !== "number" || !Number.isFinite(order))
401
- ) {
402
- throw new Error("manifest client mount order must be finite");
403
- }
404
- return { slot: requiredString(mount, "slot"), order };
405
- }),
406
- outlets: optionalStringArray(client, "outlets"),
407
- };
516
+ const decodedMounts = mounts.map((mount) => {
517
+ if (!isRecord(mount))
518
+ throw new Error("manifest client mount must be an object");
519
+ exactFields(mount, ["slot", "order"], "manifest client mount");
520
+ const order = mount.order;
521
+ if (
522
+ order !== undefined &&
523
+ (typeof order !== "number" || !Number.isFinite(order))
524
+ ) {
525
+ throw new Error("manifest client mount order must be finite");
526
+ }
527
+ return {
528
+ slot: requiredString(mount, "slot"),
529
+ ...(order === undefined ? {} : { order }),
530
+ };
531
+ });
532
+ if (client.kind === "iframe") {
533
+ if (!isV3OrLater(value)) {
534
+ throw new Error("manifest iframe client requires schema version 3");
535
+ }
536
+ exactFields(
537
+ client,
538
+ ["kind", "artifact", "mounts"],
539
+ "manifest iframe client contribution",
540
+ );
541
+ if (!isRecord(client.artifact)) {
542
+ throw new Error("manifest iframe client artifact must be an object");
543
+ }
544
+ exactFields(
545
+ client.artifact,
546
+ ["contentHash", "size", "mediaType", "bundlerVersion"],
547
+ "manifest iframe client artifact",
548
+ );
549
+ const artifact = client.artifact;
550
+ if (
551
+ typeof artifact.contentHash !== "string" ||
552
+ !/^[0-9a-f]{64}$/.test(artifact.contentHash)
553
+ ) {
554
+ throw new Error(
555
+ "manifest iframe client artifact contentHash must be a sha-256 digest",
556
+ );
557
+ }
558
+ if (
559
+ !Number.isSafeInteger(artifact.size) ||
560
+ (artifact.size as number) < 0 ||
561
+ (artifact.size as number) > 256 * 1024
562
+ ) {
563
+ throw new Error(
564
+ "manifest iframe client artifact size must be within the 256 KB quota",
565
+ );
566
+ }
567
+ if (artifact.mediaType !== "text/html") {
568
+ throw new Error("manifest iframe client artifact mediaType is invalid");
569
+ }
570
+ contributions.client = {
571
+ kind: "iframe",
572
+ artifact: {
573
+ contentHash: artifact.contentHash,
574
+ size: artifact.size as number,
575
+ mediaType: "text/html",
576
+ bundlerVersion: requiredString(artifact, "bundlerVersion"),
577
+ },
578
+ mounts: decodedMounts,
579
+ };
580
+ } else {
581
+ exactFields(
582
+ client,
583
+ ["entry", "mounts", "outlets"],
584
+ "manifest client contribution",
585
+ );
586
+ contributions.client = {
587
+ entry: relativeEntry(client, "entry"),
588
+ mounts: decodedMounts,
589
+ outlets: optionalStringArray(client, "outlets"),
590
+ };
591
+ }
408
592
  }
409
593
  if (value.contributions.mobile !== undefined) {
410
594
  const mobile = value.contributions.mobile;
@@ -1218,23 +1402,79 @@ function decodeConfiguration(
1218
1402
 
1219
1403
  function decodeV3(value: Record<string, unknown>): FrockBotManifest {
1220
1404
  const base = decodeV2(value);
1405
+ const tools = decodeManifestTools(value.tools);
1406
+ const hooks = decodeManifestHooks(value.hooks);
1407
+ const botIsolate = base.contributions.runtime?.host === "bot-isolate";
1408
+ if (botIsolate !== (tools !== undefined)) {
1409
+ throw new Error(
1410
+ "manifest bot-isolate runtime and tools declaration must appear together",
1411
+ );
1412
+ }
1413
+ if (!botIsolate && hooks !== undefined) {
1414
+ throw new Error("manifest hooks require a bot-isolate runtime");
1415
+ }
1416
+ validateIframeClientContribution(base.contributions.client, tools);
1221
1417
  return {
1222
1418
  ...base,
1223
1419
  schemaVersion: 3,
1224
1420
  configuration: decodeConfiguration(value.configuration, false),
1421
+ ...(tools ? { tools } : {}),
1422
+ ...(hooks ? { hooks } : {}),
1225
1423
  };
1226
1424
  }
1227
1425
 
1228
1426
  /** v4 is v3 plus the Capability admission ceiling, and nothing else. */
1229
1427
  function decodeV4(value: Record<string, unknown>): FrockBotManifest {
1230
1428
  const base = decodeV2(value);
1429
+ const tools = decodeManifestTools(value.tools);
1430
+ const hooks = decodeManifestHooks(value.hooks);
1431
+ const botIsolate = base.contributions.runtime?.host === "bot-isolate";
1432
+ if (botIsolate !== (tools !== undefined)) {
1433
+ throw new Error(
1434
+ "manifest bot-isolate runtime and tools declaration must appear together",
1435
+ );
1436
+ }
1437
+ if (!botIsolate && hooks !== undefined) {
1438
+ throw new Error("manifest hooks require a bot-isolate runtime");
1439
+ }
1440
+ validateIframeClientContribution(base.contributions.client, tools);
1231
1441
  return {
1232
1442
  ...base,
1233
1443
  schemaVersion: 4,
1234
1444
  configuration: decodeConfiguration(value.configuration, true),
1445
+ ...(tools ? { tools } : {}),
1446
+ ...(hooks ? { hooks } : {}),
1235
1447
  };
1236
1448
  }
1237
1449
 
1450
+ function validateIframeClientContribution(
1451
+ client: ClientContribution | undefined,
1452
+ tools: ManifestToolDeclaration[] | undefined,
1453
+ ): void {
1454
+ if (!client || !isClientIframeContribution(client)) return;
1455
+ if (client.mounts.length === 0 || client.mounts.length > 64) {
1456
+ throw new Error(
1457
+ "manifest iframe client mounts must be a non-empty bounded array",
1458
+ );
1459
+ }
1460
+ const toolNames = new Set((tools ?? []).map((tool) => tool.name));
1461
+ for (const mount of client.mounts) {
1462
+ if (mount.slot === "frockbot.bot-settings-sections") continue;
1463
+ const prefix = "frockbot.tool-result:";
1464
+ if (!mount.slot.startsWith(prefix)) {
1465
+ throw new Error(
1466
+ `manifest iframe client slot "${mount.slot}" is not iframe-safe`,
1467
+ );
1468
+ }
1469
+ const toolName = mount.slot.slice(prefix.length);
1470
+ if (!toolNames.has(toolName)) {
1471
+ throw new Error(
1472
+ `manifest iframe client tool-result slot names undeclared tool "${toolName}"`,
1473
+ );
1474
+ }
1475
+ }
1476
+ }
1477
+
1238
1478
  export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1239
1479
  if (!isRecord(value)) throw new Error("manifest must be an object");
1240
1480
  if (value.schemaVersion === 1) {
@@ -1270,6 +1510,8 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1270
1510
  "defaultEnablement",
1271
1511
  "contributions",
1272
1512
  ...(isV3OrLater(value) ? ["configuration"] : []),
1513
+ ...(isV3OrLater(value) ? ["tools"] : []),
1514
+ ...(isV3OrLater(value) ? ["hooks"] : []),
1273
1515
  ],
1274
1516
  "manifest",
1275
1517
  );