@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,288 @@
1
+ import { decodeFrockBotManifest, type FrockBotManifest } from "./manifest.ts";
2
+ import { satisfies, valid } from "semver";
3
+
4
+ export type JsonValue =
5
+ null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
6
+
7
+ export interface ApplicationPackageSelection {
8
+ specifier: string;
9
+ version: string;
10
+ config?: JsonValue;
11
+ grants: string[];
12
+ }
13
+
14
+ export interface ApplicationSource {
15
+ schemaVersion: 1;
16
+ packages: ApplicationPackageSelection[];
17
+ }
18
+
19
+ export interface ResolvedPackageSource {
20
+ specifier: string;
21
+ manifest: unknown;
22
+ }
23
+
24
+ export type ApplicationPackageResolver = (
25
+ specifier: string,
26
+ version: string,
27
+ ) => Promise<ResolvedPackageSource>;
28
+
29
+ export type ApplicationPackageDeclarationResolver = (
30
+ specifier: string,
31
+ version: string,
32
+ ) => ResolvedPackageSource;
33
+
34
+ export interface CompiledPackage {
35
+ id: string;
36
+ specifier: string;
37
+ version: string;
38
+ config: JsonValue;
39
+ grants: string[];
40
+ manifest: FrockBotManifest;
41
+ }
42
+
43
+ export interface ApplicationPlan {
44
+ schemaVersion: 1;
45
+ applicationHash: string;
46
+ packages: CompiledPackage[];
47
+ contributions: {
48
+ backend: string[];
49
+ runtime: string[];
50
+ client: string[];
51
+ desktop: string[];
52
+ mobile: string[];
53
+ };
54
+ }
55
+
56
+ export type ApplicationDeclarationPlan = Omit<
57
+ ApplicationPlan,
58
+ "applicationHash"
59
+ >;
60
+
61
+ export interface CompileApplicationOptions {
62
+ frockbotVersion: string;
63
+ }
64
+
65
+ export function canonicalJson(value: unknown): string {
66
+ if (
67
+ value === null ||
68
+ typeof value === "string" ||
69
+ typeof value === "boolean"
70
+ ) {
71
+ return JSON.stringify(value);
72
+ }
73
+ if (typeof value === "number") {
74
+ if (!Number.isFinite(value))
75
+ throw new Error("application data must be finite");
76
+ return JSON.stringify(value);
77
+ }
78
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
79
+ if (typeof value !== "object") {
80
+ throw new Error("application data must be JSON serializable");
81
+ }
82
+ return `{${Object.entries(value)
83
+ .filter(([, entry]) => entry !== undefined)
84
+ .sort(([left], [right]) => left.localeCompare(right))
85
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
86
+ .join(",")}}`;
87
+ }
88
+
89
+ export async function sha256(value: string): Promise<string> {
90
+ const digest = await crypto.subtle.digest(
91
+ "SHA-256",
92
+ new TextEncoder().encode(value),
93
+ );
94
+ return [...new Uint8Array(digest)]
95
+ .map((byte) => byte.toString(16).padStart(2, "0"))
96
+ .join("");
97
+ }
98
+
99
+ function assertVersion(version: string, label: string): void {
100
+ if (!valid(version))
101
+ throw new Error(`${label} must be a valid semver version`);
102
+ }
103
+
104
+ function validateGrants(pkg: CompiledPackage): void {
105
+ const grants = new Set(pkg.grants);
106
+ if (grants.size !== pkg.grants.length) {
107
+ throw new Error(`package "${pkg.id}" contains duplicate grants`);
108
+ }
109
+ for (const permission of pkg.manifest.permissions) {
110
+ if (!grants.has(permission)) {
111
+ throw new Error(`package "${pkg.id}" is missing grant "${permission}"`);
112
+ }
113
+ }
114
+ for (const grant of grants) {
115
+ if (!pkg.manifest.permissions.includes(grant)) {
116
+ throw new Error(
117
+ `package "${pkg.id}" received undeclared grant "${grant}"`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ function orderedPackages(
124
+ packages: Map<string, CompiledPackage>,
125
+ ): CompiledPackage[] {
126
+ const order: CompiledPackage[] = [];
127
+ const visiting = new Set<string>();
128
+ const visited = new Set<string>();
129
+
130
+ const visit = (id: string): void => {
131
+ if (visited.has(id)) return;
132
+ if (visiting.has(id))
133
+ throw new Error(`package dependency cycle includes "${id}"`);
134
+ const pkg = packages.get(id);
135
+ if (!pkg) throw new Error(`package "${id}" is missing`);
136
+ visiting.add(id);
137
+ for (const [dependencyId, range] of Object.entries(
138
+ pkg.manifest.dependencies,
139
+ ).sort(([left], [right]) => left.localeCompare(right))) {
140
+ const dependency = packages.get(dependencyId);
141
+ if (!dependency) {
142
+ throw new Error(
143
+ `package "${pkg.id}" requires missing package "${dependencyId}"`,
144
+ );
145
+ }
146
+ if (!satisfies(dependency.version, range)) {
147
+ throw new Error(
148
+ `package "${pkg.id}" requires ${dependencyId}@${range}, received ${dependency.version}`,
149
+ );
150
+ }
151
+ visit(dependencyId);
152
+ }
153
+ visiting.delete(id);
154
+ visited.add(id);
155
+ order.push(pkg);
156
+ };
157
+
158
+ for (const id of [...packages.keys()].sort()) visit(id);
159
+ return order;
160
+ }
161
+
162
+ function validateClientComposition(packages: readonly CompiledPackage[]): void {
163
+ const clients = packages.flatMap((pkg) => {
164
+ const client = pkg.manifest.contributions.client;
165
+ return client ? [{ id: pkg.id, client }] : [];
166
+ });
167
+ const roots = clients.flatMap(({ id, client }) =>
168
+ client.mounts.filter((mount) => mount.slot === "root").map(() => id),
169
+ );
170
+ if (roots.length > 1) {
171
+ throw new Error(`multiple client roots declared by: ${roots.join(", ")}`);
172
+ }
173
+ const outlets = new Set(clients.flatMap(({ client }) => client.outlets));
174
+ for (const { id, client } of clients) {
175
+ for (const mount of client.mounts) {
176
+ if (mount.slot !== "root" && !outlets.has(mount.slot)) {
177
+ throw new Error(
178
+ `package "${id}" mounts undeclared client slot "${mount.slot}"`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ export function compileApplicationDeclarations(
186
+ source: ApplicationSource,
187
+ resolvePackage: ApplicationPackageDeclarationResolver,
188
+ options: CompileApplicationOptions,
189
+ ): ApplicationDeclarationPlan {
190
+ if (source.schemaVersion !== 1) {
191
+ throw new Error("unsupported application source version");
192
+ }
193
+ assertVersion(options.frockbotVersion, "FrockBot version");
194
+ const byId = new Map<string, CompiledPackage>();
195
+ const specifiers = new Set<string>();
196
+
197
+ for (const selection of source.packages) {
198
+ if (specifiers.has(selection.specifier)) {
199
+ throw new Error(`duplicate package specifier "${selection.specifier}"`);
200
+ }
201
+ specifiers.add(selection.specifier);
202
+ assertVersion(
203
+ selection.version,
204
+ `package "${selection.specifier}" version`,
205
+ );
206
+ const resolved = resolvePackage(selection.specifier, selection.version);
207
+ if (resolved.specifier !== selection.specifier) {
208
+ throw new Error(
209
+ `resolver returned the wrong package for "${selection.specifier}"`,
210
+ );
211
+ }
212
+ const manifest = decodeFrockBotManifest(resolved.manifest);
213
+ if (manifest.version !== selection.version) {
214
+ throw new Error(
215
+ `package "${manifest.id}" manifest version does not match selection`,
216
+ );
217
+ }
218
+ if (!satisfies(options.frockbotVersion, manifest.compatibility.frockbot)) {
219
+ throw new Error(
220
+ `package "${manifest.id}" is incompatible with FrockBot ${options.frockbotVersion}`,
221
+ );
222
+ }
223
+ if (byId.has(manifest.id)) {
224
+ throw new Error(`duplicate package id "${manifest.id}"`);
225
+ }
226
+ const pkg: CompiledPackage = {
227
+ id: manifest.id,
228
+ specifier: selection.specifier,
229
+ version: selection.version,
230
+ config: selection.config ?? null,
231
+ grants: [...selection.grants].sort(),
232
+ manifest,
233
+ };
234
+ validateGrants(pkg);
235
+ byId.set(pkg.id, pkg);
236
+ }
237
+
238
+ const packages = orderedPackages(byId);
239
+ validateClientComposition(packages);
240
+ return {
241
+ schemaVersion: 1 as const,
242
+ packages,
243
+ contributions: {
244
+ backend: packages
245
+ .filter((pkg) => pkg.manifest.contributions.backend)
246
+ .map((pkg) => pkg.id),
247
+ runtime: packages
248
+ .filter((pkg) => pkg.manifest.contributions.runtime)
249
+ .map((pkg) => pkg.id),
250
+ client: packages
251
+ .filter((pkg) => pkg.manifest.contributions.client)
252
+ .map((pkg) => pkg.id),
253
+ desktop: packages
254
+ .filter((pkg) => pkg.manifest.contributions.desktop)
255
+ .map((pkg) => pkg.id),
256
+ mobile: packages
257
+ .filter((pkg) => pkg.manifest.contributions.mobile)
258
+ .map((pkg) => pkg.id),
259
+ },
260
+ };
261
+ }
262
+
263
+ export async function compileApplicationPlan(
264
+ source: ApplicationSource,
265
+ resolvePackage: ApplicationPackageResolver,
266
+ options: CompileApplicationOptions,
267
+ ): Promise<ApplicationPlan> {
268
+ const resolved = new Map<string, ResolvedPackageSource>();
269
+ for (const selection of source.packages) {
270
+ resolved.set(
271
+ `${selection.specifier}\0${selection.version}`,
272
+ await resolvePackage(selection.specifier, selection.version),
273
+ );
274
+ }
275
+ const unsigned = compileApplicationDeclarations(
276
+ source,
277
+ (specifier, version) => {
278
+ const pkg = resolved.get(`${specifier}\0${version}`);
279
+ if (!pkg) throw new Error(`unknown package: ${specifier}`);
280
+ return pkg;
281
+ },
282
+ options,
283
+ );
284
+ return {
285
+ ...unsigned,
286
+ applicationHash: await sha256(canonicalJson(unsigned)),
287
+ };
288
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ assertCompositionArtifactSetHashV1,
4
+ bootstrapGeneration,
5
+ compositionArtifactSetHashV1,
6
+ decodeCompositionGenerationV1,
7
+ type CompositionGenerationV1,
8
+ } from "./generation.ts";
9
+
10
+ const CREATED_AT = "2026-08-31T00:00:00.000Z";
11
+
12
+ function manifest(id: string) {
13
+ return { id, version: "0.0.1", displayName: id };
14
+ }
15
+
16
+ function bootstrap(): Promise<CompositionGenerationV1> {
17
+ return bootstrapGeneration(
18
+ [
19
+ {
20
+ packageId: "tools",
21
+ specifier: "@frockbot/plugin-tools",
22
+ version: "0.0.1",
23
+ manifest: manifest("tools"),
24
+ },
25
+ {
26
+ packageId: "models",
27
+ specifier: "@frockbot/plugin-models",
28
+ version: "0.0.1",
29
+ manifest: manifest("models"),
30
+ },
31
+ ],
32
+ { createdAt: CREATED_AT },
33
+ );
34
+ }
35
+
36
+ describe("Composition generation v1", () => {
37
+ test("bootstraps one first-party generation from declared Contributions", async () => {
38
+ const generation = await bootstrap();
39
+
40
+ expect(generation.origin).toEqual({ kind: "bootstrap" });
41
+ expect(generation.status).toBe("pending");
42
+ expect(generation.createdAt).toBe(CREATED_AT);
43
+ expect(generation.members.map((member) => member.packageId)).toEqual([
44
+ "models",
45
+ "tools",
46
+ ]);
47
+ expect(
48
+ generation.members.every(
49
+ (member) =>
50
+ member.provenance.kind === "first-party" &&
51
+ member.artifact === undefined,
52
+ ),
53
+ ).toBe(true);
54
+ expect(generation.generationId.startsWith(`${CREATED_AT}:`)).toBe(true);
55
+ await assertCompositionArtifactSetHashV1(generation);
56
+ });
57
+
58
+ test("keys the generation by its resolved artifact set", async () => {
59
+ const first = await bootstrap();
60
+ const second = await bootstrap();
61
+
62
+ expect(second.artifactSetHash).toBe(first.artifactSetHash);
63
+ expect(second.generationId).toBe(first.generationId);
64
+ expect(
65
+ await compositionArtifactSetHashV1([...first.members].reverse()),
66
+ ).toBe(first.artifactSetHash);
67
+ });
68
+
69
+ test("the v1 codec rejects unknown fields", async () => {
70
+ const generation = await bootstrap();
71
+
72
+ expect(() =>
73
+ decodeCompositionGenerationV1({ ...generation, extra: true }),
74
+ ).toThrow("composition generation has invalid fields");
75
+ expect(() =>
76
+ decodeCompositionGenerationV1({
77
+ ...generation,
78
+ members: [{ ...generation.members[0], extra: true }],
79
+ }),
80
+ ).toThrow("composition generation.members[0] has invalid fields");
81
+ expect(() =>
82
+ decodeCompositionGenerationV1({
83
+ ...generation,
84
+ origin: { kind: "bootstrap", userId: "user-1" },
85
+ }),
86
+ ).toThrow("composition generation.origin has invalid fields");
87
+ expect(() =>
88
+ decodeCompositionGenerationV1({
89
+ ...generation,
90
+ members: [
91
+ {
92
+ ...generation.members[0],
93
+ provenance: {
94
+ ...generation.members[0]!.provenance,
95
+ authoredAt: CREATED_AT,
96
+ },
97
+ },
98
+ ],
99
+ }),
100
+ ).toThrow(
101
+ "composition generation.members[0].provenance has invalid fields",
102
+ );
103
+ });
104
+
105
+ test("the v1 codec rejects malformed hashes and mismatched provenance", async () => {
106
+ const generation = await bootstrap();
107
+
108
+ expect(() =>
109
+ decodeCompositionGenerationV1({
110
+ ...generation,
111
+ artifactSetHash: "not-a-digest",
112
+ }),
113
+ ).toThrow("composition generation.artifactSetHash must be a sha-256 hex");
114
+ expect(() =>
115
+ decodeCompositionGenerationV1({
116
+ ...generation,
117
+ artifactSetHash: generation.artifactSetHash.toUpperCase(),
118
+ }),
119
+ ).toThrow("composition generation.artifactSetHash must be a sha-256 hex");
120
+ expect(() =>
121
+ decodeCompositionGenerationV1({
122
+ ...generation,
123
+ members: [{ ...generation.members[0], manifestHash: "abc" }],
124
+ }),
125
+ ).toThrow("composition generation.members[0].manifestHash");
126
+ expect(() =>
127
+ decodeCompositionGenerationV1({
128
+ ...generation,
129
+ members: [{ ...generation.members[0], version: "9.9.9" }],
130
+ }),
131
+ ).toThrow("does not match its member");
132
+ expect(() =>
133
+ decodeCompositionGenerationV1({ ...generation, status: "mounted" }),
134
+ ).toThrow("composition generation.status is invalid");
135
+ expect(() =>
136
+ decodeCompositionGenerationV1({ ...generation, schemaVersion: 2 }),
137
+ ).toThrow("composition generation.schemaVersion is unsupported");
138
+ });
139
+
140
+ test("rejects a generation whose recorded artifact set hash is wrong", async () => {
141
+ const generation = await bootstrap();
142
+
143
+ await expect(
144
+ assertCompositionArtifactSetHashV1({
145
+ ...generation,
146
+ artifactSetHash: "b".repeat(64),
147
+ }),
148
+ ).rejects.toThrow("mismatched artifact set hash");
149
+ });
150
+
151
+ test("decodes an isolate member with its content-addressed artifact", async () => {
152
+ const generation = await bootstrap();
153
+ const members = [
154
+ {
155
+ packageId: "authored",
156
+ specifier: "bot://authored",
157
+ version: "0.0.1",
158
+ manifestHash: "c".repeat(64),
159
+ provenance: {
160
+ kind: "bot" as const,
161
+ packageId: "authored",
162
+ version: "0.0.1",
163
+ botId: "primary",
164
+ sessionId: "user-1:primary",
165
+ turnId: "turn-1",
166
+ runId: "run-1",
167
+ authoredAt: CREATED_AT,
168
+ },
169
+ artifact: {
170
+ contentHash: "d".repeat(64),
171
+ size: 1024,
172
+ mediaType: "application/javascript" as const,
173
+ bundlerVersion: "0.2.3",
174
+ },
175
+ },
176
+ ];
177
+ const decoded = decodeCompositionGenerationV1({
178
+ ...generation,
179
+ artifactSetHash: await compositionArtifactSetHashV1(members),
180
+ members,
181
+ origin: {
182
+ kind: "bot-authored",
183
+ runId: "run-1",
184
+ sessionId: "user-1:primary",
185
+ turnId: "turn-1",
186
+ },
187
+ });
188
+
189
+ expect(decoded.members[0]?.artifact?.contentHash).toBe("d".repeat(64));
190
+ await assertCompositionArtifactSetHashV1(decoded);
191
+ expect(() =>
192
+ decodeCompositionGenerationV1({
193
+ ...decoded,
194
+ members: [
195
+ {
196
+ ...members[0],
197
+ artifact: { ...members[0]!.artifact, mediaType: "text/plain" },
198
+ },
199
+ ],
200
+ }),
201
+ ).toThrow("mediaType is invalid");
202
+ });
203
+ });