@frockbot/kernel-composition 0.1.4 → 0.2.1
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 +2 -2
- package/src/compiler.ts +8 -2
- package/src/generation.test.ts +55 -0
- package/src/generation.ts +111 -7
- package/src/index.test.ts +142 -0
- package/src/isolate-host.test.ts +341 -53
- package/src/isolate-host.ts +308 -26
- package/src/isolate-wrapper.test.ts +35 -0
- package/src/isolate-wrapper.ts +155 -32
- package/src/manifest.ts +271 -29
package/src/isolate-wrapper.ts
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The wrapper is emitted as plain JavaScript because it is a module in the
|
|
11
11
|
// loaded Worker's module map, not a source file this repository compiles.
|
|
12
|
+
import {
|
|
13
|
+
BOT_ISOLATE_HOOK_EVENTS_V1,
|
|
14
|
+
type BotPackageContextV1,
|
|
15
|
+
} from "@frockbot/kernel-contracts";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* The deadline guard, shared verbatim between the generated wrapper and the
|
|
@@ -35,6 +39,7 @@ export const BOT_ISOLATE_DEADLINE_SOURCE = `function withIsolateDeadline(work, d
|
|
|
35
39
|
* the boundary is crossed in both directions and both sides decode.
|
|
36
40
|
*/
|
|
37
41
|
export const BOT_ISOLATE_INVOCATION_SOURCE = `var TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
42
|
+
var HOOK_EVENTS = ${JSON.stringify(BOT_ISOLATE_HOOK_EVENTS_V1)};
|
|
38
43
|
var INVOCATION_KEYS = [
|
|
39
44
|
"schemaVersion",
|
|
40
45
|
"tool",
|
|
@@ -72,6 +77,42 @@ function decodeInvocation(value) {
|
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
return value;
|
|
80
|
+
}
|
|
81
|
+
var HOOK_INVOCATION_KEYS = [
|
|
82
|
+
"schemaVersion",
|
|
83
|
+
"event",
|
|
84
|
+
"payload",
|
|
85
|
+
"botId",
|
|
86
|
+
"sessionId",
|
|
87
|
+
"runId",
|
|
88
|
+
"turnId",
|
|
89
|
+
"generationId",
|
|
90
|
+
"deadlineMs",
|
|
91
|
+
];
|
|
92
|
+
function decodeHookInvocation(value) {
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
94
|
+
throw new Error("isolate hook invocation must be an object");
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
Object.keys(value).length !== HOOK_INVOCATION_KEYS.length ||
|
|
98
|
+
!HOOK_INVOCATION_KEYS.every(function (key) {
|
|
99
|
+
return Object.hasOwn(value, key);
|
|
100
|
+
})
|
|
101
|
+
) {
|
|
102
|
+
throw new Error("isolate hook invocation has invalid fields");
|
|
103
|
+
}
|
|
104
|
+
if (value.schemaVersion !== 1 || !HOOK_EVENTS.includes(value.event)) {
|
|
105
|
+
throw new Error("isolate hook invocation is unsupported");
|
|
106
|
+
}
|
|
107
|
+
if (!value.payload || typeof value.payload !== "object" || Array.isArray(value.payload)) {
|
|
108
|
+
throw new Error("isolate hook invocation payload is invalid");
|
|
109
|
+
}
|
|
110
|
+
for (const key of ["botId", "sessionId", "runId", "turnId", "generationId"]) {
|
|
111
|
+
if (typeof value[key] !== "string" || value[key].length === 0) {
|
|
112
|
+
throw new Error("isolate hook invocation " + key + " is invalid");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
75
116
|
}`;
|
|
76
117
|
|
|
77
118
|
/** Decodes one NDJSON line of the `invokeModel` byte stream inside the isolate. */
|
|
@@ -99,6 +140,86 @@ export const BOT_ISOLATE_MODEL_SOURCE = `async function* modelEvents(stream) {
|
|
|
99
140
|
}
|
|
100
141
|
}`;
|
|
101
142
|
|
|
143
|
+
const BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1 = {
|
|
144
|
+
tool: "invocation.tool",
|
|
145
|
+
event: "invocation.event",
|
|
146
|
+
botId: "invocation.botId",
|
|
147
|
+
sessionId: "invocation.sessionId",
|
|
148
|
+
runId: "invocation.runId",
|
|
149
|
+
turnId: "invocation.turnId",
|
|
150
|
+
generationId: "invocation.generationId",
|
|
151
|
+
packageId: "env.IDENTITY.packageId",
|
|
152
|
+
deadlineMs: "invocation.deadlineMs",
|
|
153
|
+
bindings: "Object.keys(env).sort()",
|
|
154
|
+
capabilities: `{
|
|
155
|
+
list: function () {
|
|
156
|
+
return capabilities.list();
|
|
157
|
+
},
|
|
158
|
+
}`,
|
|
159
|
+
model: `{
|
|
160
|
+
invoke: async function (request) {
|
|
161
|
+
const outcome = await capabilities.invokeModel(request);
|
|
162
|
+
if (!outcome || outcome.status !== "streaming") return outcome;
|
|
163
|
+
return {
|
|
164
|
+
status: "streaming",
|
|
165
|
+
requestId: outcome.requestId,
|
|
166
|
+
events: modelEvents(outcome.events),
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
}`,
|
|
170
|
+
tools: `{
|
|
171
|
+
invoke: function (request) {
|
|
172
|
+
return capabilities.invokeTool(request);
|
|
173
|
+
},
|
|
174
|
+
}`,
|
|
175
|
+
memory: `{
|
|
176
|
+
read: function (request) {
|
|
177
|
+
return capabilities.memoryRead(request);
|
|
178
|
+
},
|
|
179
|
+
write: function (request) {
|
|
180
|
+
return capabilities.memoryWrite(request);
|
|
181
|
+
},
|
|
182
|
+
forget: function (request) {
|
|
183
|
+
return capabilities.memoryForget(request);
|
|
184
|
+
},
|
|
185
|
+
}`,
|
|
186
|
+
workspace: `{
|
|
187
|
+
read: function (path) {
|
|
188
|
+
return capabilities.workspaceRead(path);
|
|
189
|
+
},
|
|
190
|
+
list: function (request) {
|
|
191
|
+
return capabilities.workspaceList(request);
|
|
192
|
+
},
|
|
193
|
+
stat: function (path) {
|
|
194
|
+
return capabilities.workspaceStat(path);
|
|
195
|
+
},
|
|
196
|
+
write: function (request) {
|
|
197
|
+
return capabilities.workspaceWrite(request);
|
|
198
|
+
},
|
|
199
|
+
delete: function (request) {
|
|
200
|
+
return capabilities.workspaceDelete(request);
|
|
201
|
+
},
|
|
202
|
+
}`,
|
|
203
|
+
connection:
|
|
204
|
+
"function (connectionId) { return capabilities.connection(connectionId); }",
|
|
205
|
+
notify: "function (request) { return capabilities.notify(request); }",
|
|
206
|
+
schedule: "function (request) { return capabilities.schedule(request); }",
|
|
207
|
+
} satisfies Record<keyof BotPackageContextV1, string>;
|
|
208
|
+
|
|
209
|
+
/** The keys the generated wrapper actually places on `ctx`. */
|
|
210
|
+
export const BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1 = Object.keys(
|
|
211
|
+
BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1,
|
|
212
|
+
) as Array<keyof BotPackageContextV1>;
|
|
213
|
+
|
|
214
|
+
export const BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1 = `function narrowContext(env, invocation) {
|
|
215
|
+
const capabilities = env.CAPABILITIES;
|
|
216
|
+
return {
|
|
217
|
+
${Object.entries(BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1)
|
|
218
|
+
.map(([key, source]) => ` ${JSON.stringify(key)}: ${source},`)
|
|
219
|
+
.join("\n")}
|
|
220
|
+
};
|
|
221
|
+
}`;
|
|
222
|
+
|
|
102
223
|
/**
|
|
103
224
|
* The wrapper module text. Content-addressed with `package.js`; bump
|
|
104
225
|
* `BOT_ISOLATE_WRAPPER_VERSION` whenever this string changes so the mounted
|
|
@@ -108,7 +229,7 @@ export const BOT_ISOLATE_WRAPPER_SOURCE = `// Generated by @frockbot/kernel-comp
|
|
|
108
229
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
109
230
|
import * as botPackage from "./package.js";
|
|
110
231
|
|
|
111
|
-
const CONTRACT_VERSION =
|
|
232
|
+
const CONTRACT_VERSION = 3;
|
|
112
233
|
|
|
113
234
|
${BOT_ISOLATE_DEADLINE_SOURCE}
|
|
114
235
|
|
|
@@ -155,38 +276,24 @@ function declaredTools() {
|
|
|
155
276
|
});
|
|
156
277
|
}
|
|
157
278
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
listCapabilities: function () {
|
|
173
|
-
return capabilities.list();
|
|
174
|
-
},
|
|
175
|
-
requestAuthority: function (request) {
|
|
176
|
-
return capabilities.requestAuthority(request);
|
|
177
|
-
},
|
|
178
|
-
invokeModel: async function (request) {
|
|
179
|
-
const outcome = await capabilities.invokeModel(request);
|
|
180
|
-
if (!outcome || outcome.status !== "streaming") return outcome;
|
|
181
|
-
return {
|
|
182
|
-
status: "streaming",
|
|
183
|
-
requestId: outcome.requestId,
|
|
184
|
-
events: modelEvents(outcome.events),
|
|
185
|
-
};
|
|
186
|
-
},
|
|
187
|
-
};
|
|
279
|
+
function declaredHooks() {
|
|
280
|
+
if (botPackage.hooks === undefined) return [];
|
|
281
|
+
if (!botPackage.hooks || typeof botPackage.hooks !== "object" || Array.isArray(botPackage.hooks)) {
|
|
282
|
+
throw new Error('package.js "hooks" must be an object');
|
|
283
|
+
}
|
|
284
|
+
return Object.keys(botPackage.hooks).map(function (event) {
|
|
285
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
286
|
+
throw new Error('package.js declared an unsupported hook "' + event + '"');
|
|
287
|
+
}
|
|
288
|
+
if (typeof botPackage.hooks[event] !== "function") {
|
|
289
|
+
throw new Error('package.js hook "' + event + '" must be a function');
|
|
290
|
+
}
|
|
291
|
+
return event;
|
|
292
|
+
});
|
|
188
293
|
}
|
|
189
294
|
|
|
295
|
+
${BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1}
|
|
296
|
+
|
|
190
297
|
export default class extends WorkerEntrypoint {
|
|
191
298
|
async health() {
|
|
192
299
|
return {
|
|
@@ -195,6 +302,7 @@ export default class extends WorkerEntrypoint {
|
|
|
195
302
|
packageId: this.env.IDENTITY.packageId,
|
|
196
303
|
contractVersion: CONTRACT_VERSION,
|
|
197
304
|
tools: declaredTools(),
|
|
305
|
+
hooks: declaredHooks(),
|
|
198
306
|
};
|
|
199
307
|
}
|
|
200
308
|
|
|
@@ -227,11 +335,26 @@ export default class extends WorkerEntrypoint {
|
|
|
227
335
|
};
|
|
228
336
|
}
|
|
229
337
|
}
|
|
338
|
+
|
|
339
|
+
async hook(rawInvocation) {
|
|
340
|
+
const invocation = decodeHookInvocation(rawInvocation);
|
|
341
|
+
const hooks = declaredHooks();
|
|
342
|
+
if (!hooks.includes(invocation.event)) {
|
|
343
|
+
throw new Error('package.js did not declare hook "' + invocation.event + '"');
|
|
344
|
+
}
|
|
345
|
+
const context = narrowContext(this.env, invocation);
|
|
346
|
+
const replacement = await withIsolateDeadline(function () {
|
|
347
|
+
return botPackage.hooks[invocation.event](invocation.payload, context);
|
|
348
|
+
}, invocation.deadlineMs);
|
|
349
|
+
return replacement === undefined
|
|
350
|
+
? { schemaVersion: 1, status: "unchanged" }
|
|
351
|
+
: { schemaVersion: 1, status: "replaced", replacement: replacement };
|
|
352
|
+
}
|
|
230
353
|
}
|
|
231
354
|
`;
|
|
232
355
|
|
|
233
356
|
/** Bumped with any change to the wrapper text; folded into the loader id. */
|
|
234
|
-
export const BOT_ISOLATE_WRAPPER_VERSION = "wrapper-
|
|
357
|
+
export const BOT_ISOLATE_WRAPPER_VERSION = "wrapper-v4";
|
|
235
358
|
|
|
236
359
|
export const BOT_ISOLATE_MAIN_MODULE = "index.js";
|
|
237
360
|
export const BOT_ISOLATE_PACKAGE_MODULE = "package.js";
|
package/src/manifest.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isBotIsolateHookEventNameV1,
|
|
3
|
+
decodeTurnTypeV1,
|
|
4
|
+
type BotIsolateHookEventNameV1,
|
|
5
|
+
type TurnTypeV1,
|
|
6
|
+
} from "@frockbot/kernel-contracts";
|
|
2
7
|
|
|
3
8
|
/** The Contribution kinds a Package manifest can declare. */
|
|
4
9
|
export type ManifestContributionKind =
|
|
5
10
|
"backend" | "runtime" | "client" | "desktop" | "mobile";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
|
-
* Every execution host a Contribution can be mounted in.
|
|
9
|
-
* manifest
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* Every execution host a Contribution can be mounted in. A Bot-authored
|
|
14
|
+
* manifest declares `bot-isolate`, but provenance and an immutable artifact
|
|
15
|
+
* still decide whether the host accepts it; a manifest never grants itself
|
|
16
|
+
* that execution authority.
|
|
12
17
|
*/
|
|
13
18
|
export type ContributionKind = ManifestContributionKind | "bot-isolate";
|
|
14
19
|
|
|
@@ -19,6 +24,15 @@ export interface BackendContribution {
|
|
|
19
24
|
|
|
20
25
|
export interface RuntimeContribution {
|
|
21
26
|
entry: string;
|
|
27
|
+
/** Present only on a Bot-authored manifest; provenance still decides host authority. */
|
|
28
|
+
host?: "bot-isolate";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A Bot-authored manifest's durable declaration; isolate health supplies details at mount. */
|
|
32
|
+
export interface ManifestToolDeclaration {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema: Record<string, unknown>;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
export interface ClientMount {
|
|
@@ -26,12 +40,37 @@ export interface ClientMount {
|
|
|
26
40
|
order?: number;
|
|
27
41
|
}
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
/** A reviewed first-party client module compiled into the hosted bundle. */
|
|
44
|
+
export interface ClientModuleContribution {
|
|
30
45
|
entry: string;
|
|
31
46
|
mounts: ClientMount[];
|
|
32
47
|
outlets: string[];
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
/** Immutable HTML bytes rendered only by the first-party sandbox host. */
|
|
51
|
+
export interface ClientIframeArtifactV1 {
|
|
52
|
+
contentHash: string;
|
|
53
|
+
size: number;
|
|
54
|
+
mediaType: "text/html";
|
|
55
|
+
bundlerVersion: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A non-first-party page. It never becomes JavaScript in the app origin. */
|
|
59
|
+
export interface ClientIframeContribution {
|
|
60
|
+
kind: "iframe";
|
|
61
|
+
artifact: ClientIframeArtifactV1;
|
|
62
|
+
mounts: ClientMount[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ClientContribution =
|
|
66
|
+
ClientModuleContribution | ClientIframeContribution;
|
|
67
|
+
|
|
68
|
+
export function isClientIframeContribution(
|
|
69
|
+
contribution: ClientContribution,
|
|
70
|
+
): contribution is ClientIframeContribution {
|
|
71
|
+
return "kind" in contribution && contribution.kind === "iframe";
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
export interface DesktopContribution {
|
|
36
75
|
entry: string;
|
|
37
76
|
execution: "sandboxed-renderer" | "trusted-main";
|
|
@@ -146,6 +185,10 @@ export interface FrockBotManifest {
|
|
|
146
185
|
};
|
|
147
186
|
permissions: string[];
|
|
148
187
|
configuration?: PackageConfiguration;
|
|
188
|
+
/** Present exactly when `contributions.runtime.host` is `bot-isolate`. */
|
|
189
|
+
tools?: ManifestToolDeclaration[];
|
|
190
|
+
/** Bot-isolate waterfalls the immutable artifact declares it exports. */
|
|
191
|
+
hooks?: BotIsolateHookEventNameV1[];
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -166,6 +209,88 @@ function exactFields(
|
|
|
166
209
|
}
|
|
167
210
|
}
|
|
168
211
|
|
|
212
|
+
function validateManifestJson(value: unknown, label: string, depth = 0): void {
|
|
213
|
+
if (depth > 16) throw new Error(`${label} is too deeply nested`);
|
|
214
|
+
if (
|
|
215
|
+
value === null ||
|
|
216
|
+
typeof value === "string" ||
|
|
217
|
+
typeof value === "boolean" ||
|
|
218
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
219
|
+
) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
for (const entry of value) validateManifestJson(entry, label, depth + 1);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!isRecord(value)) throw new Error(`${label} must contain only JSON`);
|
|
227
|
+
for (const entry of Object.values(value)) {
|
|
228
|
+
validateManifestJson(entry, label, depth + 1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function decodeManifestTools(
|
|
233
|
+
value: unknown,
|
|
234
|
+
): ManifestToolDeclaration[] | undefined {
|
|
235
|
+
if (value === undefined) return undefined;
|
|
236
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
|
|
237
|
+
throw new Error("manifest tools must be a non-empty bounded array");
|
|
238
|
+
}
|
|
239
|
+
const tools = value.map((candidate, index) => {
|
|
240
|
+
if (!isRecord(candidate)) {
|
|
241
|
+
throw new Error(`manifest tools[${index}] must be an object`);
|
|
242
|
+
}
|
|
243
|
+
exactFields(
|
|
244
|
+
candidate,
|
|
245
|
+
["name", "description", "inputSchema"],
|
|
246
|
+
`manifest tools[${index}]`,
|
|
247
|
+
);
|
|
248
|
+
const name = requiredString(candidate, "name");
|
|
249
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(name)) {
|
|
250
|
+
throw new Error(`manifest tools[${index}] name is invalid`);
|
|
251
|
+
}
|
|
252
|
+
const description = requiredString(candidate, "description");
|
|
253
|
+
if (description.length > 1_024) {
|
|
254
|
+
throw new Error(`manifest tools[${index}] description is too long`);
|
|
255
|
+
}
|
|
256
|
+
if (!isRecord(candidate.inputSchema)) {
|
|
257
|
+
throw new Error(`manifest tools[${index}] inputSchema must be an object`);
|
|
258
|
+
}
|
|
259
|
+
validateManifestJson(
|
|
260
|
+
candidate.inputSchema,
|
|
261
|
+
`manifest tools[${index}] inputSchema`,
|
|
262
|
+
);
|
|
263
|
+
return {
|
|
264
|
+
name,
|
|
265
|
+
description,
|
|
266
|
+
inputSchema: structuredClone(candidate.inputSchema),
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
|
|
270
|
+
throw new Error("manifest tools contains duplicate names");
|
|
271
|
+
}
|
|
272
|
+
return tools;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function decodeManifestHooks(
|
|
276
|
+
value: unknown,
|
|
277
|
+
): BotIsolateHookEventNameV1[] | undefined {
|
|
278
|
+
if (value === undefined) return undefined;
|
|
279
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 16) {
|
|
280
|
+
throw new Error("manifest hooks must be a non-empty bounded array");
|
|
281
|
+
}
|
|
282
|
+
const hooks = value.map((hook, index) => {
|
|
283
|
+
if (!isBotIsolateHookEventNameV1(hook)) {
|
|
284
|
+
throw new Error(`manifest hooks[${index}] is invalid`);
|
|
285
|
+
}
|
|
286
|
+
return hook;
|
|
287
|
+
});
|
|
288
|
+
if (new Set(hooks).size !== hooks.length) {
|
|
289
|
+
throw new Error("manifest hooks contains duplicates");
|
|
290
|
+
}
|
|
291
|
+
return hooks;
|
|
292
|
+
}
|
|
293
|
+
|
|
169
294
|
function requiredString(record: Record<string, unknown>, key: string): string {
|
|
170
295
|
const value = record[key];
|
|
171
296
|
if (typeof value !== "string" || !value.trim()) {
|
|
@@ -368,43 +493,102 @@ function decodeV2(value: Record<string, unknown>): FrockBotManifest {
|
|
|
368
493
|
}
|
|
369
494
|
exactFields(
|
|
370
495
|
value.contributions.runtime,
|
|
371
|
-
["entry"],
|
|
496
|
+
["entry", ...(isV3OrLater(value) ? ["host"] : [])],
|
|
372
497
|
"manifest runtime contribution",
|
|
373
498
|
);
|
|
499
|
+
const host = value.contributions.runtime.host;
|
|
500
|
+
if (host !== undefined && host !== "bot-isolate") {
|
|
501
|
+
throw new Error("manifest runtime host is invalid");
|
|
502
|
+
}
|
|
374
503
|
contributions.runtime = {
|
|
375
504
|
entry: relativeEntry(value.contributions.runtime, "entry"),
|
|
505
|
+
...(host === "bot-isolate" ? { host } : {}),
|
|
376
506
|
};
|
|
377
507
|
}
|
|
378
508
|
if (value.contributions.client !== undefined) {
|
|
379
509
|
const client = value.contributions.client;
|
|
380
510
|
if (!isRecord(client))
|
|
381
511
|
throw new Error("manifest client contribution must be an object");
|
|
382
|
-
exactFields(
|
|
383
|
-
client,
|
|
384
|
-
["entry", "mounts", "outlets"],
|
|
385
|
-
"manifest client contribution",
|
|
386
|
-
);
|
|
387
512
|
const mounts = client.mounts;
|
|
388
513
|
if (!Array.isArray(mounts)) {
|
|
389
514
|
throw new Error("manifest client mounts must be an array");
|
|
390
515
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
516
|
+
const decodedMounts = mounts.map((mount) => {
|
|
517
|
+
if (!isRecord(mount))
|
|
518
|
+
throw new Error("manifest client mount must be an object");
|
|
519
|
+
exactFields(mount, ["slot", "order"], "manifest client mount");
|
|
520
|
+
const order = mount.order;
|
|
521
|
+
if (
|
|
522
|
+
order !== undefined &&
|
|
523
|
+
(typeof order !== "number" || !Number.isFinite(order))
|
|
524
|
+
) {
|
|
525
|
+
throw new Error("manifest client mount order must be finite");
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
slot: requiredString(mount, "slot"),
|
|
529
|
+
...(order === undefined ? {} : { order }),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
if (client.kind === "iframe") {
|
|
533
|
+
if (!isV3OrLater(value)) {
|
|
534
|
+
throw new Error("manifest iframe client requires schema version 3");
|
|
535
|
+
}
|
|
536
|
+
exactFields(
|
|
537
|
+
client,
|
|
538
|
+
["kind", "artifact", "mounts"],
|
|
539
|
+
"manifest iframe client contribution",
|
|
540
|
+
);
|
|
541
|
+
if (!isRecord(client.artifact)) {
|
|
542
|
+
throw new Error("manifest iframe client artifact must be an object");
|
|
543
|
+
}
|
|
544
|
+
exactFields(
|
|
545
|
+
client.artifact,
|
|
546
|
+
["contentHash", "size", "mediaType", "bundlerVersion"],
|
|
547
|
+
"manifest iframe client artifact",
|
|
548
|
+
);
|
|
549
|
+
const artifact = client.artifact;
|
|
550
|
+
if (
|
|
551
|
+
typeof artifact.contentHash !== "string" ||
|
|
552
|
+
!/^[0-9a-f]{64}$/.test(artifact.contentHash)
|
|
553
|
+
) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
"manifest iframe client artifact contentHash must be a sha-256 digest",
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (
|
|
559
|
+
!Number.isSafeInteger(artifact.size) ||
|
|
560
|
+
(artifact.size as number) < 0 ||
|
|
561
|
+
(artifact.size as number) > 256 * 1024
|
|
562
|
+
) {
|
|
563
|
+
throw new Error(
|
|
564
|
+
"manifest iframe client artifact size must be within the 256 KB quota",
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (artifact.mediaType !== "text/html") {
|
|
568
|
+
throw new Error("manifest iframe client artifact mediaType is invalid");
|
|
569
|
+
}
|
|
570
|
+
contributions.client = {
|
|
571
|
+
kind: "iframe",
|
|
572
|
+
artifact: {
|
|
573
|
+
contentHash: artifact.contentHash,
|
|
574
|
+
size: artifact.size as number,
|
|
575
|
+
mediaType: "text/html",
|
|
576
|
+
bundlerVersion: requiredString(artifact, "bundlerVersion"),
|
|
577
|
+
},
|
|
578
|
+
mounts: decodedMounts,
|
|
579
|
+
};
|
|
580
|
+
} else {
|
|
581
|
+
exactFields(
|
|
582
|
+
client,
|
|
583
|
+
["entry", "mounts", "outlets"],
|
|
584
|
+
"manifest client contribution",
|
|
585
|
+
);
|
|
586
|
+
contributions.client = {
|
|
587
|
+
entry: relativeEntry(client, "entry"),
|
|
588
|
+
mounts: decodedMounts,
|
|
589
|
+
outlets: optionalStringArray(client, "outlets"),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
408
592
|
}
|
|
409
593
|
if (value.contributions.mobile !== undefined) {
|
|
410
594
|
const mobile = value.contributions.mobile;
|
|
@@ -1218,23 +1402,79 @@ function decodeConfiguration(
|
|
|
1218
1402
|
|
|
1219
1403
|
function decodeV3(value: Record<string, unknown>): FrockBotManifest {
|
|
1220
1404
|
const base = decodeV2(value);
|
|
1405
|
+
const tools = decodeManifestTools(value.tools);
|
|
1406
|
+
const hooks = decodeManifestHooks(value.hooks);
|
|
1407
|
+
const botIsolate = base.contributions.runtime?.host === "bot-isolate";
|
|
1408
|
+
if (botIsolate !== (tools !== undefined)) {
|
|
1409
|
+
throw new Error(
|
|
1410
|
+
"manifest bot-isolate runtime and tools declaration must appear together",
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
if (!botIsolate && hooks !== undefined) {
|
|
1414
|
+
throw new Error("manifest hooks require a bot-isolate runtime");
|
|
1415
|
+
}
|
|
1416
|
+
validateIframeClientContribution(base.contributions.client, tools);
|
|
1221
1417
|
return {
|
|
1222
1418
|
...base,
|
|
1223
1419
|
schemaVersion: 3,
|
|
1224
1420
|
configuration: decodeConfiguration(value.configuration, false),
|
|
1421
|
+
...(tools ? { tools } : {}),
|
|
1422
|
+
...(hooks ? { hooks } : {}),
|
|
1225
1423
|
};
|
|
1226
1424
|
}
|
|
1227
1425
|
|
|
1228
1426
|
/** v4 is v3 plus the Capability admission ceiling, and nothing else. */
|
|
1229
1427
|
function decodeV4(value: Record<string, unknown>): FrockBotManifest {
|
|
1230
1428
|
const base = decodeV2(value);
|
|
1429
|
+
const tools = decodeManifestTools(value.tools);
|
|
1430
|
+
const hooks = decodeManifestHooks(value.hooks);
|
|
1431
|
+
const botIsolate = base.contributions.runtime?.host === "bot-isolate";
|
|
1432
|
+
if (botIsolate !== (tools !== undefined)) {
|
|
1433
|
+
throw new Error(
|
|
1434
|
+
"manifest bot-isolate runtime and tools declaration must appear together",
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
if (!botIsolate && hooks !== undefined) {
|
|
1438
|
+
throw new Error("manifest hooks require a bot-isolate runtime");
|
|
1439
|
+
}
|
|
1440
|
+
validateIframeClientContribution(base.contributions.client, tools);
|
|
1231
1441
|
return {
|
|
1232
1442
|
...base,
|
|
1233
1443
|
schemaVersion: 4,
|
|
1234
1444
|
configuration: decodeConfiguration(value.configuration, true),
|
|
1445
|
+
...(tools ? { tools } : {}),
|
|
1446
|
+
...(hooks ? { hooks } : {}),
|
|
1235
1447
|
};
|
|
1236
1448
|
}
|
|
1237
1449
|
|
|
1450
|
+
function validateIframeClientContribution(
|
|
1451
|
+
client: ClientContribution | undefined,
|
|
1452
|
+
tools: ManifestToolDeclaration[] | undefined,
|
|
1453
|
+
): void {
|
|
1454
|
+
if (!client || !isClientIframeContribution(client)) return;
|
|
1455
|
+
if (client.mounts.length === 0 || client.mounts.length > 64) {
|
|
1456
|
+
throw new Error(
|
|
1457
|
+
"manifest iframe client mounts must be a non-empty bounded array",
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
const toolNames = new Set((tools ?? []).map((tool) => tool.name));
|
|
1461
|
+
for (const mount of client.mounts) {
|
|
1462
|
+
if (mount.slot === "frockbot.bot-settings-sections") continue;
|
|
1463
|
+
const prefix = "frockbot.tool-result:";
|
|
1464
|
+
if (!mount.slot.startsWith(prefix)) {
|
|
1465
|
+
throw new Error(
|
|
1466
|
+
`manifest iframe client slot "${mount.slot}" is not iframe-safe`,
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
const toolName = mount.slot.slice(prefix.length);
|
|
1470
|
+
if (!toolNames.has(toolName)) {
|
|
1471
|
+
throw new Error(
|
|
1472
|
+
`manifest iframe client tool-result slot names undeclared tool "${toolName}"`,
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1238
1478
|
export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
|
|
1239
1479
|
if (!isRecord(value)) throw new Error("manifest must be an object");
|
|
1240
1480
|
if (value.schemaVersion === 1) {
|
|
@@ -1270,6 +1510,8 @@ export function decodeFrockBotManifest(value: unknown): FrockBotManifest {
|
|
|
1270
1510
|
"defaultEnablement",
|
|
1271
1511
|
"contributions",
|
|
1272
1512
|
...(isV3OrLater(value) ? ["configuration"] : []),
|
|
1513
|
+
...(isV3OrLater(value) ? ["tools"] : []),
|
|
1514
|
+
...(isV3OrLater(value) ? ["hooks"] : []),
|
|
1273
1515
|
],
|
|
1274
1516
|
"manifest",
|
|
1275
1517
|
);
|