@frockbot/kernel-composition 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -81,6 +120,12 @@ export interface PackageSettingDefinition {
81
120
  id: string;
82
121
  schemaVersion: number;
83
122
  scopes: SettingScope[];
123
+ /**
124
+ * A kernel-consumed semantic role. The model role is deliberately generic:
125
+ * ADR 0019 lets a Package opt the User into model choice without teaching
126
+ * the kernel that Package's identity or policy.
127
+ */
128
+ role?: "model";
84
129
  schema: PackageSettingSchema;
85
130
  }
86
131
 
@@ -130,6 +175,7 @@ export interface FrockBotManifest {
130
175
  version: string;
131
176
  compatibility: { frockbot: string };
132
177
  dependencies: Record<string, string>;
178
+ defaultEnablement?: "enabled" | "disabled";
133
179
  contributions: {
134
180
  backend?: BackendContribution[];
135
181
  runtime?: RuntimeContribution;
@@ -139,6 +185,10 @@ export interface FrockBotManifest {
139
185
  };
140
186
  permissions: string[];
141
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[];
142
192
  }
143
193
 
144
194
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -159,6 +209,88 @@ function exactFields(
159
209
  }
160
210
  }
161
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
+
162
294
  function requiredString(record: Record<string, unknown>, key: string): string {
163
295
  const value = record[key];
164
296
  if (typeof value !== "string" || !value.trim()) {
@@ -221,6 +353,18 @@ function decodeDependencies(value: unknown): Record<string, string> {
221
353
  return dependencies;
222
354
  }
223
355
 
356
+ function decodeDefaultEnablement(
357
+ value: unknown,
358
+ ): FrockBotManifest["defaultEnablement"] {
359
+ if (value === undefined) return undefined;
360
+ if (value !== "enabled" && value !== "disabled") {
361
+ throw new Error(
362
+ 'manifest defaultEnablement must be "enabled" or "disabled"',
363
+ );
364
+ }
365
+ return value;
366
+ }
367
+
224
368
  function decodeIdentity(
225
369
  value: Record<string, unknown>,
226
370
  ): Pick<FrockBotManifest, "id" | "displayName" | "version" | "permissions"> {
@@ -301,6 +445,7 @@ function isV3OrLater(value: Record<string, unknown>): boolean {
301
445
 
302
446
  function decodeV2(value: Record<string, unknown>): FrockBotManifest {
303
447
  const identity = decodeIdentity(value);
448
+ const defaultEnablement = decodeDefaultEnablement(value.defaultEnablement);
304
449
  if (!isRecord(value.compatibility)) {
305
450
  throw new Error("manifest compatibility must be an object");
306
451
  }
@@ -348,43 +493,102 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
348
493
  }
349
494
  exactFields(
350
495
  value.contributions.runtime,
351
- ["entry"],
496
+ ["entry", ...(isV3OrLater(value) ? ["host"] : [])],
352
497
  "manifest runtime contribution",
353
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
+ }
354
503
  contributions.runtime = {
355
504
  entry: relativeEntry(value.contributions.runtime, "entry"),
505
+ ...(host === "bot-isolate" ? { host } : {}),
356
506
  };
357
507
  }
358
508
  if (value.contributions.client !== undefined) {
359
509
  const client = value.contributions.client;
360
510
  if (!isRecord(client))
361
511
  throw new Error("manifest client contribution must be an object");
362
- exactFields(
363
- client,
364
- ["entry", "mounts", "outlets"],
365
- "manifest client contribution",
366
- );
367
512
  const mounts = client.mounts;
368
513
  if (!Array.isArray(mounts)) {
369
514
  throw new Error("manifest client mounts must be an array");
370
515
  }
371
- contributions.client = {
372
- entry: relativeEntry(client, "entry"),
373
- mounts: mounts.map((mount) => {
374
- if (!isRecord(mount))
375
- throw new Error("manifest client mount must be an object");
376
- exactFields(mount, ["slot", "order"], "manifest client mount");
377
- const order = mount.order;
378
- if (
379
- order !== undefined &&
380
- (typeof order !== "number" || !Number.isFinite(order))
381
- ) {
382
- throw new Error("manifest client mount order must be finite");
383
- }
384
- return { slot: requiredString(mount, "slot"), order };
385
- }),
386
- outlets: optionalStringArray(client, "outlets"),
387
- };
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
+ }
388
592
  }
389
593
  if (value.contributions.mobile !== undefined) {
390
594
  const mobile = value.contributions.mobile;
@@ -437,6 +641,7 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
437
641
  frockbot: requiredString(value.compatibility, "frockbot"),
438
642
  },
439
643
  dependencies: decodeDependencies(value.dependencies),
644
+ ...(defaultEnablement ? { defaultEnablement } : {}),
440
645
  contributions,
441
646
  };
442
647
  }
@@ -905,6 +1110,44 @@ function safeSchema(value: unknown): PackageSettingSchema {
905
1110
  return decodeSafeSchema(value, 0);
906
1111
  }
907
1112
 
