@frockbot/kernel-composition 0.1.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-composition",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "cordis": "4.0.0-rc.8",
22
22
  "semver": "7.8.5",
23
- "@frockbot/kernel-contracts": "0.1.4"
23
+ "@frockbot/kernel-contracts": "0.2.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "1.4.0",
package/src/compiler.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { decodeFrockBotManifest, type FrockBotManifest } from "./manifest.ts";
1
+ import {
2
+ decodeFrockBotManifest,
3
+ isClientIframeContribution,
4
+ type FrockBotManifest,
5
+ } from "./manifest.ts";
2
6
  import { satisfies, valid } from "semver";
3
7
 
4
8
  export type JsonValue =
@@ -166,7 +170,9 @@ function orderedPackages(
166
170
  function validateClientComposition(packages: readonly CompiledPackage[]): void {
167
171
  const clients = packages.flatMap((pkg) => {
168
172
  const client = pkg.manifest.contributions.client;
169
- return client ? [{ id: pkg.id, client }] : [];
173
+ return client && !isClientIframeContribution(client)
174
+ ? [{ id: pkg.id, client }]
175
+ : [];
170
176
  });
171
177
  const roots = clients.flatMap(({ id, client }) =>
172
178
  client.mounts.filter((mount) => mount.slot === "root").map(() => id),
@@ -200,4 +200,59 @@ describe("Composition generation v1", () => {
200
200
  }),
201
201
  ).toThrow("mediaType is invalid");
202
202
  });
203
+
204
+ test("decodes a Catalog isolate member and its plain-language generation summary", async () => {
205
+ const generation = await bootstrap();
206
+ const member = {
207
+ packageId: "parcel-tracking",
208
+ specifier: "catalog:parcel-tracking",
209
+ version: "0.0.1",
210
+ manifestHash: "c".repeat(64),
211
+ provenance: {
212
+ kind: "catalog" as const,
213
+ packageId: "parcel-tracking",
214
+ version: "0.0.1",
215
+ catalogId: "parcel-tracking",
216
+ catalogGeneration: "catalog-1",
217
+ contentHash: "d".repeat(64),
218
+ },
219
+ artifact: {
220
+ contentHash: "d".repeat(64),
221
+ size: 512,
222
+ mediaType: "application/javascript" as const,
223
+ bundlerVersion: "catalog-test@1",
224
+ },
225
+ };
226
+ const decoded = decodeCompositionGenerationV1({
227
+ ...generation,
228
+ members: [member],
229
+ artifactSetHash: await compositionArtifactSetHashV1([member]),
230
+ origin: {
231
+ kind: "bot-catalog",
232
+ action: "install",
233
+ packageId: "parcel-tracking",
234
+ catalogId: "parcel-tracking",
235
+ botId: "primary",
236
+ runId: "run-1",
237
+ sessionId: "user-1:primary",
238
+ turnId: "turn-1",
239
+ },
240
+ summary: "Added parcel tracking",
241
+ });
242
+
243
+ expect(decoded.summary).toBe("Added parcel tracking");
244
+ expect(decoded.members[0]?.provenance.kind).toBe("catalog");
245
+ expect(() =>
246
+ decodeCompositionGenerationV1({
247
+ ...decoded,
248
+ members: [{ ...member, artifact: undefined }],
249
+ }),
250
+ ).toThrow("must match its Bot-isolate artifact");
251
+ expect(() =>
252
+ decodeCompositionGenerationV1({
253
+ ...decoded,
254
+ summary: "Added\nparcel tracking",
255
+ }),
256
+ ).toThrow("must be one trimmed line");
257
+ });
203
258
  });
package/src/generation.ts CHANGED
@@ -7,6 +7,14 @@ import { canonicalJson, sha256 } from "./compiler.ts";
7
7
 
8
8
  export type PackageProvenanceV1 =
9
9
  | { kind: "first-party"; packageId: string; version: string }
