@frockbot/kernel-composition 0.0.0 → 0.1.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.
@@ -0,0 +1,1233 @@
1
+ import { decodeTurnTypeV1, type TurnTypeV1 } from "@frockbot/kernel-contracts";
2
+
3
+ /** The Contribution kinds a Package manifest can declare. */
4
+ export type ManifestContributionKind =
5
+ "backend" | "runtime" | "client" | "desktop" | "mobile";
6
+
7
+ /**
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.
12
+ */
13
+ export type ContributionKind = ManifestContributionKind | "bot-isolate";
14
+
15
+ export interface BackendContribution {
16
+ entry: string;
17
+ host: "gateway" | "bot" | "user";
18
+ }
19
+
20
+ export interface RuntimeContribution {
21
+ entry: string;
22
+ }
23
+
24
+ export interface ClientMount {
25
+ slot: string;
26
+ order?: number;
27
+ }
28
+
29
+ export interface ClientContribution {
30
+ entry: string;
31
+ mounts: ClientMount[];
32
+ outlets: string[];
33
+ }
34
+
35
+ export interface DesktopContribution {
36
+ entry: string;
37
+ execution: "sandboxed-renderer" | "trusted-main";
38
+ commands: string[];
39
+ }
40
+
41
+ export interface MobileContribution {
42
+ entry: string;
43
+ }
44
+
45
+ /**
46
+ * Where one setting's value lives. `connection` is manifest v4: a setting a
47
+ * Connection Type declares, whose value belongs to one Connection.
48
+ */
49
+ export type SettingScope = "user" | "bot" | "connection";
50
+
51
+ export type PackageSettingSchemaType =
52
+ "object" | "array" | "string" | "number" | "integer" | "boolean" | "null";
53
+
54
+ export type PackageSettingSchemaValue = string | number | boolean | null;
55
+
56
+ export interface PackageSettingSchema {
57
+ type?: PackageSettingSchemaType;
58
+ title?: string;
59
+ description?: string;
60
+ enum?: PackageSettingSchemaValue[];
61
+ const?: PackageSettingSchemaValue;
62
+ properties?: Record<string, PackageSettingSchema>;
63
+ required?: string[];
64
+ additionalProperties?: boolean;
65
+ items?: PackageSettingSchema;
66
+ minLength?: number;
67
+ maxLength?: number;
68
+ minimum?: number;
69
+ maximum?: number;
70
+ exclusiveMinimum?: number;
71
+ exclusiveMaximum?: number;
72
+ multipleOf?: number;
73
+ minItems?: number;
74
+ maxItems?: number;
75
+ uniqueItems?: boolean;
76
+ minProperties?: number;
77
+ maxProperties?: number;
78
+ }
79
+
80
+ export interface PackageSettingDefinition {
81
+ id: string;
82
+ schemaVersion: number;
83
+ scopes: SettingScope[];
84
+ schema: PackageSettingSchema;
85
+ }
86
+
87
+ export interface ConnectionTypeDefinition {
88
+ id: string;
89
+ displayName: string;
90
+ allowMultiple: boolean;
91
+ authorization: {
92
+ kind: "none" | "api-key" | "ambient-native" | "grant";
93
+ /** Ambient native bindings have no credential or authorization driver. */
94
+ driverId?: string;
95
+ };
96
+ capabilities: string[];
97
+ /**
98
+ * Manifest v4. Connection-scoped settings: the configuration one Connection
99
+ * of this type carries beside its credential — an MCP server's URL and
100
+ * transport, say. They are declared here rather than under `settings`
101
+ * because their scope is a Connection, not the User or the Bot, and they are
102
+ * configuration only: a secret reaches the keyring through the Connection's
103
+ * credential and never through a setting.
104
+ */
105
+ settings?: PackageSettingDefinition[];
106
+ }
107
+
108
+ export interface CapabilityDefinition {
109
+ id: string;
110
+ kind: "tool" | "model" | "memory" | "notification" | "computer";
111
+ connectionTypes: string[];
112
+ /**
113
+ * Manifest v4. The durable ceiling on the turn types this Capability's
114
+ * tools may be admitted onto: a Contribution cannot offer a tool on a turn
115
+ * type its manifest does not list. Absent means the manifest set no bound.
116
+ */
117
+ admission?: { turnTypes: TurnTypeV1[]; subagentRoles?: string[] };
118
+ }
119
+
120
+ export interface PackageConfiguration {
121
+ settings: PackageSettingDefinition[];
122
+ connectionTypes: ConnectionTypeDefinition[];
123
+ capabilities: CapabilityDefinition[];
124
+ }
125
+
126
+ export interface FrockBotManifest {
127
+ schemaVersion: 2 | 3 | 4;
128
+ id: string;
129
+ displayName: string;
130
+ version: string;
131
+ compatibility: { frockbot: string };
132
+ dependencies: Record<string, string>;
133
+ contributions: {
134
+ backend?: BackendContribution[];
135
+ runtime?: RuntimeContribution;
136
+ client?: ClientContribution;
137
+ desktop?: DesktopContribution;
138
+ mobile?: MobileContribution;
139
+ };
140
+ permissions: string[];
141
+ configuration?: PackageConfiguration;
142
+ }
143
+
144
+ function isRecord(value: unknown): value is Record<string, unknown> {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value);
146
+ }
147
+
148
+ function exactFields(
149
+ record: Record<string, unknown>,
150
+ allowed: readonly string[],
151
+ boundary: string,
152
+ ): void {
153
+ const allowedFields = new Set(allowed);
154
+ const unknown = Reflect.ownKeys(record).find(
155
+ (key) => typeof key !== "string" || !allowedFields.has(key),
156
+ );
157
+ if (unknown !== undefined) {
158
+ throw new Error(`${boundary} has unknown field "${String(unknown)}"`);
159
+ }
160
+ }
161
+
162
+ function requiredString(record: Record<string, unknown>, key: string): string {
163
+ const value = record[key];
164
+ if (typeof value !== "string" || !value.trim()) {
165
+ throw new Error(`manifest field "${key}" must be a non-empty string`);
166
+ }
167
+ return value;
168
+ }
169
+
170
+ function optionalStringArray(
171
+ record: Record<string, unknown>,
172
+ key: string,
173
+ ): string[] {
174
+ const value = record[key] ?? [];
175
+ if (
176
+ !Array.isArray(value) ||
177
+ !value.every((item) => typeof item === "string" && item.length > 0)
178
+ ) {
179
+ throw new Error(`manifest field "${key}" must contain non-empty strings`);
180
+ }
181
+ return [...value];
182
+ }
183
+
184
+ function relativeEntry(record: Record<string, unknown>, key: string): string {
185
+ const value = requiredString(record, key);
186
+ if (!value.startsWith("./")) {
187
+ throw new Error(
188
+ `manifest contribution "${key}" must be a relative export path`,
189
+ );
190
+ }
191
+ return value;
192
+ }
193
+
194
+ function optionalLegacyEntry(
195
+ record: Record<string, unknown>,
196
+ key: string,
197
+ ): string | undefined {
198
+ const value = record[key];
199
+ if (value === undefined) return undefined;
200
+ if (typeof value !== "string" || !value.startsWith("./")) {
201
+ throw new Error(
202
+ `manifest contribution "${key}" must be a relative export path`,
203
+ );
204
+ }
205
+ return value;
206
+ }
207
+
208
+ function decodeDependencies(value: unknown): Record<string, string> {
209
+ if (value === undefined) return {};
210
+ if (!isRecord(value))
211
+ throw new Error("manifest dependencies must be an object");
212
+ const dependencies: Record<string, string> = {};
213
+ for (const [id, range] of Object.entries(value).sort(([a], [b]) =>
214
+ a.localeCompare(b),
215
+ )) {
216
+ if (!/^[a-z][a-z0-9-]*$/.test(id) || typeof range !== "string" || !range) {
217
+ throw new Error("manifest dependencies must map package ids to versions");
218
+ }
219
+ dependencies[id] = range;
220
+ }
221
+ return dependencies;
222
+ }
223
+
224
+ function decodeIdentity(
225
+ value: Record<string, unknown>,
226
+ ): Pick<FrockBotManifest, "id" | "displayName" | "version" | "permissions"> {
227
+ const id = requiredString(value, "id");
228
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
229
+ throw new Error("manifest id must be lowercase kebab-case");
230
+ }
231
+ return {
232
+ id,
233
+ displayName: requiredString(value, "displayName"),
234
+ version: requiredString(value, "version"),
235
+ permissions: optionalStringArray(value, "permissions"),
236
+ };
237
+ }
238
+
239
+ function decodeV1(value: Record<string, unknown>): FrockBotManifest {
240
+ const identity = decodeIdentity(value);
241
+ if (!isRecord(value.contributions)) {
242
+ throw new Error("manifest contributions must be an object");
243
+ }
244
+ exactFields(
245
+ value.contributions,
246
+ ["agent", "web", "desktop", "mobile"],
247
+ "manifest contributions",
248
+ );
249
+ const agent = optionalLegacyEntry(value.contributions, "agent");
250
+ if (value.contributions.desktop !== undefined) {
251
+ throw new Error("manifest v1 desktop Contributions are unsupported");
252
+ }
253
+ const mobile = optionalLegacyEntry(value.contributions, "mobile");
254
+ let client: ClientContribution | undefined;
255
+ if (value.contributions.web !== undefined) {
256
+ const web = value.contributions.web;
257
+ if (!isRecord(web))
258
+ throw new Error("manifest web contribution must be an object");
259
+ exactFields(
260
+ web,
261
+ ["entry", "manifest", "slots"],
262
+ "manifest legacy web contribution",
263
+ );
264
+ optionalLegacyEntry(web, "manifest");
265
+ const slots = optionalStringArray(web, "slots");
266
+ client = {
267
+ entry: relativeEntry(web, "entry"),
268
+ mounts: slots.map((slot) => ({ slot })),
269
+ outlets: [],
270
+ };
271
+ }
272
+ const contributions: FrockBotManifest["contributions"] = {
273
+ runtime: agent ? { entry: agent } : undefined,
274
+ client,
275
+ mobile: mobile ? { entry: mobile } : undefined,
276
+ };
277
+ if (
278
+ !contributions.backend &&
279
+ !contributions.runtime &&
280
+ !contributions.client &&
281
+ !contributions.desktop &&
282
+ !contributions.mobile
283
+ ) {
284
+ throw new Error("manifest has no contributions");
285
+ }
286
+ return {
287
+ schemaVersion: 2,
288
+ ...identity,
289
+ compatibility: { frockbot: "*" },
290
+ dependencies: {},
291
+ contributions,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Manifest v4 extends v3, so every v3 rule applies unchanged to a v4 body.
297
+ */
298
+ function isV3OrLater(value: Record<string, unknown>): boolean {
299
+ return value.schemaVersion === 3 || value.schemaVersion === 4;
300
+ }
301
+
302
+ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
303
+ const identity = decodeIdentity(value);
304
+ if (!isRecord(value.compatibility)) {
305
+ throw new Error("manifest compatibility must be an object");
306
+ }
307
+ exactFields(value.compatibility, ["frockbot"], "manifest compatibility");
308
+ if (!isRecord(value.contributions)) {
309
+ throw new Error("manifest contributions must be an object");
310
+ }
311
+ exactFields(
312
+ value.contributions,
313
+ isV3OrLater(value)
314
+ ? ["backend", "runtime", "client", "desktop", "mobile"]
315
+ : ["runtime", "client", "desktop", "mobile"],
316
+ "manifest contributions",
317
+ );
318
+ const contributions: FrockBotManifest["contributions"] = {};
319
+ if (isV3OrLater(value) && value.contributions.backend !== undefined) {
320
+ const backend = Array.isArray(value.contributions.backend)
321
+ ? value.contributions.backend
322
+ : [value.contributions.backend];
323
+ if (backend.length === 0 || !backend.every(isRecord)) {
324
+ throw new Error("manifest backend contributions must contain objects");
325
+ }
326
+ contributions.backend = backend.map((contribution) => {
327
+ exactFields(
328
+ contribution,
329
+ ["entry", "host"],
330
+ "manifest backend contribution",
331
+ );
332
+ if (
333
+ contribution.host !== "gateway" &&
334
+ contribution.host !== "bot" &&
335
+ contribution.host !== "user"
336
+ ) {
337
+ throw new Error("manifest backend host is invalid");
338
+ }
339
+ return {
340
+ entry: relativeEntry(contribution, "entry"),
341
+ host: contribution.host,
342
+ };
343
+ });
344
+ }
345
+ if (value.contributions.runtime !== undefined) {
346
+ if (!isRecord(value.contributions.runtime)) {
347
+ throw new Error("manifest runtime contribution must be an object");
348
+ }
349
+ exactFields(
350
+ value.contributions.runtime,
351
+ ["entry"],
352
+ "manifest runtime contribution",
353
+ );
354
+ contributions.runtime = {
355
+ entry: relativeEntry(value.contributions.runtime, "entry"),
356
+ };
357
+ }
358
+ if (value.contributions.client !== undefined) {
359
+ const client = value.contributions.client;
360
+ if (!isRecord(client))
361
+ throw new Error("manifest client contribution must be an object");
362
+ exactFields(
363
+ client,
364
+ ["entry", "mounts", "outlets"],
365
+ "manifest client contribution",
366
+ );
367
+ const mounts = client.mounts;
368
+ if (!Array.isArray(mounts)) {
369
+ throw new Error("manifest client mounts must be an array");
370
+ }
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
+ };
388
+ }
389
+ if (value.contributions.mobile !== undefined) {
390
+ const mobile = value.contributions.mobile;
391
+ if (!isRecord(mobile)) {
392
+ throw new Error("manifest mobile contribution must be an object");
393
+ }
394
+ exactFields(mobile, ["entry"], "manifest mobile contribution");
395
+ contributions.mobile = { entry: relativeEntry(mobile, "entry") };
396
+ }
397
+ if (value.contributions.desktop !== undefined) {
398
+ const desktop = value.contributions.desktop;
399
+ if (!isRecord(desktop)) {
400
+ throw new Error("manifest desktop contribution must be an object");
401
+ }
402
+ exactFields(
403
+ desktop,
404
+ ["entry", "execution", "commands"],
405
+ "manifest desktop contribution",
406
+ );
407
+ const execution = desktop.execution;
408
+ if (
409
+ execution !== "sandboxed-renderer" &&
410
+ (!isV3OrLater(value) || execution !== "trusted-main")
411
+ ) {
412
+ throw new Error(
413
+ isV3OrLater(value)
414
+ ? 'manifest desktop execution must be "sandboxed-renderer" or "trusted-main"'
415
+ : 'manifest v2 desktop execution must be "sandboxed-renderer"',
416
+ );
417
+ }
418
+ contributions.desktop = {
419
+ entry: relativeEntry(desktop, "entry"),
420
+ execution,
421
+ commands: optionalStringArray(desktop, "commands"),
422
+ };
423
+ }
424
+ if (
425
+ !contributions.backend &&
426
+ !contributions.runtime &&
427
+ !contributions.client &&
428
+ !contributions.desktop &&
429
+ !contributions.mobile
430
+ ) {
431
+ throw new Error("manifest has no contributions");
432
+ }
433
+ return {
434
+ schemaVersion: 2,
435
+ ...identity,
436
+ compatibility: {
437
+ frockbot: requiredString(value.compatibility, "frockbot"),
438
+ },
439
+ dependencies: decodeDependencies(value.dependencies),
440
+ contributions,
441
+ };
442
+ }
443
+
444
+ function definitionArray(
445
+ value: Record<string, unknown>,
446
+ key: string,
447
+ ): Record<string, unknown>[] {
448
+ const candidate = value[key] ?? [];
449
+ if (!Array.isArray(candidate) || !candidate.every(isRecord)) {
450
+ throw new Error(`manifest configuration "${key}" must be an array`);
451
+ }
452
+ return candidate;
453
+ }
454
+
455
+ function definitionId(value: Record<string, unknown>): string {
456
+ const id = requiredString(value, "id");
457
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
458
+ throw new Error("manifest configuration id must be lowercase kebab-case");
459
+ }
460
+ return id;
461
+ }
462
+
463
+ const PACKAGE_SETTING_SCHEMA_TYPES = new Set<PackageSettingSchemaType>([
464
+ "object",
465
+ "array",
466
+ "string",
467
+ "number",
468
+ "integer",
469
+ "boolean",
470
+ "null",
471
+ ]);
472
+
473
+ const PACKAGE_SETTING_SCHEMA_KEYWORDS = new Set([
474
+ "type",
475
+ "title",
476
+ "description",
477
+ "enum",
478
+ "const",
479
+ "properties",
480
+ "required",
481
+ "additionalProperties",
482
+ "items",
483
+ "minLength",
484
+ "maxLength",
485
+ "minimum",
486
+ "maximum",
487
+ "exclusiveMinimum",
488
+ "exclusiveMaximum",
489
+ "multipleOf",
490
+ "minItems",
491
+ "maxItems",
492
+ "uniqueItems",
493
+ "minProperties",
494
+ "maxProperties",
495
+ ]);
496
+
497
+ function schemaKeywordError(keyword: string, message: string): Error {
498
+ return new Error(`manifest setting schema "${keyword}" ${message}`);
499
+ }
500
+
501
+ function invalidSchemaJson(message: string): never {
502
+ throw new Error(`manifest setting schema ${message}`);
503
+ }
504
+
505
+ function validateSchemaJsonValue(value: unknown, ancestors: Set<object>): void {
506
+ if (
507
+ value === null ||
508
+ typeof value === "string" ||
509
+ typeof value === "boolean"
510
+ ) {
511
+ return;
512
+ }
513
+ if (typeof value === "number") {
514
+ if (!Number.isFinite(value)) {
515
+ invalidSchemaJson("numbers must be finite");
516
+ }
517
+ return;
518
+ }
519
+ if (typeof value !== "object") {
520
+ invalidSchemaJson("must contain only JSON values");
521
+ }
522
+ if (ancestors.has(value)) {
523
+ invalidSchemaJson("must be acyclic");
524
+ }
525
+ ancestors.add(value);
526
+
527
+ if (Array.isArray(value)) {
528
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
529
+ invalidSchemaJson("arrays must not inherit custom entries");
530
+ }
531
+ const keys = Reflect.ownKeys(value);
532
+ if (
533
+ keys.some(
534
+ (key) =>
535
+ typeof key !== "string" ||
536
+ (key !== "length" &&
537
+ (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length)),
538
+ )
539
+ ) {
540
+ invalidSchemaJson("arrays must contain only indexed entries");
541
+ }
542
+ for (let index = 0; index < value.length; index += 1) {
543
+ if (!Object.hasOwn(value, index)) {
544
+ invalidSchemaJson("arrays must be dense");
545
+ }
546
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
547
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
548
+ invalidSchemaJson("arrays must contain plain JSON entries");
549
+ }
550
+ validateSchemaJsonValue(descriptor.value, ancestors);
551
+ }
552
+ for (const key in value) {
553
+ if (!Object.hasOwn(value, key)) {
554
+ invalidSchemaJson("arrays must not inherit entries");
555
+ }
556
+ }
557
+ ancestors.delete(value);
558
+ return;
559
+ }
560
+
561
+ const prototype = Object.getPrototypeOf(value);
562
+ if (prototype !== Object.prototype && prototype !== null) {
563
+ invalidSchemaJson("objects must not inherit custom entries");
564
+ }
565
+ for (const key in value) {
566
+ if (!Object.hasOwn(value, key)) {
567
+ invalidSchemaJson("objects must not inherit entries");
568
+ }
569
+ }
570
+ for (const key of Reflect.ownKeys(value)) {
571
+ if (typeof key !== "string") {
572
+ invalidSchemaJson("objects must use string keys");
573
+ }
574
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
575
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
576
+ invalidSchemaJson("objects must contain plain JSON entries");
577
+ }
578
+ validateSchemaJsonValue(descriptor.value, ancestors);
579
+ }
580
+ ancestors.delete(value);
581
+ }
582
+
583
+ function schemaString(
584
+ value: Record<string, unknown>,
585
+ keyword: "title" | "description",
586
+ ): string | undefined {
587
+ const candidate = value[keyword];
588
+ if (candidate === undefined) return undefined;
589
+ if (typeof candidate !== "string") {
590
+ throw schemaKeywordError(keyword, "must be a string");
591
+ }
592
+ return candidate;
593
+ }
594
+
595
+ function schemaNonNegativeInteger(
596
+ value: Record<string, unknown>,
597
+ keyword:
598
+ | "minLength"
599
+ | "maxLength"
600
+ | "minItems"
601
+ | "maxItems"
602
+ | "minProperties"
603
+ | "maxProperties",
604
+ ): number | undefined {
605
+ const candidate = value[keyword];
606
+ if (candidate === undefined) return undefined;
607
+ if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
608
+ throw schemaKeywordError(keyword, "must be a non-negative integer");
609
+ }
610
+ return candidate as number;
611
+ }
612
+
613
+ function schemaFiniteNumber(
614
+ value: Record<string, unknown>,
615
+ keyword:
616
+ | "minimum"
617
+ | "maximum"
618
+ | "exclusiveMinimum"
619
+ | "exclusiveMaximum"
620
+ | "multipleOf",
621
+ ): number | undefined {
622
+ const candidate = value[keyword];
623
+ if (candidate === undefined) return undefined;
624
+ if (typeof candidate !== "number" || !Number.isFinite(candidate)) {
625
+ throw schemaKeywordError(keyword, "must be a finite number");
626
+ }
627
+ if (keyword === "multipleOf" && candidate <= 0) {
628
+ throw schemaKeywordError(keyword, "must be greater than zero");
629
+ }
630
+ return candidate;
631
+ }
632
+
633
+ function schemaValue(
634
+ value: unknown,
635
+ keyword: "enum" | "const",
636
+ ): PackageSettingSchemaValue {
637
+ if (
638
+ value !== null &&
639
+ typeof value !== "string" &&
640
+ typeof value !== "boolean" &&
641
+ (typeof value !== "number" || !Number.isFinite(value))
642
+ ) {
643
+ throw schemaKeywordError(keyword, "must contain only primitive values");
644
+ }
645
+ return value as PackageSettingSchemaValue;
646
+ }
647
+
648
+ function schemaValueMatchesType(
649
+ value: PackageSettingSchemaValue,
650
+ type: PackageSettingSchemaType,
651
+ ): boolean {
652
+ if (type === "null") return value === null;
653
+ if (type === "integer") return Number.isSafeInteger(value);
654
+ if (type === "number") return typeof value === "number";
655
+ if (type === "object" || type === "array") return false;
656
+ return typeof value === type;
657
+ }
658
+
659
+ function decodeSafeSchema(value: unknown, depth: number): PackageSettingSchema {
660
+ if (!isRecord(value)) {
661
+ throw new Error("manifest setting schema must be an object");
662
+ }
663
+ if (depth > 12) {
664
+ throw new Error("manifest setting schema is too deeply nested");
665
+ }
666
+ const source = Object.fromEntries(Object.entries(value));
667
+ for (const keyword of Object.keys(source)) {
668
+ if (keyword === "default") {
669
+ throw schemaKeywordError(keyword, "is not supported");
670
+ }
671
+ if (keyword === "format") {
672
+ throw schemaKeywordError(keyword, "is not supported");
673
+ }
674
+ if (keyword.startsWith("$")) {
675
+ throw schemaKeywordError(keyword, "is not supported");
676
+ }
677
+ if (!PACKAGE_SETTING_SCHEMA_KEYWORDS.has(keyword)) {
678
+ throw schemaKeywordError(keyword, "is not supported");
679
+ }
680
+ }
681
+
682
+ const schema: PackageSettingSchema = {};
683
+ const rawType = source.type;
684
+ if (rawType !== undefined) {
685
+ if (
686
+ typeof rawType !== "string" ||
687
+ !PACKAGE_SETTING_SCHEMA_TYPES.has(rawType as PackageSettingSchemaType)
688
+ ) {
689
+ throw schemaKeywordError("type", "is unsupported");
690
+ }
691
+ schema.type = rawType as PackageSettingSchemaType;
692
+ }
693
+ const title = schemaString(source, "title");
694
+ if (title !== undefined) schema.title = title;
695
+ const description = schemaString(source, "description");
696
+ if (description !== undefined) schema.description = description;
697
+
698
+ if (source.enum !== undefined) {
699
+ if (!Array.isArray(source.enum) || source.enum.length === 0) {
700
+ throw schemaKeywordError("enum", "must be a non-empty array");
701
+ }
702
+ schema.enum = source.enum.map((candidate) =>
703
+ schemaValue(candidate, "enum"),
704
+ );
705
+ if (
706
+ new Set(schema.enum.map((candidate) => JSON.stringify(candidate)))
707
+ .size !== schema.enum.length
708
+ ) {
709
+ throw schemaKeywordError("enum", "must contain unique values");
710
+ }
711
+ }
712
+ if (Object.hasOwn(source, "const")) {
713
+ schema.const = schemaValue(source.const, "const");
714
+ }
715
+ if (
716
+ schema.type &&
717
+ schema.enum?.some((item) => !schemaValueMatchesType(item, schema.type!))
718
+ ) {
719
+ throw schemaKeywordError("enum", "values must match type");
720
+ }
721
+ if (
722
+ schema.type &&
723
+ Object.hasOwn(schema, "const") &&
724
+ !schemaValueMatchesType(schema.const!, schema.type)
725
+ ) {
726
+ throw schemaKeywordError("const", "must match type");
727
+ }
728
+
729
+ if (source.properties !== undefined) {
730
+ if (!isRecord(source.properties)) {
731
+ throw schemaKeywordError("properties", "must be an object");
732
+ }
733
+ schema.properties = Object.fromEntries(
734
+ Object.entries(source.properties).map(([name, nested]) => {
735
+ if (!name) {
736
+ throw schemaKeywordError(
737
+ "properties",
738
+ "must use non-empty property names",
739
+ );
740
+ }
741
+ return [name, decodeSafeSchema(nested, depth + 1)];
742
+ }),
743
+ );
744
+ }
745
+ if (source.required !== undefined) {
746
+ if (
747
+ !Array.isArray(source.required) ||
748
+ !source.required.every(
749
+ (item) => typeof item === "string" && item.length > 0,
750
+ )
751
+ ) {
752
+ throw schemaKeywordError(
753
+ "required",
754
+ "must contain non-empty property names",
755
+ );
756
+ }
757
+ schema.required = [...source.required];
758
+ if (new Set(schema.required).size !== schema.required.length) {
759
+ throw schemaKeywordError("required", "must contain unique names");
760
+ }
761
+ if (
762
+ !schema.properties ||
763
+ schema.required.some((name) => !Object.hasOwn(schema.properties!, name))
764
+ ) {
765
+ throw schemaKeywordError("required", "must name declared properties");
766
+ }
767
+ }
768
+ if (source.additionalProperties !== undefined) {
769
+ if (typeof source.additionalProperties !== "boolean") {
770
+ throw schemaKeywordError("additionalProperties", "must be a boolean");
771
+ }
772
+ schema.additionalProperties = source.additionalProperties;
773
+ }
774
+ if (source.items !== undefined) {
775
+ schema.items = decodeSafeSchema(source.items, depth + 1);
776
+ }
777
+
778
+ for (const keyword of [
779
+ "minLength",
780
+ "maxLength",
781
+ "minItems",
782
+ "maxItems",
783
+ "minProperties",
784
+ "maxProperties",
785
+ ] as const) {
786
+ const candidate = schemaNonNegativeInteger(source, keyword);
787
+ if (candidate !== undefined) schema[keyword] = candidate;
788
+ }
789
+ for (const keyword of [
790
+ "minimum",
791
+ "maximum",
792
+ "exclusiveMinimum",
793
+ "exclusiveMaximum",
794
+ "multipleOf",
795
+ ] as const) {
796
+ const candidate = schemaFiniteNumber(source, keyword);
797
+ if (candidate !== undefined) schema[keyword] = candidate;
798
+ }
799
+ if (source.uniqueItems !== undefined) {
800
+ if (typeof source.uniqueItems !== "boolean") {
801
+ throw schemaKeywordError("uniqueItems", "must be a boolean");
802
+ }
803
+ schema.uniqueItems = source.uniqueItems;
804
+ }
805
+
806
+ const objectKeywords = [
807
+ schema.properties,
808
+ schema.required,
809
+ schema.additionalProperties,
810
+ schema.minProperties,
811
+ schema.maxProperties,
812
+ ];
813
+ const arrayKeywords = [
814
+ schema.items,
815
+ schema.minItems,
816
+ schema.maxItems,
817
+ schema.uniqueItems,
818
+ ];
819
+ const stringKeywords = [schema.minLength, schema.maxLength];
820
+ const numberKeywords = [
821
+ schema.minimum,
822
+ schema.maximum,
823
+ schema.exclusiveMinimum,
824
+ schema.exclusiveMaximum,
825
+ schema.multipleOf,
826
+ ];
827
+ if (
828
+ objectKeywords.some((item) => item !== undefined) &&
829
+ schema.type !== "object"
830
+ ) {
831
+ throw new Error(
832
+ "manifest setting schema object keywords require object type",
833
+ );
834
+ }
835
+ if (
836
+ arrayKeywords.some((item) => item !== undefined) &&
837
+ schema.type !== "array"
838
+ ) {
839
+ throw new Error(
840
+ "manifest setting schema array keywords require array type",
841
+ );
842
+ }
843
+ if (
844
+ stringKeywords.some((item) => item !== undefined) &&
845
+ schema.type !== "string"
846
+ ) {
847
+ throw new Error(
848
+ "manifest setting schema string keywords require string type",
849
+ );
850
+ }
851
+ if (
852
+ numberKeywords.some((item) => item !== undefined) &&
853
+ schema.type !== "number" &&
854
+ schema.type !== "integer"
855
+ ) {
856
+ throw new Error(
857
+ "manifest setting schema number keywords require numeric type",
858
+ );
859
+ }
860
+ if (
861
+ schema.minLength !== undefined &&
862
+ schema.maxLength !== undefined &&
863
+ schema.minLength > schema.maxLength
864
+ ) {
865
+ throw new Error("manifest setting schema minLength exceeds maxLength");
866
+ }
867
+ if (
868
+ schema.minItems !== undefined &&
869
+ schema.maxItems !== undefined &&
870
+ schema.minItems > schema.maxItems
871
+ ) {
872
+ throw new Error("manifest setting schema minItems exceeds maxItems");
873
+ }
874
+ if (
875
+ schema.minProperties !== undefined &&
876
+ schema.maxProperties !== undefined &&
877
+ schema.minProperties > schema.maxProperties
878
+ ) {
879
+ throw new Error(
880
+ "manifest setting schema minProperties exceeds maxProperties",
881
+ );
882
+ }
883
+ if (
884
+ schema.minimum !== undefined &&
885
+ schema.maximum !== undefined &&
886
+ schema.minimum > schema.maximum
887
+ ) {
888
+ throw new Error("manifest setting schema minimum exceeds maximum");
889
+ }
890
+ return schema;
891
+ }
892
+
893
+ function safeSchema(value: unknown): PackageSettingSchema {
894
+ if (!isRecord(value)) {
895
+ throw new Error("manifest setting schema must be an object");
896
+ }
897
+ validateSchemaJsonValue(value, new Set());
898
+ const serialized = JSON.stringify(value);
899
+ if (serialized === undefined) {
900
+ invalidSchemaJson("must contain only JSON values");
901
+ }
902
+ if (serialized.length > 50_000) {
903
+ throw new Error("manifest setting schema is too large");
904
+ }
905
+ return decodeSafeSchema(value, 0);
906
+ }
907
+
908
+ function decodeCapabilityAdmission(value: unknown): {
909
+ turnTypes: TurnTypeV1[];
910
+ subagentRoles?: string[];
911
+ } {
912
+ if (!isRecord(value)) {
913
+ throw new Error("manifest capability admission must be an object");
914
+ }
915
+ exactFields(
916
+ value,
917
+ ["turnTypes", "subagentRoles"],
918
+ "manifest capability admission",
919
+ );
920
+ if (!Array.isArray(value.turnTypes)) {
921
+ throw new Error("manifest capability admission turnTypes must be an array");
922
+ }
923
+ if (value.turnTypes.length === 0) {
924
+ throw new Error(
925
+ "manifest capability admission turnTypes must not be empty",
926
+ );
927
+ }
928
+ const turnTypes = value.turnTypes.map((turnType) =>
929
+ decodeTurnTypeV1(turnType),
930
+ );
931
+ if (new Set(turnTypes).size !== turnTypes.length) {
932
+ throw new Error("manifest capability admission turnTypes has duplicates");
933
+ }
934
+ if (value.subagentRoles === undefined) return { turnTypes };
935
+ // The second ceiling dimension. The role names are opaque to the kernel —
936
+ // bounded strings a Package chose — exactly as the kernel treats them at the
937
+ // tool registry: what any of them *means* is the Subagents Package's policy.
938
+ if (
939
+ !Array.isArray(value.subagentRoles) ||
940
+ value.subagentRoles.length === 0 ||
941
+ value.subagentRoles.length > MANIFEST_SUBAGENT_ROLE_LIMIT
942
+ ) {
943
+ throw new Error(
944
+ "manifest capability admission subagentRoles must be a bounded array",
945
+ );
946
+ }
947
+ const subagentRoles = value.subagentRoles.map((role) => {
948
+ if (
949
+ typeof role !== "string" ||
950
+ role.trim().length === 0 ||
951
+ role.length > MANIFEST_SUBAGENT_ROLE_MAX
952
+ ) {
953
+ throw new Error(
954
+ "manifest capability admission subagentRoles entry is invalid",
955
+ );
956
+ }
957
+ return role;
958
+ });
959
+ if (new Set(subagentRoles).size !== subagentRoles.length) {
960
+ throw new Error(
961
+ "manifest capability admission subagentRoles has duplicates",
962
+ );
963
+ }
964
+ return { turnTypes, subagentRoles };
965
+ }
966
+
967
+ /** How many roles a manifest may name, and how long a role name may be. */
968
+ const MANIFEST_SUBAGENT_ROLE_LIMIT = 16;
969
+ const MANIFEST_SUBAGENT_ROLE_MAX = 64;
970
+
971
+ /**
972
+ * The setting definitions on one manifest record. A Package's own `settings`
973
+ * are scoped to a User or a Bot; a Connection Type's are scoped to one
974
+ * Connection and carry no `scopes` field at all, because their scope is the
975
+ * record that declares them.
976
+ */
977
+ function settingDefinitions(
978
+ owner: Record<string, unknown>,
979
+ scope: "package" | "connection",
980
+ ): PackageSettingDefinition[] {
981
+ return definitionArray(owner, "settings").map((setting) => {
982
+ // `scopes` is optional on a Connection Type's settings and fixed when it
983
+ // is present: the decoded form carries `["connection"]`, so a manifest
984
+ // that round-trips through this decoder decodes again unchanged.
985
+ exactFields(
986
+ setting,
987
+ ["id", "schemaVersion", "schema", "scopes"],
988
+ "manifest setting definition",
989
+ );
990
+ const schemaVersion = setting.schemaVersion;
991
+ if (!Number.isSafeInteger(schemaVersion) || (schemaVersion as number) < 1) {
992
+ throw new Error(
993
+ "manifest setting schemaVersion must be a positive integer",
994
+ );
995
+ }
996
+ if (scope === "connection") {
997
+ const declared = setting.scopes;
998
+ if (
999
+ declared !== undefined &&
1000
+ (!Array.isArray(declared) ||
1001
+ declared.length !== 1 ||
1002
+ declared[0] !== "connection")
1003
+ ) {
1004
+ throw new Error(
1005
+ 'manifest Connection Type setting scopes must be ["connection"]',
1006
+ );
1007
+ }
1008
+ return {
1009
+ id: definitionId(setting),
1010
+ schemaVersion: schemaVersion as number,
1011
+ scopes: ["connection"],
1012
+ schema: safeSchema(setting.schema),
1013
+ };
1014
+ }
1015
+ const scopes = optionalStringArray(setting, "scopes");
1016
+ if (
1017
+ scopes.length === 0 ||
1018
+ !scopes.every((candidate) => candidate === "user" || candidate === "bot")
1019
+ ) {
1020
+ throw new Error("manifest setting scopes must contain user or bot");
1021
+ }
1022
+ return {
1023
+ id: definitionId(setting),
1024
+ schemaVersion: schemaVersion as number,
1025
+ scopes: scopes as SettingScope[],
1026
+ schema: safeSchema(setting.schema),
1027
+ };
1028
+ });
1029
+ }
1030
+
1031
+ function decodeConfiguration(
1032
+ value: unknown,
1033
+ allowV4: boolean,
1034
+ ): PackageConfiguration {
1035
+ if (value === undefined) {
1036
+ return { settings: [], connectionTypes: [], capabilities: [] };
1037
+ }
1038
+ if (!isRecord(value))
1039
+ throw new Error("manifest configuration must be an object");
1040
+ exactFields(
1041
+ value,
1042
+ ["settings", "connectionTypes", "capabilities"],
1043
+ "manifest configuration",
1044
+ );
1045
+ const settings = settingDefinitions(value, "package");
1046
+ const connectionTypes = definitionArray(value, "connectionTypes").map(
1047
+ (connection) => {
1048
+ exactFields(
1049
+ connection,
1050
+ [
1051
+ "id",
1052
+ "displayName",
1053
+ "allowMultiple",
1054
+ "authorization",
1055
+ "capabilities",
1056
+ ...(allowV4 ? ["settings"] : []),
1057
+ ],
1058
+ "manifest connection definition",
1059
+ );
1060
+ if (!isRecord(connection.authorization)) {
1061
+ throw new Error("manifest connection authorization must be an object");
1062
+ }
1063
+ exactFields(
1064
+ connection.authorization,
1065
+ [
1066
+ "kind",
1067
+ ...(Object.hasOwn(connection.authorization, "driverId")
1068
+ ? ["driverId"]
1069
+ : []),
1070
+ ],
1071
+ "manifest connection authorization",
1072
+ );
1073
+ const rawKind = requiredString(connection.authorization, "kind");
1074
+ if (
1075
+ rawKind !== "none" &&
1076
+ rawKind !== "api-key" &&
1077
+ rawKind !== "ambient-native" &&
1078
+ rawKind !== "grant"
1079
+ ) {
1080
+ throw new Error(
1081
+ "manifest connection authorization kind is unsupported",
1082
+ );
1083
+ }
1084
+ const kind: ConnectionTypeDefinition["authorization"]["kind"] = rawKind;
1085
+ const driverId =
1086
+ connection.authorization.driverId === undefined
1087
+ ? undefined
1088
+ : requiredString(connection.authorization, "driverId");
1089
+ if (kind !== "ambient-native" && driverId === undefined) {
1090
+ throw new Error(
1091
+ "manifest connection authorization driverId is required",
1092
+ );
1093
+ }
1094
+ if (kind === "ambient-native" && driverId !== undefined) {
1095
+ throw new Error(
1096
+ "manifest ambient-native authorization must not name a driver",
1097
+ );
1098
+ }
1099
+ if (typeof connection.allowMultiple !== "boolean") {
1100
+ throw new Error("manifest connection allowMultiple must be boolean");
1101
+ }
1102
+ return {
1103
+ id: definitionId(connection),
1104
+ displayName: requiredString(connection, "displayName"),
1105
+ allowMultiple: connection.allowMultiple,
1106
+ authorization: {
1107
+ kind,
1108
+ ...(driverId ? { driverId } : {}),
1109
+ },
1110
+ capabilities: optionalStringArray(connection, "capabilities"),
1111
+ ...(allowV4 && connection.settings !== undefined
1112
+ ? { settings: settingDefinitions(connection, "connection") }
1113
+ : {}),
1114
+ };
1115
+ },
1116
+ );
1117
+ const capabilities = definitionArray(value, "capabilities").map(
1118
+ (capability) => {
1119
+ exactFields(
1120
+ capability,
1121
+ ["id", "kind", "connectionTypes", ...(allowV4 ? ["admission"] : [])],
1122
+ "manifest capability definition",
1123
+ );
1124
+ const rawKind = requiredString(capability, "kind");
1125
+ if (
1126
+ rawKind !== "tool" &&
1127
+ rawKind !== "model" &&
1128
+ rawKind !== "memory" &&
1129
+ rawKind !== "notification" &&
1130
+ rawKind !== "computer"
1131
+ ) {
1132
+ throw new Error("manifest capability kind is unsupported");
1133
+ }
1134
+ const kind: CapabilityDefinition["kind"] = rawKind;
1135
+ return {
1136
+ id: definitionId(capability),
1137
+ kind,
1138
+ connectionTypes: optionalStringArray(capability, "connectionTypes"),
1139
+ ...(allowV4 && capability.admission !== undefined
1140
+ ? { admission: decodeCapabilityAdmission(capability.admission) }
1141
+ : {}),
1142
+ };
1143
+ },
1144
+ );
1145
+ return { settings, connectionTypes, capabilities };
1146
+ }
1147
+
1148
+ function decodeV3(value: Record<string, unknown>): FrockBotManifest {
1149
+ const base = decodeV2(value);
1150
+ return {
1151
+ ...base,
1152
+ schemaVersion: 3,
1153
+ configuration: decodeConfiguration(value.configuration, false),
1154
+ };
1155
+ }
1156
+
1157
+ /** v4 is v3 plus the Capability admission ceiling, and nothing else. */
1158
+ function decodeV4(value: Record<string, unknown>): FrockBotManifest {
1159
+ const base = decodeV2(value);
1160
+ return {
1161
+ ...base,
1162
+ schemaVersion: 4,
1163
+ configuration: decodeConfiguration(value.configuration, true),
1164
+ };
1165
+ }
1166
+
1167
+ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
1168
+ if (!isRecord(value)) throw new Error("manifest must be an object");
1169
+ if (value.schemaVersion === 1) {
1170
+ exactFields(
1171
+ value,
1172
+ [
1173
+ "schemaVersion",
1174
+ "id",
1175
+ "displayName",
1176
+ "version",
1177
+ "permissions",
1178
+ "contributions",
1179
+ ],
1180
+ "manifest",
1181
+ );
1182
+ return decodeV1(value);
1183
+ }
1184
+ if (
1185
+ value.schemaVersion === 2 ||
1186
+ value.schemaVersion === 3 ||
1187
+ value.schemaVersion === 4
1188
+ ) {
1189
+ exactFields(
1190
+ value,
1191
+ [
1192
+ "schemaVersion",
1193
+ "id",
1194
+ "displayName",
1195
+ "version",
1196
+ "permissions",
1197
+ "compatibility",
1198
+ "dependencies",
1199
+ "contributions",
1200
+ ...(isV3OrLater(value) ? ["configuration"] : []),
1201
+ ],
1202
+ "manifest",
1203
+ );
1204
+ if (value.schemaVersion === 2) return decodeV2(value);
1205
+ if (value.schemaVersion === 3) return decodeV3(value);
1206
+ return decodeV4(value);
1207
+ }
1208
+ throw new Error("unsupported FrockBot manifest version");
1209
+ }
1210
+
1211
+ export function declaredContributionKinds(
1212
+ manifest: FrockBotManifest,
1213
+ ): ManifestContributionKind[] {
1214
+ const kinds: ManifestContributionKind[] = [];
1215
+ if (manifest.contributions.backend) kinds.push("backend");
1216
+ if (manifest.contributions.runtime) kinds.push("runtime");
1217
+ if (manifest.contributions.client) kinds.push("client");
1218
+ if (manifest.contributions.desktop) kinds.push("desktop");
1219
+ if (manifest.contributions.mobile) kinds.push("mobile");
1220
+ return kinds;
1221
+ }
1222
+
1223
+ /**
1224
+ * The Package setting schema decoder, exported for seams outside a manifest
1225
+ * that carry the same shape — a Catalog entry's `setupFields`, for instance.
1226
+ * Reuse rather than a second dialect: a field a Package could not declare in
1227
+ * its manifest must not become installable through the Catalog.
1228
+ */
1229
+ export function decodePackageSettingSchemaV1(
1230
+ value: unknown,
1231
+ ): PackageSettingSchema {
1232
+ return safeSchema(value);
1233
+ }