1113
+ /**
1114
+ * The one object contract the kernel interprets from a setting value. Keeping
1115
+ * this exact prevents a Package from smuggling provider policy into the model
1116
+ * seam while still letting ordinary settings use the supported schema subset.
1117
+ */
1118
+ function assertModelBindingSchema(schema: PackageSettingSchema): void {
1119
+ const fields = Reflect.ownKeys(schema);
1120
+ const properties = schema.properties;
1121
+ const required = schema.required;
1122
+ if (
1123
+ fields.length !== 4 ||
1124
+ !fields.every((field) =>
1125
+ ["type", "properties", "required", "additionalProperties"].includes(
1126
+ String(field),
1127
+ ),
1128
+ ) ||
1129
+ schema.type !== "object" ||
1130
+ schema.additionalProperties !== false ||
1131
+ !properties ||
1132
+ Reflect.ownKeys(properties).length !== 2 ||
1133
+ !Object.hasOwn(properties, "connectionId") ||
1134
+ !Object.hasOwn(properties, "providerModelId") ||
1135
+ Reflect.ownKeys(properties.connectionId ?? {}).length !== 1 ||
1136
+ properties.connectionId?.type !== "string" ||
1137
+ Reflect.ownKeys(properties.providerModelId ?? {}).length !== 1 ||
1138
+ properties.providerModelId?.type !== "string" ||
1139
+ !required ||
1140
+ required.length !== 2 ||
1141
+ new Set(required).size !== 2 ||
1142
+ !required.includes("connectionId") ||
1143
+ !required.includes("providerModelId")
1144
+ ) {
1145
+ throw new Error(
1146
+ 'manifest model setting schema must be exactly an object with required string properties "connectionId" and "providerModelId" and no additional properties',
1147
+ );
1148
+ }
1149
+ }
1150
+
908
1151
  function decodeCapabilityAdmission(value: unknown): {
909
1152
  turnTypes: TurnTypeV1[];
910
1153
  subagentRoles?: string[];
@@ -984,7 +1227,13 @@ function settingDefinitions(
984
1227
  // that round-trips through this decoder decodes again unchanged.
985
1228
  exactFields(
986
1229
  setting,
987
- ["id", "schemaVersion", "schema", "scopes"],
1230
+ [
1231
+ "id",
1232
+ "schemaVersion",
1233
+ "schema",
1234
+ "scopes",
1235
+ ...(scope === "package" ? ["role"] : []),
1236
+ ],
988
1237
  "manifest setting definition",
989
1238
  );
990
1239
  const schemaVersion = setting.schemaVersion;
@@ -1019,11 +1268,17 @@ function settingDefinitions(
1019
1268
  ) {
1020
1269
  throw new Error("manifest setting scopes must contain user or bot");
1021
1270
  }
1271
+ if (setting.role !== undefined && setting.role !== "model") {
1272
+ throw new Error('manifest setting role must be "model"');
1273
+ }
1274
+ const schema = safeSchema(setting.schema);
1275
+ if (setting.role === "model") assertModelBindingSchema(schema);
1022
1276
  return {
1023
1277
  id: definitionId(setting),
1024
1278
  schemaVersion: schemaVersion as number,
1025
1279
  scopes: scopes as SettingScope[],
1026
- schema: safeSchema(setting.schema),
1280
+ ...(setting.role === undefined ? {} : { role: setting.role }),
1281
+ schema,
1027
1282
  };
1028
1283
  });
1029
1284
  }
@@ -1147,23 +1402,79 @@ function decodeConfiguration(
1147
1402
 
1148
1403
  function decodeV3(value: Record<string, unknown>): FrockBotManifest {
1149
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);
1150
1417
  return {
1151
1418
  ...base,
1152
1419
  schemaVersion: 3,
1153
1420
  configuration: decodeConfiguration(value.configuration, false),
1421
+ ...(tools ? { tools } : {}),
1422
+ ...(hooks ? { hooks } : {}),
1154
1423
  };
1155
1424
  }
1156
1425
 
1157
1426
  /** v4 is v3 plus the Capability admission ceiling, and nothing else. */
1158
1427
  function decodeV4(value: Record<string, unknown>): FrockBotManifest {
1159
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);
1160
1441
  return {
1161
1442
  ...base,
1162
1443
  schemaVersion: 4,
1163
1444
  configuration: decodeConfiguration(value.configuration, true),
1445
+ ...(tools ? { tools } : {}),
1446
+ ...(hooks ? { hooks } : {}),
1164
1447
  };
1165
1448
  }
1166
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
+
1167
1478
  export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1168
1479
  if (!isRecord(value)) throw new Error("manifest must be an object");
1169
1480
  if (value.schemaVersion === 1) {
@@ -1196,8 +1507,11 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1196
1507
  "permissions",
1197
1508
  "compatibility",
1198
1509
  "dependencies",
1510
+ "defaultEnablement",
1199
1511
  "contributions",
1200
1512
  ...(isV3OrLater(value) ? ["configuration"] : []),
1513
+ ...(isV3OrLater(value) ? ["tools"] : []),
1514
+ ...(isV3OrLater(value) ? ["hooks"] : []),
1201
1515
  ],
1202
1516
  "manifest",
1203
1517
  );