10
+ | {
11
+ kind: "catalog";
12
+ packageId: string;
13
+ version: string;
14
+ catalogId: string;
15
+ catalogGeneration: string;
16
+ contentHash: string;
17
+ }
10
18
  | {
11
19
  kind: "user";
12
20
  packageId: string;
@@ -46,8 +54,25 @@ export interface CompositionMemberV1 {
46
54
  export type CompositionOriginV1 =
47
55
  | { kind: "bootstrap" }
48
56
  | { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
57
+ | {
58
+ kind: "bot-catalog";
59
+ action: "install" | "update" | "remove";
60
+ packageId: string;
61
+ catalogId: string;
62
+ botId: string;
63
+ runId: string;
64
+ sessionId: string;
65
+ turnId: string;
66
+ }
49
67
  | { kind: "user-install"; userId: string }
50
- | { kind: "revert"; revertsTo: string; userId: string };
68
+ | { kind: "revert"; revertsTo: string; userId: string }
69
+ | {
70
+ kind: "revert";
71
+ revertsTo: string;
72
+ botId: string;
73
+ runId: string;
74
+ turnId: string;
75
+ };
51
76
 
52
77
  export type CompositionGenerationStatusV1 =
53
78
  "pending" | "active" | "superseded" | "failed" | "quarantined";
@@ -59,6 +84,8 @@ export interface CompositionGenerationV1 {
59
84
  /** sha-256 over the canonical member list — the loader identity. */
60
85
  artifactSetHash: string;
61
86
  parentGenerationId?: string;
87
+ /** Bot-written one-line audit copy for a setup change. */
88
+ summary?: string;
62
89
  createdAt: string;
63
90
  origin: CompositionOriginV1;
64
91
  members: CompositionMemberV1[];
@@ -83,6 +110,7 @@ export interface CompositionStore {
83
110
  revert(
84
111
  toGenerationId: string,
85
112
  origin: Extract<CompositionOriginV1, { kind: "revert" }>,
113
+ options?: { createdAt?: string },
86
114
  ): Promise<CompositionGenerationV1>;
87
115
  list(query: {
88
116
  limit: number;
@@ -115,7 +143,7 @@ const GENERATION_REQUIRED_KEYS = [
115
143
  "members",
116
144
  "status",
117
145
  ] as const;
118
- const GENERATION_OPTIONAL_KEYS = ["parentGenerationId"] as const;
146
+ const GENERATION_OPTIONAL_KEYS = ["parentGenerationId", "summary"] as const;
119
147
  const MEMBER_REQUIRED_KEYS = [
120
148
  "packageId",
121
149
  "specifier",
@@ -132,6 +160,7 @@ const ARTIFACT_KEYS = [
132
160
  ] as const;
133
161
  const MAX_COMPOSITION_MEMBERS = 512;
134
162
  const SHA256_HEX = /^[0-9a-f]{64}$/;
163
+ export const MAX_COMPOSITION_SUMMARY_V1 = 160;
135
164
 
136
165
  function record(value: unknown, label: string): Record<string, unknown> {
137
166
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -195,6 +224,17 @@ function decodePackageProvenanceV1(
195
224
  if (kind === "first-party") {
196
225
  exactKeys(value, common, [], label);
197
226
  identity();
227
+ } else if (kind === "catalog") {
228
+ exactKeys(
229
+ value,
230
+ [...common, "catalogId", "catalogGeneration", "contentHash"],
231
+ [],
232
+ label,
233
+ );
234
+ identity();
235
+ boundedString(value.catalogId, `${label}.catalogId`, 64);
236
+ boundedString(value.catalogGeneration, `${label}.catalogGeneration`, 64);
237
+ hashString(value.contentHash, `${label}.contentHash`);
198
238
  } else if (kind === "user") {
199
239
  exactKeys(value, [...common, "userId", "authoredAt"], [], label);
200
240
  identity();
@@ -251,15 +291,25 @@ function decodeCompositionMemberV1(
251
291
  if (provenance.packageId !== packageId || provenance.version !== version) {
252
292
  throw new Error(`${label}.provenance does not match its member`);
253
293
  }
294
+ const artifact =
295
+ value.artifact === undefined
296
+ ? undefined
297
+ : decodeArtifactRefV1(value.artifact, `${label}.artifact`);
298
+ if (
299
+ provenance.kind === "catalog" &&
300
+ (!artifact || artifact.contentHash !== provenance.contentHash)
301
+ ) {
302
+ throw new Error(
303
+ `${label}.catalog provenance must match its Bot-isolate artifact`,
304
+ );
305
+ }
254
306
  return {
255
307
  packageId,
256
308
  specifier,
257
309
  version,
258
310
  manifestHash,
259
311
  provenance,
260
- ...(value.artifact === undefined
261
- ? {}
262
- : { artifact: decodeArtifactRefV1(value.artifact, `${label}.artifact`) }),
312
+ ...(artifact === undefined ? {} : { artifact }),
263
313
  };
264
314
  }
265
315
 
@@ -276,13 +326,54 @@ function decodeCompositionOriginV1(
276
326
  boundedString(value.runId, `${label}.runId`, 128);
277
327
  boundedString(value.sessionId, `${label}.sessionId`, 257);
278
328
  boundedString(value.turnId, `${label}.turnId`, 128);
329
+ } else if (kind === "bot-catalog") {
330
+ exactKeys(
331
+ value,
332
+ [
333
+ "kind",
334
+ "action",
335
+ "packageId",
336
+ "catalogId",
337
+ "botId",
338
+ "runId",
339
+ "sessionId",
340
+ "turnId",
341
+ ],
342
+ [],
343
+ label,
344
+ );
345
+ if (
346
+ value.action !== "install" &&
347
+ value.action !== "update" &&
348
+ value.action !== "remove"
349
+ ) {
350
+ throw new Error(`${label}.action is invalid`);
351
+ }
352
+ boundedString(value.packageId, `${label}.packageId`, 128);
353
+ boundedString(value.catalogId, `${label}.catalogId`, 64);
354
+ boundedString(value.botId, `${label}.botId`, 256);
355
+ boundedString(value.runId, `${label}.runId`, 128);
356
+ boundedString(value.sessionId, `${label}.sessionId`, 257);
357
+ boundedString(value.turnId, `${label}.turnId`, 128);
279
358
  } else if (kind === "user-install") {
280
359
  exactKeys(value, ["kind", "userId"], [], label);
281
360
  boundedString(value.userId, `${label}.userId`, 256);
282
361
  } else if (kind === "revert") {
283
- exactKeys(value, ["kind", "revertsTo", "userId"], [], label);
284
362
  boundedString(value.revertsTo, `${label}.revertsTo`, 256);
285
- boundedString(value.userId, `${label}.userId`, 256);
363
+ if (Object.hasOwn(value, "userId")) {
364
+ exactKeys(value, ["kind", "revertsTo", "userId"], [], label);
365
+ boundedString(value.userId, `${label}.userId`, 256);
366
+ } else {
367
+ exactKeys(
368
+ value,
369
+ ["kind", "revertsTo", "botId", "runId", "turnId"],
370
+ [],
371
+ label,
372
+ );
373
+ boundedString(value.botId, `${label}.botId`, 256);
374
+ boundedString(value.runId, `${label}.runId`, 128);
375
+ boundedString(value.turnId, `${label}.turnId`, 128);
376
+ }
286
377
  } else {
287
378
  throw new Error(`${label}.kind is invalid`);
288
379
  }
@@ -331,6 +422,16 @@ export function decodeCompositionGenerationV1(
331
422
  if (value.parentGenerationId !== undefined) {
332
423
  boundedString(value.parentGenerationId, `${label}.parentGenerationId`, 256);
333
424
  }
425
+ if (value.summary !== undefined) {
426
+ const summary = boundedString(
427
+ value.summary,
428
+ `${label}.summary`,
429
+ MAX_COMPOSITION_SUMMARY_V1,
430
+ );
431
+ if (summary.trim() !== summary || /[\r\n]/u.test(summary)) {
432
+ throw new Error(`${label}.summary must be one trimmed line`);
433
+ }
434
+ }
334
435
  return {
335
436
  schemaVersion: 1,
336
437
  generationId,
@@ -342,6 +443,9 @@ export function decodeCompositionGenerationV1(
342
443
  ...(value.parentGenerationId === undefined
343
444
  ? {}
344
445
  : { parentGenerationId: value.parentGenerationId as string }),
446
+ ...(value.summary === undefined
447
+ ? {}
448
+ : { summary: value.summary as string }),
345
449
  };
346
450
  }
347
451
 
package/src/index.test.ts CHANGED
@@ -295,6 +295,84 @@ describe("PackageCatalog", () => {
295
295
  });
296
296
 
297
297
  describe("decodeFrockBotManifest", () => {
298
+ test("accepts iframe UI only in settings or its own declared tool-result slots", () => {
299
+ const manifest = {
300
+ schemaVersion: 3,
301
+ id: "weather-page",
302
+ displayName: "Weather page",
303
+ version: "0.0.1",
304
+ compatibility: { frockbot: ">=0.0.1" },
305
+ dependencies: {},
306
+ contributions: {
307
+ runtime: { entry: "./package.js", host: "bot-isolate" },
308
+ client: {
309
+ kind: "iframe",
310
+ artifact: {
311
+ contentHash: "a".repeat(64),
312
+ size: 123,
313
+ mediaType: "text/html",
314
+ bundlerVersion: "frockbot-inline-html@1",
315
+ },
316
+ mounts: [
317
+ { slot: "frockbot.tool-result:weather_lookup" },
318
+ { slot: "frockbot.bot-settings-sections", order: 10 },
319
+ ],
320
+ },
321
+ },
322
+ tools: [
323
+ { name: "weather_lookup", description: "Weather", inputSchema: {} },
324
+ ],
325
+ hooks: ["agent/tool-exposure"],
326
+ permissions: [],
327
+ };
328
+ const decoded = decodeFrockBotManifest(manifest);
329
+ const client = decoded.contributions.client;
330
+ expect(client && "kind" in client ? client.kind : undefined).toBe("iframe");
331
+ expect(client?.mounts[0]).toEqual({
332
+ slot: "frockbot.tool-result:weather_lookup",
333
+ });
334
+ expect(decoded.hooks).toEqual(["agent/tool-exposure"]);
335
+ expect(() =>
336
+ decodeFrockBotManifest({
337
+ ...manifest,
338
+ contributions: {
339
+ ...manifest.contributions,
340
+ client: {
341
+ ...manifest.contributions.client,
342
+ mounts: [{ slot: "root" }],
343
+ },
344
+ },
345
+ }),
346
+ ).toThrow("not iframe-safe");
347
+ expect(() =>
348
+ decodeFrockBotManifest({
349
+ ...manifest,
350
+ contributions: {
351
+ ...manifest.contributions,
352
+ client: {
353
+ ...manifest.contributions.client,
354
+ mounts: [{ slot: "frockbot.tool-result:package_author" }],
355
+ },
356
+ },
357
+ }),
358
+ ).toThrow("undeclared tool");
359
+ expect(() =>
360
+ decodeFrockBotManifest({
361
+ ...manifest,
362
+ contributions: {
363
+ ...manifest.contributions,
364
+ client: {
365
+ ...manifest.contributions.client,
366
+ artifact: {
367
+ ...manifest.contributions.client.artifact,
368
+ size: 256 * 1024 + 1,
369
+ },
370
+ },
371
+ },
372
+ }),
373
+ ).toThrow("256 KB quota");
374
+ });
375
+
298
376
  test("keeps trusted Electron main execution exclusive to manifest v3", () => {
299
377
  const contribution = {
300
378
  desktop: {
@@ -1127,6 +1205,70 @@ describe("decodeFrockBotManifest", () => {
1127
1205
  ).toThrow("manifest setting schema is too large");
1128
1206
  });
1129
1207
 
1208
+ test("decodes the exact Bot isolate runtime and tool declaration", () => {
1209
+ const decoded = decodeFrockBotManifest({
1210
+ schemaVersion: 3,
1211
+ id: "authored",
1212
+ displayName: "Authored",
1213
+ version: "0.0.1",
1214
+ compatibility: { frockbot: ">=0.0.1" },
1215
+ dependencies: {},
1216
+ contributions: {
1217
+ runtime: { entry: "./package.js", host: "bot-isolate" },
1218
+ },
1219
+ tools: [
1220
+ {
1221
+ name: "look_up",
1222
+ description: "Looks up a value",
1223
+ inputSchema: { type: "object" },
1224
+ },
1225
+ ],
1226
+ hooks: ["agent/tool-exposure", "tools/post-execute"],
1227
+ permissions: [],
1228
+ });
1229
+
1230
+ expect(decoded.contributions.runtime?.host).toBe("bot-isolate");
1231
+ expect(decoded.tools?.map((tool) => tool.name)).toEqual(["look_up"]);
1232
+ expect(decoded.hooks).toEqual([
1233
+ "agent/tool-exposure",
1234
+ "tools/post-execute",
1235
+ ]);
1236
+ });
1237
+
1238
+ test("requires Bot isolate runtime and tools declarations together", () => {
1239
+ const base = {
1240
+ schemaVersion: 3,
1241
+ id: "authored",
1242
+ displayName: "Authored",
1243
+ version: "0.0.1",
1244
+ compatibility: { frockbot: ">=0.0.1" },
1245
+ dependencies: {},
1246
+ permissions: [],
1247
+ };
1248
+ expect(() =>
1249
+ decodeFrockBotManifest({
1250
+ ...base,
1251
+ contributions: {
1252
+ runtime: { entry: "./package.js", host: "bot-isolate" },
1253
+ },
1254
+ }),
1255
+ ).toThrow(/must appear together/);
1256
+ expect(() =>
1257
+ decodeFrockBotManifest({
1258
+ ...base,
1259
+ contributions: { runtime: { entry: "./package.js" } },
1260
+ hooks: ["agent/tool-exposure"],
1261
+ }),
1262
+ ).toThrow(/hooks require a bot-isolate/);
1263
+ expect(() =>
1264
+ decodeFrockBotManifest({
1265
+ ...base,
1266
+ contributions: { runtime: { entry: "./package.js" } },
1267
+ tools: [{ name: "look_up", description: "Looks", inputSchema: {} }],
1268
+ }),
1269
+ ).toThrow(/must appear together/);
1270
+ });
1271
+
1130
1272
  test("orders normalized contribution kinds", () => {
1131
1273
  const decoded = decodeFrockBotManifest({
1132
1274
  schemaVersion: 3,