@frockbot/plugin-authoring 0.3.1 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-authoring",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsc --noEmit -p tsconfig.json"
22
22
  },
23
23
  "dependencies": {
24
- "@frockbot/kernel-contracts": "0.3.1",
24
+ "@frockbot/kernel-contracts": "0.3.3",
25
25
  "cordis": "4.0.0-rc.8"
26
26
  },
27
27
  "devDependencies": {
package/src/agent.test.ts CHANGED
@@ -115,7 +115,7 @@ describe("the package_author tool", () => {
115
115
  await dispose();
116
116
  });
117
117
 
118
- test("includes the UI HTML hash and declared hooks in the effect identity", async () => {
118
+ test("includes every UI page hash and declared hooks in the effect identity", async () => {
119
119
  const { sessions, dispose } = await openSession();
120
120
  const effectInputs: Parameters<PackageAuthoringHost["effectIdFor"]>[0][] =
121
121
  [];
@@ -138,8 +138,18 @@ describe("the package_author tool", () => {
138
138
  ...INPUT,
139
139
  hooks: ["agent/tool-exposure"],
140
140
  ui: {
141
- html,
142
- mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
141
+ pages: [
142
+ {
143
+ id: "main",
144
+ html,
145
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
146
+ },
147
+ {
148
+ id: "board",
149
+ html: "<!doctype html><h1>Board</h1>",
150
+ mounts: [{ slot: "frockbot.right-panel" }],
151
+ },
152
+ ],
143
153
  },
144
154
  },
145
155
  CONTEXT,
@@ -149,7 +159,13 @@ describe("the package_author tool", () => {
149
159
  {
150
160
  packageId: "weather-lookup",
151
161
  sourceHash: await sha256HexV1(INPUT.source),
152
- uiHtmlHash: await sha256HexV1(html),
162
+ uiPageHashes: [
163
+ { id: "main", htmlHash: await sha256HexV1(html) },
164
+ {
165
+ id: "board",
166
+ htmlHash: await sha256HexV1("<!doctype html><h1>Board</h1>"),
167
+ },
168
+ ],
153
169
  hooks: ["agent/tool-exposure"],
154
170
  },
155
171
  ]);
package/src/agent.ts CHANGED
@@ -57,7 +57,7 @@ export interface PackageAuthoringHost {
57
57
  effectIdFor(input: {
58
58
  packageId: string;
59
59
  sourceHash: string;
60
- uiHtmlHash?: string;
60
+ uiPageHashes?: Array<{ id: string; htmlHash: string }>;
61
61
  hooks?: AuthorPackageInputV1["hooks"];
62
62
  }): Promise<string>;
63
63
  author(request: AuthorPackageRequestV1): Promise<AuthorPackageOutcomeV1>;
@@ -119,6 +119,7 @@ export function createPackageAuthorTool(
119
119
  ): ToolDefinition {
120
120
  return {
121
121
  name: "package_author",
122
+ namespace: "frockbot",
122
123
  // A general work tool: the full toolset an `executor` subagent gets, and
123
124
  // not part of the narrow reach of `browserUse`, `computerUse`, or the two
124
125
  // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
@@ -154,13 +155,18 @@ export function createPackageAuthorTool(
154
155
  };
155
156
  }
156
157
  const sourceHash = await sha256HexV1(decoded.source);
157
- const uiHtmlHash = decoded.ui
158
- ? await sha256HexV1(decoded.ui.html)
158
+ const uiPageHashes = decoded.ui
159
+ ? await Promise.all(
160
+ decoded.ui.pages.map(async (page) => ({
161
+ id: page.id,
162
+ htmlHash: await sha256HexV1(page.html),
163
+ })),
164
+ )
159
165
  : undefined;
160
166
  const effectId = await host.effectIdFor({
161
167
  packageId: decoded.packageId,
162
168
  sourceHash,
163
- ...(uiHtmlHash === undefined ? {} : { uiHtmlHash }),
169
+ ...(uiPageHashes === undefined ? {} : { uiPageHashes }),
164
170
  ...(decoded.hooks === undefined ? {} : { hooks: decoded.hooks }),
165
171
  });
166
172
  const position = openTurnPositionV1(session);
@@ -207,6 +213,7 @@ export function createPackageUndoTool(
207
213
  ): ToolDefinition {
208
214
  return {
209
215
  name: "package_undo",
216
+ namespace: "frockbot",
210
217
  admission: { subagentRoles: ["executor"] },
211
218
  description:
212
219
  "Undo your latest Package setup change, or restore an earlier Composition generation. This only changes Package setup; it never undoes actions taken through Connections. The resulting generation activates on the next Turn.",
@@ -285,6 +292,7 @@ export function createPackageInspectSelfTool(
285
292
  ): ToolDefinition {
286
293
  return {
287
294
  name: "package_inspect_self",
295
+ namespace: "frockbot",
288
296
  admission: { subagentRoles: ["executor"] },
289
297
  description:
290
298
  "Read your exact Package execution context contract, current Composition (including your stored Package source), and latest authoring or activation failure per authored Package.",
@@ -83,14 +83,36 @@ describe("decodeAuthorPackageInputV1", () => {
83
83
  ).toThrow();
84
84
  });
85
85
 
86
- test("accepts one inline iframe page and refuses external resources or an oversized page", () => {
87
- const ui = {
86
+ test("accepts inline iframe pages with entries and refuses external resources or an oversized page", () => {
87
+ const main = {
88
+ id: "main",
88
89
  html: "<!doctype html><style>body{color:red}</style><script>window.frockbot.resize()</script>",
89
90
  mounts: [
90
91
  { slot: "frockbot.tool-result:weather_lookup", order: 10 },
91
92
  { slot: "frockbot.bot-settings-sections" },
92
93
  ],
93
94
  };
95
+ const board = {
96
+ id: "board",
97
+ html: "<!doctype html><h1>Board</h1>",
98
+ mounts: [
99
+ { slot: "frockbot.surface:board" },
100
+ { slot: "frockbot.right-panel" },
101
+ ],
102
+ };
103
+ const ui = {
104
+ pages: [main, board],
105
+ entries: [
106
+ {
107
+ id: "open",
108
+ slot: "frockbot.sidebar-actions" as const,
109
+ order: 5,
110
+ label: "Weather board",
111
+ icon: "sparkle",
112
+ opens: { kind: "surface" as const, page: "board" },
113
+ },
114
+ ],
115
+ };
94
116
  expect(
95
117
  decodeAuthorPackageInputV1({
96
118
  ...VALID,
@@ -98,18 +120,70 @@ describe("decodeAuthorPackageInputV1", () => {
98
120
  ui,
99
121
  }),
100
122
  ).toMatchObject({ hooks: ["agent/tool-exposure"], ui });
123
+ const withPages = (pages: unknown) => ({ ...VALID, ui: { pages } });
124
+ expect(() =>
125
+ decodeAuthorPackageInputV1(
126
+ withPages([
127
+ {
128
+ ...main,
129
+ html: '<script src="https://example.com/x.js"></script>',
130
+ },
131
+ ]),
132
+ ),
133
+ ).toThrow("inline resources only");
134
+ expect(() =>
135
+ decodeAuthorPackageInputV1(
136
+ withPages([{ ...main, html: "a".repeat(256 * 1024 + 1) }]),
137
+ ),
138
+ ).toThrow();
139
+ expect(() => decodeAuthorPackageInputV1(withPages([]))).toThrow(
140
+ "non-empty bounded array",
141
+ );
142
+ expect(() =>
143
+ decodeAuthorPackageInputV1(withPages([main, { ...board, id: "main" }])),
144
+ ).toThrow("duplicate ids");
145
+ expect(() =>
146
+ decodeAuthorPackageInputV1(withPages([{ ...main, id: "Main" }])),
147
+ ).toThrow("id is invalid");
148
+ expect(() =>
149
+ decodeAuthorPackageInputV1(
150
+ withPages([{ ...main, mounts: [{ slot: "frockbot.surface:absent" }] }]),
151
+ ),
152
+ ).toThrow("not iframe-safe");
153
+ expect(() =>
154
+ decodeAuthorPackageInputV1(
155
+ withPages([
156
+ { ...main, mounts: [{ slot: "frockbot.tool-result:unknown_tool" }] },
157
+ ]),
158
+ ),
159
+ ).toThrow("not iframe-safe");
101
160
  expect(() =>
102
161
  decodeAuthorPackageInputV1({
103
162
  ...VALID,
104
- ui: { ...ui, html: '<script src="https://example.com/x.js"></script>' },
163
+ ui: {
164
+ pages: [main, board],
165
+ entries: [
166
+ { ...ui.entries[0]!, opens: { kind: "surface", page: "main" } },
167
+ ],
168
+ },
105
169
  }),
106
- ).toThrow("inline resources only");
170
+ ).toThrow("names no page with a matching surface mount");
171
+ expect(() =>
172
+ decodeAuthorPackageInputV1({
173
+ ...VALID,
174
+ ui: {
175
+ pages: [main, board],
176
+ entries: [{ ...ui.entries[0]!, label: "x".repeat(33) }],
177
+ },
178
+ }),
179
+ ).toThrow("label must be a bounded non-empty string");
180
+ // The retired single-page shape is not honoured backward.
107
181
  expect(() =>
108
182
  decodeAuthorPackageInputV1({
109
183
  ...VALID,
110
- ui: { ...ui, html: "a".repeat(256 * 1024 + 1) },
184
+ ui: { html: main.html, mounts: main.mounts },
111
185
  }),
112
- ).toThrow();
186
+ ).toThrow("has invalid fields");
113
187
  });
114
188
  });
115
189
 
@@ -140,8 +214,38 @@ describe("authoring identity", () => {
140
214
  runId: "run-1",
141
215
  packageId: VALID.packageId,
142
216
  sourceHash,
143
- uiHtmlHash: await sha256HexV1("<!doctype html><h1>Weather</h1>"),
217
+ uiPageHashes: [
218
+ {
219
+ id: "main",
220
+ htmlHash: await sha256HexV1("<!doctype html><h1>Weather</h1>"),
221
+ },
222
+ ],
223
+ });
224
+ const otherUiPageId = await authoringEffectIdV1({
225
+ runId: "run-1",
226
+ packageId: VALID.packageId,
227
+ sourceHash,
228
+ uiPageHashes: [
229
+ {
230
+ id: "board",
231
+ htmlHash: await sha256HexV1("<!doctype html><h1>Weather</h1>"),
232
+ },
233
+ ],
234
+ });
235
+ expect(otherUi).not.toBe(otherUiPageId);
236
+ const twoPages = await authoringEffectIdV1({
237
+ runId: "run-1",
238
+ packageId: VALID.packageId,
239
+ sourceHash,
240
+ uiPageHashes: [
241
+ {
242
+ id: "main",
243
+ htmlHash: await sha256HexV1("<!doctype html><h1>Weather</h1>"),
244
+ },
245
+ { id: "board", htmlHash: await sha256HexV1("<!doctype html>b") },
246
+ ],
144
247
  });
248
+ expect(twoPages).not.toBe(otherUi);
145
249
  const otherHooks = await authoringEffectIdV1({
146
250
  runId: "run-1",
147
251
  packageId: VALID.packageId,
@@ -215,18 +319,56 @@ describe("authoring identity", () => {
215
319
  version: "0.0.1",
216
320
  tools: VALID.tools,
217
321
  ui: {
218
- artifact,
219
- mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
322
+ pages: [
323
+ {
324
+ id: "main",
325
+ artifact,
326
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
327
+ },
328
+ {
329
+ id: "board",
330
+ artifact: { ...artifact, contentHash: "b".repeat(64) },
331
+ mounts: [{ slot: "frockbot.surface:board" }],
332
+ },
333
+ ],
334
+ entries: [
335
+ {
336
+ id: "open",
337
+ slot: "frockbot.sidebar-actions",
338
+ label: "Weather board",
339
+ icon: "sparkle",
340
+ opens: { kind: "surface", page: "board" },
341
+ },
342
+ ],
220
343
  },
221
344
  });
222
345
  expect(manifest).toMatchObject({
223
- schemaVersion: 3,
346
+ schemaVersion: 5,
224
347
  contributions: {
225
348
  runtime: { host: "bot-isolate" },
226
349
  client: {
227
350
  kind: "iframe",
228
- artifact,
229
- mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
351
+ pages: [
352
+ {
353
+ id: "main",
354
+ artifact,
355
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
356
+ },
357
+ {
358
+ id: "board",
359
+ artifact: { ...artifact, contentHash: "b".repeat(64) },
360
+ mounts: [{ slot: "frockbot.surface:board" }],
361
+ },
362
+ ],
363
+ entries: [
364
+ {
365
+ id: "open",
366
+ slot: "frockbot.sidebar-actions",
367
+ label: "Weather board",
368
+ icon: "sparkle",
369
+ opens: { kind: "surface", page: "board" },
370
+ },
371
+ ],
230
372
  },
231
373
  },
232
374
  });
package/src/shared.ts CHANGED
@@ -9,7 +9,13 @@
9
9
  import {
10
10
  BOT_ISOLATE_HOOK_EVENTS_V1,
11
11
  isBotIsolateHookEventNameV1,
12
+ iframePageSlotAllowedV1,
12
13
  PACKAGE_BUNDLE_MAX_SOURCE_BYTES,
14
+ PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1,
15
+ PACKAGE_IFRAME_ENTRY_SLOT_V1,
16
+ PACKAGE_IFRAME_ID_V1,
17
+ PACKAGE_IFRAME_MAX_ENTRIES_V1,
18
+ PACKAGE_IFRAME_MAX_PAGES_V1,
13
19
  type BotIsolateHookEventNameV1,
14
20
  } from "@frockbot/kernel-contracts";
15
21
 
@@ -24,10 +30,21 @@ export interface AuthorPackageInputV1 {
24
30
  hooks?: BotIsolateHookEventNameV1[];
25
31
  /** TypeScript text; exactly one `package.ts`. */
26
32
  source: string;
27
- /** Optional sandboxed page. All CSS and JavaScript must be inline. */
33
+ /** Optional sandboxed pages. All CSS and JavaScript must be inline. */
28
34
  ui?: {
29
- html: string;
30
- mounts: Array<{ slot: string; order?: number }>;
35
+ pages: Array<{
36
+ id: string;
37
+ html: string;
38
+ mounts: Array<{ slot: string; order?: number }>;
39
+ }>;
40
+ entries?: Array<{
41
+ id: string;
42
+ slot: "frockbot.sidebar-actions";
43
+ order?: number;
44
+ label: string;
45
+ icon: string;
46
+ opens: { kind: "surface"; page: string };
47
+ }>;
31
48
  };
32
49
  }
33
50
 
@@ -259,62 +276,165 @@ export function decodeAuthorPackageInputV1(
259
276
  let ui: AuthorPackageInputV1["ui"];
260
277
  if (value.ui !== undefined) {
261
278
  const rawUi = record(value.ui, `${label}.ui`);
262
- exactKeys(rawUi, ["html", "mounts"], [], `${label}.ui`);
263
- const html = boundedString(
264
- rawUi.html,
265
- `${label}.ui.html`,
266
- PACKAGE_BUNDLE_MAX_SOURCE_BYTES,
267
- );
279
+ exactKeys(rawUi, ["pages"], ["entries"], `${label}.ui`);
268
280
  if (
269
- new TextEncoder().encode(html).byteLength >
270
- PACKAGE_BUNDLE_MAX_SOURCE_BYTES
281
+ !Array.isArray(rawUi.pages) ||
282
+ rawUi.pages.length === 0 ||
283
+ rawUi.pages.length > PACKAGE_IFRAME_MAX_PAGES_V1
271
284
  ) {
272
- throw new Error(`${label}.ui.html exceeds the per-Package source quota`);
285
+ throw new Error(`${label}.ui.pages must be a non-empty bounded array`);
273
286
  }
274
- if (
275
- /<(?:script|iframe|img|audio|video|source|embed|input)\b[^>]*\bsrc\s*=\s*["'](?!data:)/i.test(
276
- html,
277
- ) ||
278
- /<object\b[^>]*\bdata\s*=\s*["'](?!data:)/i.test(html) ||
279
- /\bsrcset\s*=/i.test(html) ||
280
- /<link\b/i.test(html) ||
281
- /@import\b/i.test(html) ||
282
- /<meta\b[^>]*http-equiv\s*=\s*["']?refresh/i.test(html) ||
283
- /url\(\s*["']?(?!data:|["']?\s*\))/i.test(html)
284
- ) {
285
- throw new Error(`${label}.ui.html may contain inline resources only`);
286
- }
287
- if (
288
- !Array.isArray(rawUi.mounts) ||
289
- rawUi.mounts.length === 0 ||
290
- rawUi.mounts.length > 64
291
- ) {
292
- throw new Error(`${label}.ui.mounts must be a non-empty bounded array`);
287
+ const pageIds = rawUi.pages.map((candidate, index) => {
288
+ const page = record(candidate, `${label}.ui.pages[${index}]`);
289
+ const id = boundedString(page.id, `${label}.ui.pages[${index}].id`, 32);
290
+ if (!PACKAGE_IFRAME_ID_V1.test(id)) {
291
+ throw new Error(`${label}.ui.pages[${index}].id is invalid`);
292
+ }
293
+ return id;
294
+ });
295
+ if (new Set(pageIds).size !== pageIds.length) {
296
+ throw new Error(`${label}.ui.pages contains duplicate ids`);
293
297
  }
294
- const mounts = rawUi.mounts.map((candidate, index) => {
295
- const mount = record(candidate, `${label}.ui.mounts[${index}]`);
296
- exactKeys(mount, ["slot"], ["order"], `${label}.ui.mounts[${index}]`);
297
- const slot = boundedString(
298
- mount.slot,
299
- `${label}.ui.mounts[${index}].slot`,
300
- 160,
298
+ const declaredTools = tools.map((tool) => tool.name);
299
+ const pages = rawUi.pages.map((candidate, index) => {
300
+ const pageLabel = `${label}.ui.pages[${index}]`;
301
+ const page = record(candidate, pageLabel);
302
+ exactKeys(page, ["id", "html", "mounts"], [], pageLabel);
303
+ const html = boundedString(
304
+ page.html,
305
+ `${pageLabel}.html`,
306
+ PACKAGE_BUNDLE_MAX_SOURCE_BYTES,
301
307
  );
302
308
  if (
303
- slot !== "frockbot.bot-settings-sections" &&
304
- !slot.startsWith("frockbot.tool-result:")
309
+ new TextEncoder().encode(html).byteLength >
310
+ PACKAGE_BUNDLE_MAX_SOURCE_BYTES
311
+ ) {
312
+ throw new Error(
313
+ `${pageLabel}.html exceeds the per-Package source quota`,
314
+ );
315
+ }
316
+ if (
317
+ /<(?:script|iframe|img|audio|video|source|embed|input)\b[^>]*\bsrc\s*=\s*["'](?!data:)/i.test(
318
+ html,
319
+ ) ||
320
+ /<object\b[^>]*\bdata\s*=\s*["'](?!data:)/i.test(html) ||
321
+ /\bsrcset\s*=/i.test(html) ||
322
+ /<link\b/i.test(html) ||
323
+ /@import\b/i.test(html) ||
324
+ /<meta\b[^>]*http-equiv\s*=\s*["']?refresh/i.test(html) ||
325
+ /url\(\s*["']?(?!data:|["']?\s*\))/i.test(html)
305
326
  ) {
306
- throw new Error(`${label}.ui.mounts[${index}].slot is not iframe-safe`);
327
+ throw new Error(`${pageLabel}.html may contain inline resources only`);
307
328
  }
308
- const order = mount.order;
309
329
  if (
310
- order !== undefined &&
311
- (typeof order !== "number" || !Number.isFinite(order))
330
+ !Array.isArray(page.mounts) ||
331
+ page.mounts.length === 0 ||
332
+ page.mounts.length > 64
312
333
  ) {
313
- throw new Error(`${label}.ui.mounts[${index}].order must be finite`);
334
+ throw new Error(
335
+ `${pageLabel}.mounts must be a non-empty bounded array`,
336
+ );
314
337
  }
315
- return { slot, ...(order === undefined ? {} : { order }) };
338
+ const mounts = page.mounts.map((candidateMount, mountIndex) => {
339
+ const mount = record(
340
+ candidateMount,
341
+ `${pageLabel}.mounts[${mountIndex}]`,
342
+ );
343
+ exactKeys(
344
+ mount,
345
+ ["slot"],
346
+ ["order"],
347
+ `${pageLabel}.mounts[${mountIndex}]`,
348
+ );
349
+ const slot = boundedString(
350
+ mount.slot,
351
+ `${pageLabel}.mounts[${mountIndex}].slot`,
352
+ 160,
353
+ );
354
+ if (!iframePageSlotAllowedV1(slot, { declaredTools, pageIds })) {
355
+ throw new Error(
356
+ `${pageLabel}.mounts[${mountIndex}].slot is not iframe-safe`,
357
+ );
358
+ }
359
+ const order = mount.order;
360
+ if (
361
+ order !== undefined &&
362
+ (typeof order !== "number" || !Number.isFinite(order))
363
+ ) {
364
+ throw new Error(
365
+ `${pageLabel}.mounts[${mountIndex}].order must be finite`,
366
+ );
367
+ }
368
+ return { slot, ...(order === undefined ? {} : { order }) };
369
+ });
370
+ return { id: pageIds[index]!, html, mounts };
316
371
  });
317
- ui = { html, mounts };
372
+ let entries: NonNullable<AuthorPackageInputV1["ui"]>["entries"];
373
+ if (rawUi.entries !== undefined) {
374
+ if (
375
+ !Array.isArray(rawUi.entries) ||
376
+ rawUi.entries.length > PACKAGE_IFRAME_MAX_ENTRIES_V1
377
+ ) {
378
+ throw new Error(`${label}.ui.entries must be a bounded array`);
379
+ }
380
+ entries = rawUi.entries.map((candidate, index) => {
381
+ const entryLabel = `${label}.ui.entries[${index}]`;
382
+ const entry = record(candidate, entryLabel);
383
+ exactKeys(
384
+ entry,
385
+ ["id", "slot", "label", "icon", "opens"],
386
+ ["order"],
387
+ entryLabel,
388
+ );
389
+ const id = boundedString(entry.id, `${entryLabel}.id`, 32);
390
+ if (!PACKAGE_IFRAME_ID_V1.test(id)) {
391
+ throw new Error(`${entryLabel}.id is invalid`);
392
+ }
393
+ if (entry.slot !== PACKAGE_IFRAME_ENTRY_SLOT_V1) {
394
+ throw new Error(`${entryLabel}.slot is invalid`);
395
+ }
396
+ const order = entry.order;
397
+ if (
398
+ order !== undefined &&
399
+ (typeof order !== "number" || !Number.isFinite(order))
400
+ ) {
401
+ throw new Error(`${entryLabel}.order must be finite`);
402
+ }
403
+ const opens = record(entry.opens, `${entryLabel}.opens`);
404
+ exactKeys(opens, ["kind", "page"], [], `${entryLabel}.opens`);
405
+ if (opens.kind !== "surface") {
406
+ throw new Error(`${entryLabel}.opens.kind is invalid`);
407
+ }
408
+ const page = boundedString(opens.page, `${entryLabel}.opens.page`, 32);
409
+ const target = pages.find((candidatePage) => candidatePage.id === page);
410
+ if (
411
+ !target ||
412
+ !target.mounts.some(
413
+ (mount) => mount.slot === `frockbot.surface:${page}`,
414
+ )
415
+ ) {
416
+ throw new Error(
417
+ `${entryLabel}.opens.page names no page with a matching surface mount`,
418
+ );
419
+ }
420
+ return {
421
+ id,
422
+ slot: PACKAGE_IFRAME_ENTRY_SLOT_V1 as "frockbot.sidebar-actions",
423
+ ...(order === undefined ? {} : { order }),
424
+ label: boundedString(
425
+ entry.label,
426
+ `${entryLabel}.label`,
427
+ PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1,
428
+ ),
429
+ icon: boundedString(entry.icon, `${entryLabel}.icon`, 64),
430
+ opens: { kind: "surface" as const, page },
431
+ };
432
+ });
433
+ if (new Set(entries.map((entry) => entry.id)).size !== entries.length) {
434
+ throw new Error(`${label}.ui.entries contains duplicate ids`);
435
+ }
436
+ }
437
+ ui = { pages, ...(entries ? { entries } : {}) };
318
438
  }
319
439
  return {
320
440
  packageId,
@@ -370,28 +490,81 @@ export const AUTHOR_PACKAGE_INPUT_SCHEMA_V1 = {
370
490
  ui: {
371
491
  type: "object",
372
492
  additionalProperties: false,
373
- required: ["html", "mounts"],
493
+ required: ["pages"],
374
494
  properties: {
375
- html: {
376
- type: "string",
495
+ pages: {
496
+ type: "array",
497
+ minItems: 1,
498
+ maxItems: PACKAGE_IFRAME_MAX_PAGES_V1,
377
499
  description:
378
- "One ui.html page (maximum 256 KB). CSS and JavaScript must be inline; images may use data: URLs.",
500
+ "Up to 8 sandboxed HTML pages, each mounted into one or more slots.",
501
+ items: {
502
+ type: "object",
503
+ additionalProperties: false,
504
+ required: ["id", "html", "mounts"],
505
+ properties: {
506
+ id: {
507
+ type: "string",
508
+ description:
509
+ "Lowercase page id, /^[a-z][a-z0-9-]{0,31}$/, unique in this Package.",
510
+ },
511
+ html: {
512
+ type: "string",
513
+ description:
514
+ "One HTML page (maximum 256 KB). CSS and JavaScript must be inline; images may use data: URLs.",
515
+ },
516
+ mounts: {
517
+ type: "array",
518
+ minItems: 1,
519
+ maxItems: 64,
520
+ items: {
521
+ type: "object",
522
+ additionalProperties: false,
523
+ required: ["slot"],
524
+ properties: {
525
+ slot: {
526
+ type: "string",
527
+ description:
528
+ "frockbot.bot-settings-sections, frockbot.tool-result:<declaredToolName>, frockbot.right-panel, or frockbot.surface:<pageId>",
529
+ },
530
+ order: { type: "number" },
531
+ },
532
+ },
533
+ },
534
+ },
535
+ },
379
536
  },
380
- mounts: {
537
+ entries: {
381
538
  type: "array",
382
- minItems: 1,
383
- maxItems: 64,
539
+ maxItems: PACKAGE_IFRAME_MAX_ENTRIES_V1,
540
+ description:
541
+ "Up to 4 sidebar launchers. Each opens one of your pages as an overlay surface; that page must mount frockbot.surface:<its own id>.",
384
542
  items: {
385
543
  type: "object",
386
544
  additionalProperties: false,
387
- required: ["slot"],
545
+ required: ["id", "slot", "label", "icon", "opens"],
388
546
  properties: {
389
- slot: {
547
+ id: {
390
548
  type: "string",
391
549
  description:
392
- "frockbot.bot-settings-sections or frockbot.tool-result:<declaredToolName>",
550
+ "Lowercase entry id, /^[a-z][a-z0-9-]{0,31}$/, unique in this Package.",
393
551
  },
552
+ slot: { type: "string", enum: [PACKAGE_IFRAME_ENTRY_SLOT_V1] },
394
553
  order: { type: "number" },
554
+ label: {
555
+ type: "string",
556
+ maxLength: PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1,
557
+ },
558
+ icon: { type: "string", description: "A FrockBot UiIcon name." },
559
+ opens: {
560
+ type: "object",
561
+ additionalProperties: false,
562
+ required: ["kind", "page"],
563
+ properties: {
564
+ kind: { type: "string", enum: ["surface"] },
565
+ page: { type: "string" },
566
+ },
567
+ },
395
568
  },
396
569
  },
397
570
  },
@@ -449,7 +622,8 @@ export async function authoringEffectIdV1(input: {
449
622
  runId: string;
450
623
  packageId: string;
451
624
  sourceHash: string;
452
- uiHtmlHash?: string;
625
+ /** Every page the bundler is asked for, in declaration order. */
626
+ uiPageHashes?: Array<{ id: string; htmlHash: string }>;
453
627
  hooks?: BotIsolateHookEventNameV1[];
454
628
  }): Promise<string> {
455
629
  const digest = await sha256HexV1(
@@ -457,7 +631,7 @@ export async function authoringEffectIdV1(input: {
457
631
  input.runId,
458
632
  input.packageId,
459
633
  input.sourceHash,
460
- input.uiHtmlHash ?? null,
634
+ (input.uiPageHashes ?? []).map((page) => [page.id, page.htmlHash]),
461
635
  input.hooks ?? [],
462
636
  ]),
463
637
  );
@@ -502,17 +676,21 @@ export function authoredManifestV1(input: {
502
676
  tools: AuthorPackageInputV1["tools"];
503
677
  hooks?: AuthorPackageInputV1["hooks"];
504
678
  ui?: {
505
- artifact: {
506
- contentHash: string;
507
- size: number;
508
- mediaType: "text/html";
509
- bundlerVersion: string;
510
- };
511
- mounts: Array<{ slot: string; order?: number }>;
679
+ pages: Array<{
680
+ id: string;
681
+ artifact: {
682
+ contentHash: string;
683
+ size: number;
684
+ mediaType: "text/html";
685
+ bundlerVersion: string;
686
+ };
687
+ mounts: Array<{ slot: string; order?: number }>;
688
+ }>;
689
+ entries?: NonNullable<AuthorPackageInputV1["ui"]>["entries"];
512
690
  };
513
691
  }): Record<string, unknown> {
514
692
  return {
515
- schemaVersion: 3,
693
+ schemaVersion: 5,
516
694
  id: input.packageId,
517
695
  displayName: input.displayName,
518
696
  version: input.version,
@@ -524,8 +702,19 @@ export function authoredManifestV1(input: {
524
702
  ? {
525
703
  client: {
526
704
  kind: "iframe",
527
- artifact: { ...input.ui.artifact },
528
- mounts: input.ui.mounts.map((mount) => ({ ...mount })),
705
+ pages: input.ui.pages.map((page) => ({
706
+ id: page.id,
707
+ artifact: { ...page.artifact },
708
+ mounts: page.mounts.map((mount) => ({ ...mount })),
709
+ })),
710
+ ...(input.ui.entries
711
+ ? {
712
+ entries: input.ui.entries.map((entry) => ({
713
+ ...entry,
714
+ opens: { ...entry.opens },
715
+ })),
716
+ }
717
+ : {}),
529
718
  },
530
719
  }
531
720
  : {}),