@frockbot/kernel-composition 0.1.3 → 0.1.4

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.3",
3
+ "version": "0.1.4",
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.3"
23
+ "@frockbot/kernel-contracts": "0.1.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "1.4.0",
@@ -12,6 +12,7 @@ function runtimeManifest(
12
12
  version?: string;
13
13
  permissions?: string[];
14
14
  dependencies?: Record<string, string>;
15
+ defaultEnablement?: "enabled" | "disabled";
15
16
  compatibility?: string;
16
17
  } = {},
17
18
  ) {
@@ -22,6 +23,7 @@ function runtimeManifest(
22
23
  version: options.version ?? "1.0.0",
23
24
  compatibility: { frockbot: options.compatibility ?? ">=0.0.1" },
24
25
  dependencies: options.dependencies,
26
+ defaultEnablement: options.defaultEnablement,
25
27
  contributions: { runtime: { entry: "./runtime" } },
26
28
  permissions: options.permissions ?? [],
27
29
  };
@@ -191,6 +193,21 @@ describe("compileApplicationPlan", () => {
191
193
  );
192
194
  });
193
195
 
196
+ test("allows a disabled Package to depend on a Catalog Package", async () => {
197
+ const plan = await compileApplicationPlan(
198
+ { schemaVersion: 1, packages: [selection("@fixture/feature")] },
199
+ resolver({
200
+ "@fixture/feature": runtimeManifest("feature", {
201
+ dependencies: { optional: "^1.0.0" },
202
+ defaultEnablement: "disabled",
203
+ }),
204
+ }),
205
+ { frockbotVersion: "1.0.0" },
206
+ );
207
+
208
+ expect(plan.packages[0]?.manifest.defaultEnablement).toBe("disabled");
209
+ });
210
+
194
211
  test("validates client roots and declared outlets", async () => {
195
212
  const clientManifest = (
196
213
  id: string,
package/src/compiler.ts CHANGED
@@ -139,6 +139,10 @@ function orderedPackages(
139
139
  ).sort(([left], [right]) => left.localeCompare(right))) {
140
140
  const dependency = packages.get(dependencyId);
141
141
  if (!dependency) {
142
+ // A disabled-by-default Package may depend on a Package available only
143
+ // from the User's Catalog. Settings refuses to enable it until an
144
+ // installed dependency row exists; enabled built-ins still fail here.
145
+ if (pkg.manifest.defaultEnablement === "disabled") continue;
142
146
  throw new Error(
143
147
  `package "${pkg.id}" requires missing package "${dependencyId}"`,
144
148
  );
package/src/index.test.ts CHANGED
@@ -427,11 +427,95 @@ describe("decodeFrockBotManifest", () => {
427
427
  });
428
428
  });
429
429
 
430
+ test("decodes the exact provider-neutral model setting role", () => {
431
+ const decoded = decodeFrockBotManifest({
432
+ ...manifest("custom-models"),
433
+ configuration: {
434
+ settings: [
435
+ {
436
+ id: "model",
437
+ schemaVersion: 1,
438
+ scopes: ["user", "bot"],
439
+ role: "model",
440
+ schema: {
441
+ type: "object",
442
+ properties: {
443
+ connectionId: { type: "string" },
444
+ providerModelId: { type: "string" },
445
+ },
446
+ required: ["connectionId", "providerModelId"],
447
+ additionalProperties: false,
448
+ },
449
+ },
450
+ ],
451
+ },
452
+ });
453
+
454
+ expect(decoded.configuration?.settings).toEqual([
455
+ {
456
+ id: "model",
457
+ schemaVersion: 1,
458
+ scopes: ["user", "bot"],
459
+ role: "model",
460
+ schema: {
461
+ type: "object",
462
+ properties: {
463
+ connectionId: { type: "string" },
464
+ providerModelId: { type: "string" },
465
+ },
466
+ required: ["connectionId", "providerModelId"],
467
+ additionalProperties: false,
468
+ },
469
+ },
470
+ ]);
471
+ });
472
+
473
+ test("rejects any other schema for a model-role setting", () => {
474
+ const exact = {
475
+ type: "object",
476
+ properties: {
477
+ connectionId: { type: "string" },
478
+ providerModelId: { type: "string" },
479
+ },
480
+ required: ["connectionId", "providerModelId"],
481
+ additionalProperties: false,
482
+ };
483
+ for (const schema of [
484
+ { ...exact, additionalProperties: true },
485
+ { ...exact, required: ["connectionId"] },
486
+ {
487
+ ...exact,
488
+ properties: {
489
+ ...exact.properties,
490
+ providerModelId: { type: "number" },
491
+ },
492
+ },
493
+ { ...exact, title: "Choose a model" },
494
+ ]) {
495
+ expect(() =>
496
+ decodeFrockBotManifest({
497
+ ...manifest("custom-models"),
498
+ configuration: {
499
+ settings: [
500
+ {
501
+ id: "model",
502
+ schemaVersion: 1,
503
+ scopes: ["user"],
504
+ role: "model",
505
+ schema,
506
+ },
507
+ ],
508
+ },
509
+ }),
510
+ ).toThrow(/model setting schema must be exactly/);
511
+ }
512
+ });
513
+
430
514
  test("decodes an ambient-native Connection without an authorization driver", () => {
431
515
  const decoded = decodeFrockBotManifest({
432
516
  schemaVersion: 4,
433
- id: "workers-ai",
434
- displayName: "Workers AI",
517
+ id: "flock-ai",
518
+ displayName: "Flock AI",
435
519
  version: "1.0.0",
436
520
  compatibility: { frockbot: ">=0.0.1" },
437
521
  contributions: { runtime: { entry: "./runtime" } },
@@ -439,18 +523,18 @@ describe("decodeFrockBotManifest", () => {
439
523
  configuration: {
440
524
  connectionTypes: [
441
525
  {
442
- id: "workers-ai-account",
443
- displayName: "Workers AI",
526
+ id: "flock-ai-account",
527
+ displayName: "Flock AI",
444
528
  allowMultiple: false,
445
529
  authorization: { kind: "ambient-native" },
446
- capabilities: ["workers-ai-models"],
530
+ capabilities: ["flock-ai-models"],
447
531
  },
448
532
  ],
449
533
  capabilities: [
450
534
  {
451
- id: "workers-ai-models",
535
+ id: "flock-ai-models",
452
536
  kind: "model",
453
- connectionTypes: ["workers-ai-account"],
537
+ connectionTypes: ["flock-ai-account"],
454
538
  },
455
539
  ],
456
540
  },
@@ -178,7 +178,7 @@ describe("Bot isolate contribution host", () => {
178
178
  test("a caller that omits the binding digest does not compile", () => {
179
179
  // @ts-expect-error the binding digest is required: an isolate loaded with
180
180
  // no digest of its granted bindings would share a loader id across
181
- // Assignments and generations.
181
+ // enabled bindings and generations.
182
182
  void botIsolateModuleSetHashV1(CONTENT_HASH);
183
183
  expect(true).toBe(true);
184
184
  });
@@ -191,20 +191,60 @@ describe("Bot isolate contribution host", () => {
191
191
  expect(loads[0]!.loaderId).not.toBe(other.loads[0]!.loaderId);
192
192
  });
193
193
 
194
- test("keys the loader id on the User, the Bot and the module set", async () => {
194
+ test("reuses the loader id only for the same Bot, generation, and enabled set", async () => {
195
195
  const { host: subject, loads } = host();
196
196
  await subject.prepare(descriptor());
197
- const other = host({ botId: "bot-2" });
198
- await other.host.prepare(descriptor());
197
+ const same = host();
198
+ await same.host.prepare(descriptor());
199
199
  const expected = await botIsolateModuleSetHashV1(
200
200
  CONTENT_HASH,
201
201
  BINDING_DIGEST,
202
202
  );
203
- expect(loads[0]!.loaderId).toBe(`bot-package:user-1:bot-1:${expected}`);
204
- expect(other.loads[0]!.loaderId).toBe(
205
- `bot-package:user-1:bot-2:${expected}`,
203
+ expect(loads[0]!.loaderId).toBe(`bot-package:user-1:${expected}`);
204
+ expect(same.loads[0]!.loaderId).toBe(loads[0]!.loaderId);
205
+ });
206
+
207
+ test("changes the loader id with the Bot, generation, or enabled set", async () => {
208
+ const first = host();
209
+ const otherBot = host({ botId: "bot-2", bindingDigest: "d".repeat(64) });
210
+ const otherGeneration = host({
211
+ generationId: "gen-2",
212
+ bindingDigest: "e".repeat(64),
213
+ });
214
+ const otherEnabledSet = host({ bindingDigest: "f".repeat(64) });
215
+
216
+ await Promise.all([
217
+ first.host.prepare(descriptor()),
218
+ otherBot.host.prepare(descriptor()),
219
+ otherGeneration.host.prepare(descriptor()),
220
+ otherEnabledSet.host.prepare(descriptor()),
221
+ ]);
222
+
223
+ const loaderIds = [
224
+ first.loads[0]!.loaderId,
225
+ otherBot.loads[0]!.loaderId,
226
+ otherGeneration.loads[0]!.loaderId,
227
+ otherEnabledSet.loads[0]!.loaderId,
228
+ ];
229
+ expect(new Set(loaderIds).size).toBe(loaderIds.length);
230
+ });
231
+
232
+ test("never shares a loader id across Users", async () => {
233
+ const first = host();
234
+ const otherUser = host({ userId: "user-2" });
235
+
236
+ await Promise.all([
237
+ first.host.prepare(descriptor()),
238
+ otherUser.host.prepare(descriptor()),
239
+ ]);
240
+
241
+ expect(first.loads[0]!.loaderId).toMatch(
242
+ /^bot-package:user-1:[0-9a-f]{64}$/,
243
+ );
244
+ expect(otherUser.loads[0]!.loaderId).toMatch(
245
+ /^bot-package:user-2:[0-9a-f]{64}$/,
206
246
  );
207
- expect(loads[0]!.loaderId).not.toBe(other.loads[0]!.loaderId);
247
+ expect(otherUser.loads[0]!.loaderId).not.toBe(first.loads[0]!.loaderId);
208
248
  });
209
249
 
210
250
  test("health failure is a prepare failure with a diagnostic", async () => {
@@ -5,12 +5,12 @@
5
5
  // It sits beside `LocalCordisContributionHost` because it is the *other*
6
6
  // execution host the constitution names — first-party Packages run in the
7
7
  // kernel isolate, everything else runs in a loaded Worker with
8
- // `globalOutbound` disabled and only Assignment-derived bindings.
8
+ // `globalOutbound` disabled and only enabled-capability bindings.
9
9
  //
10
10
  // Two behaviours come straight from `docs/research/spike-worker-loader-from-do.md`:
11
11
  // `.get()` never throws, so mount and `health()` are a single guarded phase;
12
12
  // and a reused loader id silently serves the first code, so the id is nothing
13
- // but the content address of the module set actually mounted.
13
+ // but the content address of the mounted modules and their baked-in bindings.
14
14
  import {
15
15
  decodeIsolateHealthV1,
16
16
  decodeIsolateToolResultV1,
@@ -95,11 +95,11 @@ export interface BotIsolateHostOptions {
95
95
  */
96
96
  capabilities: BotCapabilitiesStub;
97
97
  /**
98
- * A content address of the Assignment-derived bindings this isolate is
99
- * loaded with. Required, and part of the loader id, because a loader id is
100
- * served from cache: the `env` a Bot isolate was first loaded with is the
101
- * `env` it keeps, so a change in the Bot's Assignments must produce a new
102
- * isolate or the isolate would keep answering from a revoked authority.
98
+ * A content address of every binding this isolate is loaded with: User, Bot,
99
+ * Composition generation, and the User's enabled capability set. Required,
100
+ * and part of the loader id, because the `env` first loaded under one id is
101
+ * the `env` it keeps. Any changed binding must therefore produce a new
102
+ * isolate (AGENTS.md Package composition; ADR 0019).
103
103
  */
104
104
  bindingDigest: string;
105
105
  compatibilityDate: string;
@@ -120,8 +120,8 @@ export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
120
120
 
121
121
  /**
122
122
  * The content address of what a Bot isolate mounts: the kernel wrapper text,
123
- * the Package artifact, and the digest of the Assignment-derived bindings it
124
- * is loaded with. A change to any of the three is a new isolate.
123
+ * the Package artifact, and the digest of every binding baked into its `env`.
124
+ * A change to any of the three is a new isolate.
125
125
  */
126
126
  export async function botIsolateModuleSetHashV1(
127
127
  artifactContentHash: string,
@@ -241,7 +241,6 @@ export class BotIsolateContributionHost implements ContributionHost {
241
241
  const source = await this.loadSource(packageId, artifact.contentHash);
242
242
  const loaderId = isolateLoaderIdV1({
243
243
  userId: this.options.userId,
244
- botId: this.options.botId,
245
244
  artifactSetHash: await botIsolateModuleSetHashV1(
246
245
  artifact.contentHash,
247
246
  this.options.bindingDigest,
package/src/manifest.ts CHANGED
@@ -81,6 +81,12 @@ export interface PackageSettingDefinition {
81
81
  id: string;
82
82
  schemaVersion: number;
83
83
  scopes: SettingScope[];
84
+ /**
85
+ * A kernel-consumed semantic role. The model role is deliberately generic:
86
+ * ADR 0019 lets a Package opt the User into model choice without teaching
87
+ * the kernel that Package's identity or policy.
88
+ */
89
+ role?: "model";
84
90
  schema: PackageSettingSchema;
85
91
  }
86
92
 
@@ -130,6 +136,7 @@ export interface FrockBotManifest {
130
136
  version: string;
131
137
  compatibility: { frockbot: string };
132
138
  dependencies: Record<string, string>;
139
+ defaultEnablement?: "enabled" | "disabled";
133
140
  contributions: {
134
141
  backend?: BackendContribution[];
135
142
  runtime?: RuntimeContribution;
@@ -221,6 +228,18 @@ function decodeDependencies(value: unknown): Record<string, string> {
221
228
  return dependencies;
222
229
  }
223
230
 
231
+ function decodeDefaultEnablement(
232
+ value: unknown,
233
+ ): FrockBotManifest["defaultEnablement"] {
234
+ if (value === undefined) return undefined;
235
+ if (value !== "enabled" && value !== "disabled") {
236
+ throw new Error(
237
+ 'manifest defaultEnablement must be "enabled" or "disabled"',
238
+ );
239
+ }
240
+ return value;
241
+ }
242
+
224
243
  function decodeIdentity(
225
244
  value: Record<string, unknown>,
226
245
  ): Pick<FrockBotManifest, "id" | "displayName" | "version" | "permissions"> {
@@ -301,6 +320,7 @@ function isV3OrLater(value: Record<string, unknown>): boolean {
301
320
 
302
321
  function decodeV2(value: Record<string, unknown>): FrockBotManifest {
303
322
  const identity = decodeIdentity(value);
323
+ const defaultEnablement = decodeDefaultEnablement(value.defaultEnablement);
304
324
  if (!isRecord(value.compatibility)) {
305
325
  throw new Error("manifest compatibility must be an object");
306
326
  }
@@ -437,6 +457,7 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
437
457
  frockbot: requiredString(value.compatibility, "frockbot"),
438
458
  },
439
459
  dependencies: decodeDependencies(value.dependencies),
460
+ ...(defaultEnablement ? { defaultEnablement } : {}),
440
461
  contributions,
441
462
  };
442
463
  }
@@ -905,6 +926,44 @@ function safeSchema(value: unknown): PackageSettingSchema {
905
926
  return decodeSafeSchema(value, 0);
906
927
  }
907
928
 
929
+ /**
930
+ * The one object contract the kernel interprets from a setting value. Keeping
931
+ * this exact prevents a Package from smuggling provider policy into the model
932
+ * seam while still letting ordinary settings use the supported schema subset.
933
+ */
934
+ function assertModelBindingSchema(schema: PackageSettingSchema): void {
935
+ const fields = Reflect.ownKeys(schema);
936
+ const properties = schema.properties;
937
+ const required = schema.required;
938
+ if (
939
+ fields.length !== 4 ||
940
+ !fields.every((field) =>
941
+ ["type", "properties", "required", "additionalProperties"].includes(
942
+ String(field),
943
+ ),
944
+ ) ||
945
+ schema.type !== "object" ||
946
+ schema.additionalProperties !== false ||
947
+ !properties ||
948
+ Reflect.ownKeys(properties).length !== 2 ||
949
+ !Object.hasOwn(properties, "connectionId") ||
950
+ !Object.hasOwn(properties, "providerModelId") ||
951
+ Reflect.ownKeys(properties.connectionId ?? {}).length !== 1 ||
952
+ properties.connectionId?.type !== "string" ||
953
+ Reflect.ownKeys(properties.providerModelId ?? {}).length !== 1 ||
954
+ properties.providerModelId?.type !== "string" ||
955
+ !required ||
956
+ required.length !== 2 ||
957
+ new Set(required).size !== 2 ||
958
+ !required.includes("connectionId") ||
959
+ !required.includes("providerModelId")
960
+ ) {
961
+ throw new Error(
962
+ 'manifest model setting schema must be exactly an object with required string properties "connectionId" and "providerModelId" and no additional properties',
963
+ );
964
+ }
965
+ }
966
+
908
967
  function decodeCapabilityAdmission(value: unknown): {
909
968
  turnTypes: TurnTypeV1[];
910
969
  subagentRoles?: string[];
@@ -984,7 +1043,13 @@ function settingDefinitions(
984
1043
  // that round-trips through this decoder decodes again unchanged.
985
1044
  exactFields(
986
1045
  setting,
987
- ["id", "schemaVersion", "schema", "scopes"],
1046
+ [
1047
+ "id",
1048
+ "schemaVersion",
1049
+ "schema",
1050
+ "scopes",
1051
+ ...(scope === "package" ? ["role"] : []),
1052
+ ],
988
1053
  "manifest setting definition",
989
1054
  );
990
1055
  const schemaVersion = setting.schemaVersion;
@@ -1019,11 +1084,17 @@ function settingDefinitions(
1019
1084
  ) {
1020
1085
  throw new Error("manifest setting scopes must contain user or bot");
1021
1086
  }
1087
+ if (setting.role !== undefined && setting.role !== "model") {
1088
+ throw new Error('manifest setting role must be "model"');
1089
+ }
1090
+ const schema = safeSchema(setting.schema);
1091
+ if (setting.role === "model") assertModelBindingSchema(schema);
1022
1092
  return {
1023
1093
  id: definitionId(setting),
1024
1094
  schemaVersion: schemaVersion as number,
1025
1095
  scopes: scopes as SettingScope[],
1026
- schema: safeSchema(setting.schema),
1096
+ ...(setting.role === undefined ? {} : { role: setting.role }),
1097
+ schema,
1027
1098
  };
1028
1099
  });
1029
1100
  }
@@ -1196,6 +1267,7 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1196
1267
  "permissions",
1197
1268
  "compatibility",
1198
1269
  "dependencies",
1270
+ "defaultEnablement",
1199
1271
  "contributions",
1200
1272
  ...(isV3OrLater(value) ? ["configuration"] : []),
1201
1273
  ],