@frockbot/kernel-composition 0.3.0 → 0.3.2

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,9 +1,16 @@
1
1
  import {
2
2
  isBotIsolateHookEventNameV1,
3
3
  decodeTurnTypeV1,
4
+ iframePageSlotAllowedV1,
5
+ PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1,
6
+ PACKAGE_IFRAME_ENTRY_SLOT_V1,
7
+ PACKAGE_IFRAME_ID_V1,
8
+ PACKAGE_IFRAME_MAX_ENTRIES_V1,
9
+ PACKAGE_IFRAME_MAX_PAGES_V1,
4
10
  type BotIsolateHookEventNameV1,
5
11
  type TurnTypeV1,
6
12
  } from "@frockbot/kernel-contracts";
13
+ import type { ArtifactRefV1 } from "./generation.ts";
7
14
 
8
15
  /** The Contribution kinds a Package manifest can declare. */
9
16
  export type ManifestContributionKind =
@@ -55,13 +62,54 @@ export interface ClientIframeArtifactV1 {
55
62
  bundlerVersion: string;
56
63
  }
57
64
 
58
- /** A non-first-party page. It never becomes JavaScript in the app origin. */
59
- export interface ClientIframeContribution {
60
- kind: "iframe";
65
+ /** One non-first-party page. It never becomes JavaScript in the app origin. */
66
+ export interface ClientIframePageV1 {
67
+ /** `/^[a-z][a-z0-9-]{0,31}$/`, unique within the Contribution. */
68
+ id: string;
61
69
  artifact: ClientIframeArtifactV1;
62
70
  mounts: ClientMount[];
63
71
  }
64
72
 
73
+ /**
74
+ * Manifest v5. A declarative launcher for one of the Package's pages. The
75
+ * shell renders it; the Package never scripts the app origin to place it.
76
+ */
77
+ export interface ClientEntryV1 {
78
+ id: string;
79
+ slot: "frockbot.sidebar-actions";
80
+ order?: number;
81
+ /** At most 32 characters. */
82
+ label: string;
83
+ /** A `UiIcon` name. */
84
+ icon: string;
85
+ opens: { kind: "surface"; page: string };
86
+ }
87
+
88
+ /**
89
+ * Manifest v5. The multi-page iframe client. A v3/v4 record carrying one
90
+ * top-level `artifact` and `mounts` is migrated to `pages: [{ id: "main" }]`
91
+ * at the decoder; there is one in-memory shape, never two.
92
+ */
93
+ export interface ClientIframeContribution {
94
+ kind: "iframe";
95
+ /** 1..8 pages. */
96
+ pages: ClientIframePageV1[];
97
+ /** 0..4 entries. */
98
+ entries?: ClientEntryV1[];
99
+ }
100
+
101
+ /**
102
+ * Manifest v5. What a Package contributes as one durable instance per User —
103
+ * an Applet. The kernel mounts `server` as a facet under its own Applet
104
+ * Durable Object; `tools` is what that instance offers the User's Bots.
105
+ */
106
+ export interface InstanceContributionV1 {
107
+ contract: 1;
108
+ server: ArtifactRefV1;
109
+ ui: ClientIframeArtifactV1;
110
+ tools: ManifestToolDeclaration[];
111
+ }
112
+
65
113
  export type ClientContribution =
66
114
  ClientModuleContribution | ClientIframeContribution;
67
115
 
@@ -162,6 +210,27 @@ export interface CapabilityDefinition {
162
210
  admission?: { turnTypes: TurnTypeV1[]; subagentRoles?: string[] };
163
211
  }
164
212
 
213
+ /**
214
+ * One durable Workspace root a Package declares for itself.
215
+ *
216
+ * "Durable roots, declared by the Computer Package's Workspace layout and by
217
+ * Package manifests, survive hibernation, cold start, host migration, and
218
+ * image rebuild." The Computer half of that sentence has always been real —
219
+ * `FLY_WORKSPACE_LAYOUT` carries the `package-declared` mount template — but
220
+ * the manifest half had nowhere to be written, so no Package-declared root
221
+ * ever reached the durable-root sync. This is that half.
222
+ *
223
+ * `scope` is `user` and only `user`: `WorkspaceRootV1` names a
224
+ * `package-declared` root by User and Package with no Bot in it, and Package
225
+ * availability is a User-level fact (ADR 0019). A Bot-scoped Package root
226
+ * would be a root the kernel's own root type cannot address.
227
+ */
228
+ export interface ManifestDeclaredRootV1 {
229
+ /** The `rootId`, in the kernel's `package-declared` root-id shape. */
230
+ id: string;
231
+ scope: "user";
232
+ }
233
+
165
234
  export interface PackageConfiguration {
166
235
  settings: PackageSettingDefinition[];
167
236
  connectionTypes: ConnectionTypeDefinition[];
@@ -169,7 +238,7 @@ export interface PackageConfiguration {
169
238
  }
170
239
 
171
240
  export interface FrockBotManifest {
172
- schemaVersion: 2 | 3 | 4;
241
+ schemaVersion: 2 | 3 | 4 | 5;
173
242
  id: string;
174
243
  displayName: string;
175
244
  version: string;
@@ -182,6 +251,8 @@ export interface FrockBotManifest {
182
251
  client?: ClientContribution;
183
252
  desktop?: DesktopContribution;
184
253
  mobile?: MobileContribution;
254
+ /** Manifest v5 only; refused below it. */
255
+ instance?: InstanceContributionV1;
185
256
  };
186
257
  permissions: string[];
187
258
  configuration?: PackageConfiguration;
@@ -189,6 +260,8 @@ export interface FrockBotManifest {
189
260
  tools?: ManifestToolDeclaration[];
190
261
  /** Bot-isolate waterfalls the immutable artifact declares it exports. */
191
262
  hooks?: BotIsolateHookEventNameV1[];
263
+ /** The durable Workspace roots this Package declares. Manifest v3 onward. */
264
+ roots?: ManifestDeclaredRootV1[];
192
265
  }
193
266
 
194
267
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -291,6 +364,45 @@ function decodeManifestHooks(
291
364
  return hooks;
292
365
  }
293
366
 
367
+ /**
368
+ * The `package-declared` root-id shape, kept identical to the kernel's own
369
+ * `ROOT_ID` in `kernel-contracts/src/workspace.ts`.
370
+ *
371
+ * Restated rather than imported because `kernel-composition` decodes a
372
+ * manifest without depending on the Workspace contracts; the round-trip test
373
+ * beside this decoder is what keeps the two from drifting.
374
+ */
375
+ const MANIFEST_ROOT_ID = /^[a-z][a-z0-9-]{0,127}$/;
376
+
377
+ function decodeManifestRoots(
378
+ value: unknown,
379
+ ): ManifestDeclaredRootV1[] | undefined {
380
+ if (value === undefined) return undefined;
381
+ if (!Array.isArray(value) || value.length === 0 || value.length > 8) {
382
+ throw new Error("manifest roots must be a non-empty bounded array");
383
+ }
384
+ const roots = value.map((entry, index) => {
385
+ if (!isRecord(entry)) {
386
+ throw new Error(`manifest roots[${index}] must be an object`);
387
+ }
388
+ exactFields(entry, ["id", "scope"], `manifest roots[${index}]`);
389
+ const id = requiredString(entry, "id");
390
+ if (!MANIFEST_ROOT_ID.test(id)) {
391
+ throw new Error(`manifest roots[${index}] id is invalid`);
392
+ }
393
+ // User and only User: a `package-declared` root names no Bot, so a
394
+ // Bot-scoped one would be a root the kernel cannot address.
395
+ if (entry.scope !== "user") {
396
+ throw new Error(`manifest roots[${index}] scope must be "user"`);
397
+ }
398
+ return { id, scope: "user" as const };
399
+ });
400
+ if (new Set(roots.map((root) => root.id)).size !== roots.length) {
401
+ throw new Error("manifest roots contains duplicate ids");
402
+ }
403
+ return roots;
404
+ }
405
+
294
406
  function requiredString(record: Record<string, unknown>, key: string): string {
295
407
  const value = record[key];
296
408
  if (typeof value !== "string" || !value.trim()) {
@@ -440,7 +552,187 @@ function decodeV1(value: Record<string, unknown>): FrockBotManifest {
440
552
  * Manifest v4 extends v3, so every v3 rule applies unchanged to a v4 body.
441
553
  */
442
554
  function isV3OrLater(value: Record<string, unknown>): boolean {
443
- return value.schemaVersion === 3 || value.schemaVersion === 4;
555
+ return (
556
+ value.schemaVersion === 3 ||
557
+ value.schemaVersion === 4 ||
558
+ value.schemaVersion === 5
559
+ );
560
+ }
561
+
562
+ function isV5(value: Record<string, unknown>): boolean {
563
+ return value.schemaVersion === 5;
564
+ }
565
+
566
+ function decodeClientMounts(value: unknown, label: string): ClientMount[] {
567
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
568
+ return value.map((mount) => {
569
+ if (!isRecord(mount)) throw new Error(`${label} entry must be an object`);
570
+ exactFields(mount, ["slot", "order"], label);
571
+ const order = mount.order;
572
+ if (
573
+ order !== undefined &&
574
+ (typeof order !== "number" || !Number.isFinite(order))
575
+ ) {
576
+ throw new Error(`${label} order must be finite`);
577
+ }
578
+ return {
579
+ slot: requiredString(mount, "slot"),
580
+ ...(order === undefined ? {} : { order }),
581
+ };
582
+ });
583
+ }
584
+
585
+ function decodeClientIframeArtifact(
586
+ value: unknown,
587
+ label: string,
588
+ ): ClientIframeArtifactV1 {
589
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
590
+ exactFields(
591
+ value,
592
+ ["contentHash", "size", "mediaType", "bundlerVersion"],
593
+ label,
594
+ );
595
+ if (
596
+ typeof value.contentHash !== "string" ||
597
+ !/^[0-9a-f]{64}$/.test(value.contentHash)
598
+ ) {
599
+ throw new Error(`${label} contentHash must be a sha-256 digest`);
600
+ }
601
+ if (
602
+ !Number.isSafeInteger(value.size) ||
603
+ (value.size as number) < 0 ||
604
+ (value.size as number) > 256 * 1024
605
+ ) {
606
+ throw new Error(`${label} size must be within the 256 KB quota`);
607
+ }
608
+ if (value.mediaType !== "text/html") {
609
+ throw new Error(`${label} mediaType is invalid`);
610
+ }
611
+ return {
612
+ contentHash: value.contentHash,
613
+ size: value.size as number,
614
+ mediaType: "text/html",
615
+ bundlerVersion: requiredString(value, "bundlerVersion"),
616
+ };
617
+ }
618
+
619
+ function decodeInstanceContribution(value: unknown): InstanceContributionV1 {
620
+ if (!isRecord(value)) {
621
+ throw new Error("manifest instance Contribution must be an object");
622
+ }
623
+ exactFields(
624
+ value,
625
+ ["contract", "server", "ui", "tools"],
626
+ "manifest instance Contribution",
627
+ );
628
+ if (value.contract !== 1) {
629
+ throw new Error("manifest instance Contribution contract is unsupported");
630
+ }
631
+ if (!isRecord(value.server)) {
632
+ throw new Error("manifest instance Contribution server must be an object");
633
+ }
634
+ exactFields(
635
+ value.server,
636
+ ["contentHash", "size", "mediaType", "bundlerVersion"],
637
+ "manifest instance Contribution server",
638
+ );
639
+ const server = value.server;
640
+ if (
641
+ typeof server.contentHash !== "string" ||
642
+ !/^[0-9a-f]{64}$/.test(server.contentHash)
643
+ ) {
644
+ throw new Error(
645
+ "manifest instance Contribution server contentHash must be a sha-256 digest",
646
+ );
647
+ }
648
+ if (!Number.isSafeInteger(server.size) || (server.size as number) < 0) {
649
+ throw new Error(
650
+ "manifest instance Contribution server size must be a non-negative integer",
651
+ );
652
+ }
653
+ if (server.mediaType !== "application/javascript") {
654
+ throw new Error(
655
+ "manifest instance Contribution server mediaType is invalid",
656
+ );
657
+ }
658
+ const tools = decodeManifestTools(value.tools);
659
+ if (!tools) {
660
+ throw new Error(
661
+ "manifest instance Contribution must declare its instance tools",
662
+ );
663
+ }
664
+ return {
665
+ contract: 1,
666
+ server: {
667
+ contentHash: server.contentHash,
668
+ size: server.size as number,
669
+ mediaType: "application/javascript",
670
+ bundlerVersion: requiredString(server, "bundlerVersion"),
671
+ },
672
+ ui: decodeClientIframeArtifact(
673
+ value.ui,
674
+ "manifest instance Contribution ui",
675
+ ),
676
+ tools,
677
+ };
678
+ }
679
+
680
+ function decodeClientEntries(value: unknown): ClientEntryV1[] | undefined {
681
+ if (value === undefined) return undefined;
682
+ if (!Array.isArray(value) || value.length > PACKAGE_IFRAME_MAX_ENTRIES_V1) {
683
+ throw new Error("manifest iframe client entries must be a bounded array");
684
+ }
685
+ const entries = value.map((candidate, index) => {
686
+ const label = `manifest iframe client entries[${index}]`;
687
+ if (!isRecord(candidate)) throw new Error(`${label} must be an object`);
688
+ exactFields(
689
+ candidate,
690
+ ["id", "slot", "order", "label", "icon", "opens"],
691
+ label,
692
+ );
693
+ const id = requiredString(candidate, "id");
694
+ if (!PACKAGE_IFRAME_ID_V1.test(id)) {
695
+ throw new Error(`${label} id is invalid`);
696
+ }
697
+ if (candidate.slot !== PACKAGE_IFRAME_ENTRY_SLOT_V1) {
698
+ throw new Error(`${label} slot is invalid`);
699
+ }
700
+ const order = candidate.order;
701
+ if (
702
+ order !== undefined &&
703
+ (typeof order !== "number" || !Number.isFinite(order))
704
+ ) {
705
+ throw new Error(`${label} order must be finite`);
706
+ }
707
+ const entryLabel = requiredString(candidate, "label");
708
+ if (entryLabel.length > PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1) {
709
+ throw new Error(`${label} label is too long`);
710
+ }
711
+ const icon = requiredString(candidate, "icon");
712
+ if (icon.length > 64) throw new Error(`${label} icon is too long`);
713
+ if (!isRecord(candidate.opens)) {
714
+ throw new Error(`${label} opens must be an object`);
715
+ }
716
+ exactFields(candidate.opens, ["kind", "page"], `${label} opens`);
717
+ if (candidate.opens.kind !== "surface") {
718
+ throw new Error(`${label} opens.kind is invalid`);
719
+ }
720
+ return {
721
+ id,
722
+ slot: PACKAGE_IFRAME_ENTRY_SLOT_V1 as "frockbot.sidebar-actions",
723
+ ...(order === undefined ? {} : { order }),
724
+ label: entryLabel,
725
+ icon,
726
+ opens: {
727
+ kind: "surface" as const,
728
+ page: requiredString(candidate.opens, "page"),
729
+ },
730
+ };
731
+ });
732
+ if (new Set(entries.map((entry) => entry.id)).size !== entries.length) {
733
+ throw new Error("manifest iframe client entries contain duplicate ids");
734
+ }
735
+ return entries;
444
736
  }
445
737
 
446
738
  function decodeV2(value: Record<string, unknown>): FrockBotManifest {
@@ -456,7 +748,7 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
456
748
  exactFields(
457
749
  value.contributions,
458
750
  isV3OrLater(value)
459
- ? ["backend", "runtime", "client", "desktop", "mobile"]
751
+ ? ["backend", "runtime", "client", "desktop", "mobile", "instance"]
460
752
  : ["runtime", "client", "desktop", "mobile"],
461
753
  "manifest contributions",
462
754
  );
@@ -509,74 +801,77 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
509
801
  const client = value.contributions.client;
510
802
  if (!isRecord(client))
511
803
  throw new Error("manifest client contribution must be an object");
512
- const mounts = client.mounts;
513
- if (!Array.isArray(mounts)) {
514
- throw new Error("manifest client mounts must be an array");
515
- }
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
804
  if (client.kind === "iframe") {
533
805
  if (!isV3OrLater(value)) {
534
806
  throw new Error("manifest iframe client requires schema version 3");
535
807
  }
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",
808
+ if (isV5(value)) {
809
+ exactFields(
810
+ client,
811
+ ["kind", "pages", "entries"],
812
+ "manifest iframe client contribution",
556
813
  );
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",
814
+ if (
815
+ !Array.isArray(client.pages) ||
816
+ client.pages.length === 0 ||
817
+ client.pages.length > PACKAGE_IFRAME_MAX_PAGES_V1
818
+ ) {
819
+ throw new Error(
820
+ "manifest iframe client pages must be a non-empty bounded array",
821
+ );
822
+ }
823
+ const pages = client.pages.map((candidate, index) => {
824
+ const label = `manifest iframe client pages[${index}]`;
825
+ if (!isRecord(candidate))
826
+ throw new Error(`${label} must be an object`);
827
+ exactFields(candidate, ["id", "artifact", "mounts"], label);
828
+ const id = requiredString(candidate, "id");
829
+ if (!PACKAGE_IFRAME_ID_V1.test(id)) {
830
+ throw new Error(`${label} id is invalid`);
831
+ }
832
+ return {
833
+ id,
834
+ artifact: decodeClientIframeArtifact(
835
+ candidate.artifact,
836
+ `${label} artifact`,
837
+ ),
838
+ mounts: decodeClientMounts(candidate.mounts, `${label} mounts`),
839
+ };
840
+ });
841
+ if (new Set(pages.map((page) => page.id)).size !== pages.length) {
842
+ throw new Error("manifest iframe client pages contain duplicate ids");
843
+ }
844
+ const entries = decodeClientEntries(client.entries);
845
+ contributions.client = {
846
+ kind: "iframe",
847
+ pages,
848
+ ...(entries ? { entries } : {}),
849
+ };
850
+ } else {
851
+ // Migration, not compatibility: the v3/v4 single-page record becomes
852
+ // the one in-memory multi-page shape and is written back as v5.
853
+ exactFields(
854
+ client,
855
+ ["kind", "artifact", "mounts"],
856
+ "manifest iframe client contribution",
565
857
  );
858
+ contributions.client = {
859
+ kind: "iframe",
860
+ pages: [
861
+ {
862
+ id: "main",
863
+ artifact: decodeClientIframeArtifact(
864
+ client.artifact,
865
+ "manifest iframe client artifact",
866
+ ),
867
+ mounts: decodeClientMounts(
868
+ client.mounts,
869
+ "manifest client mount",
870
+ ),
871
+ },
872
+ ],
873
+ };
566
874
  }
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
875
  } else {
581
876
  exactFields(
582
877
  client,
@@ -585,7 +880,7 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
585
880
  );
586
881
  contributions.client = {
587
882
  entry: relativeEntry(client, "entry"),
588
- mounts: decodedMounts,
883
+ mounts: decodeClientMounts(client.mounts, "manifest client mount"),
589
884
  outlets: optionalStringArray(client, "outlets"),
590
885
  };
591
886
  }
@@ -625,12 +920,23 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
625
920
  commands: optionalStringArray(desktop, "commands"),
626
921
  };
627
922
  }
923
+ if (value.contributions.instance !== undefined) {
924
+ if (!isV5(value)) {
925
+ throw new Error(
926
+ "manifest instance Contribution requires schema version 5",
927
+ );
928
+ }
929
+ contributions.instance = decodeInstanceContribution(
930
+ value.contributions.instance,
931
+ );
932
+ }
628
933
  if (
629
934
  !contributions.backend &&
630
935
  !contributions.runtime &&
631
936
  !contributions.client &&
632
937
  !contributions.desktop &&
633
- !contributions.mobile
938
+ !contributions.mobile &&
939
+ !contributions.instance
634
940
  ) {
635
941
  throw new Error("manifest has no contributions");
636
942
  }
@@ -1404,6 +1710,7 @@ function decodeV3(value: Record<string, unknown>): FrockBotManifest {
1404
1710
  const base = decodeV2(value);
1405
1711
  const tools = decodeManifestTools(value.tools);
1406
1712
  const hooks = decodeManifestHooks(value.hooks);
1713
+ const roots = decodeManifestRoots(value.roots);
1407
1714
  const botIsolate = base.contributions.runtime?.host === "bot-isolate";
1408
1715
  if (botIsolate !== (tools !== undefined)) {
1409
1716
  throw new Error(
@@ -1420,6 +1727,7 @@ function decodeV3(value: Record<string, unknown>): FrockBotManifest {
1420
1727
  configuration: decodeConfiguration(value.configuration, false),
1421
1728
  ...(tools ? { tools } : {}),
1422
1729
  ...(hooks ? { hooks } : {}),
1730
+ ...(roots ? { roots } : {}),
1423
1731
  };
1424
1732
  }
1425
1733
 
@@ -1428,6 +1736,7 @@ function decodeV4(value: Record<string, unknown>): FrockBotManifest {
1428
1736
  const base = decodeV2(value);
1429
1737
  const tools = decodeManifestTools(value.tools);
1430
1738
  const hooks = decodeManifestHooks(value.hooks);
1739
+ const roots = decodeManifestRoots(value.roots);
1431
1740
  const botIsolate = base.contributions.runtime?.host === "bot-isolate";
1432
1741
  if (botIsolate !== (tools !== undefined)) {
1433
1742
  throw new Error(
@@ -1444,6 +1753,40 @@ function decodeV4(value: Record<string, unknown>): FrockBotManifest {
1444
1753
  configuration: decodeConfiguration(value.configuration, true),
1445
1754
  ...(tools ? { tools } : {}),
1446
1755
  ...(hooks ? { hooks } : {}),
1756
+ ...(roots ? { roots } : {}),
1757
+ };
1758
+ }
1759
+
1760
+ /**
1761
+ * v5 is v4 plus the multi-page iframe client, declarative entries, and the
1762
+ * Instance Contribution. A v3 or v4 record decodes to the same in-memory
1763
+ * shape through the single-page migration in `decodeV2`.
1764
+ */
1765
+ function decodeV5(value: Record<string, unknown>): FrockBotManifest {
1766
+ const base = decodeV2(value);
1767
+ const tools = decodeManifestTools(value.tools);
1768
+ const hooks = decodeManifestHooks(value.hooks);
1769
+ // Declared roots are v3 onward, so a v5 manifest carries them too. Dropping
1770
+ // them here would silently unmount the durable root a v5 Package declares —
1771
+ // for the Applets Package, the directory its source lives in.
1772
+ const roots = decodeManifestRoots(value.roots);
1773
+ const botIsolate = base.contributions.runtime?.host === "bot-isolate";
1774
+ if (botIsolate !== (tools !== undefined)) {
1775
+ throw new Error(
1776
+ "manifest bot-isolate runtime and tools declaration must appear together",
1777
+ );
1778
+ }
1779
+ if (!botIsolate && hooks !== undefined) {
1780
+ throw new Error("manifest hooks require a bot-isolate runtime");
1781
+ }
1782
+ validateIframeClientContribution(base.contributions.client, tools);
1783
+ return {
1784
+ ...base,
1785
+ schemaVersion: 5,
1786
+ configuration: decodeConfiguration(value.configuration, true),
1787
+ ...(tools ? { tools } : {}),
1788
+ ...(hooks ? { hooks } : {}),
1789
+ ...(roots ? { roots } : {}),
1447
1790
  };
1448
1791
  }
1449
1792
 
@@ -1452,24 +1795,35 @@ function validateIframeClientContribution(
1452
1795
  tools: ManifestToolDeclaration[] | undefined,
1453
1796
  ): void {
1454
1797
  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)) {
1798
+ const declaredTools = (tools ?? []).map((tool) => tool.name);
1799
+ const pageIds = client.pages.map((page) => page.id);
1800
+ for (const page of client.pages) {
1801
+ if (page.mounts.length === 0 || page.mounts.length > 64) {
1465
1802
  throw new Error(
1466
- `manifest iframe client slot "${mount.slot}" is not iframe-safe`,
1803
+ `manifest iframe client page "${page.id}" mounts must be a non-empty bounded array`,
1467
1804
  );
1468
1805
  }
1469
- const toolName = mount.slot.slice(prefix.length);
1470
- if (!toolNames.has(toolName)) {
1806
+ for (const mount of page.mounts) {
1807
+ if (!iframePageSlotAllowedV1(mount.slot, { declaredTools, pageIds })) {
1808
+ const toolResult = "frockbot.tool-result:";
1809
+ throw new Error(
1810
+ mount.slot.startsWith(toolResult)
1811
+ ? `manifest iframe client tool-result slot names undeclared tool "${mount.slot.slice(toolResult.length)}"`
1812
+ : `manifest iframe client slot "${mount.slot}" is not iframe-safe`,
1813
+ );
1814
+ }
1815
+ }
1816
+ }
1817
+ for (const entry of client.entries ?? []) {
1818
+ const target = client.pages.find((page) => page.id === entry.opens.page);
1819
+ if (
1820
+ !target ||
1821
+ !target.mounts.some(
1822
+ (mount) => mount.slot === `frockbot.surface:${entry.opens.page}`,
1823
+ )
1824
+ ) {
1471
1825
  throw new Error(
1472
- `manifest iframe client tool-result slot names undeclared tool "${toolName}"`,
1826
+ `manifest iframe client entry "${entry.id}" opens page "${entry.opens.page}", which declares no surface mount`,
1473
1827
  );
1474
1828
  }
1475
1829
  }
@@ -1495,7 +1849,8 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1495
1849
  if (
1496
1850
  value.schemaVersion === 2 ||
1497
1851
  value.schemaVersion === 3 ||
1498
- value.schemaVersion === 4
1852
+ value.schemaVersion === 4 ||
1853
+ value.schemaVersion === 5
1499
1854
  ) {
1500
1855
  exactFields(
1501
1856
  value,
@@ -1512,12 +1867,14 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1512
1867
  ...(isV3OrLater(value) ? ["configuration"] : []),
1513
1868
  ...(isV3OrLater(value) ? ["tools"] : []),
1514
1869
  ...(isV3OrLater(value) ? ["hooks"] : []),
1870
+ ...(isV3OrLater(value) ? ["roots"] : []),
1515
1871
  ],
1516
1872
  "manifest",
1517
1873
  );
1518
1874
  if (value.schemaVersion === 2) return decodeV2(value);
1519
1875
  if (value.schemaVersion === 3) return decodeV3(value);
1520
- return decodeV4(value);
1876
+ if (value.schemaVersion === 4) return decodeV4(value);
1877
+ return decodeV5(value);
1521
1878
  }
1522
1879
  throw new Error("unsupported FrockBot manifest version");
1523
1880
  }