@frockbot/kernel-contracts 0.3.1 → 0.3.2
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 +4 -2
- package/src/applets.test.ts +299 -0
- package/src/applets.ts +902 -0
- package/src/authoring.test.ts +47 -0
- package/src/authoring.ts +57 -21
- package/src/contributions.ts +102 -0
- package/src/iframe-ui.test.ts +238 -37
- package/src/iframe-ui.ts +430 -89
- package/src/index.ts +2 -0
- package/src/isolate-context-catalog.generated.ts +3 -2
- package/src/isolate.ts +98 -0
- package/src/types.ts +10 -3
package/src/authoring.test.ts
CHANGED
|
@@ -31,6 +31,53 @@ describe("the kernel-declared bundler seam", () => {
|
|
|
31
31
|
expect(decodePackageBundleArtifactV1(ARTIFACT)).toEqual(ARTIFACT as never);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test("decodes multi-page UI artifacts exactly", () => {
|
|
35
|
+
const page = (id: string, hash: string) => ({
|
|
36
|
+
id,
|
|
37
|
+
artifact: {
|
|
38
|
+
contentHash: hash.repeat(64),
|
|
39
|
+
size: 32,
|
|
40
|
+
mediaType: "text/html",
|
|
41
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
42
|
+
},
|
|
43
|
+
html: `<!doctype html><h1>${id}</h1>`,
|
|
44
|
+
});
|
|
45
|
+
const bundled = (uiArtifacts: unknown) => ({
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
effectId: "author-1",
|
|
48
|
+
status: "bundled",
|
|
49
|
+
artifact: ARTIFACT,
|
|
50
|
+
module: "export const tools = [];",
|
|
51
|
+
uiArtifacts,
|
|
52
|
+
diagnostics: [],
|
|
53
|
+
});
|
|
54
|
+
const result = decodePackageBundleResultV1(
|
|
55
|
+
bundled([page("main", "b"), page("board", "c")]),
|
|
56
|
+
);
|
|
57
|
+
expect(
|
|
58
|
+
result.status === "bundled"
|
|
59
|
+
? result.uiArtifacts?.map((entry) => entry.id)
|
|
60
|
+
: undefined,
|
|
61
|
+
).toEqual(["main", "board"]);
|
|
62
|
+
expect(() => decodePackageBundleResultV1(bundled([]))).toThrow(
|
|
63
|
+
"non-empty bounded array",
|
|
64
|
+
);
|
|
65
|
+
expect(() =>
|
|
66
|
+
decodePackageBundleResultV1(
|
|
67
|
+
bundled([page("main", "b"), page("main", "c")]),
|
|
68
|
+
),
|
|
69
|
+
).toThrow("duplicate ids");
|
|
70
|
+
expect(() =>
|
|
71
|
+
decodePackageBundleResultV1(bundled([page("Main", "b")])),
|
|
72
|
+
).toThrow("id is invalid");
|
|
73
|
+
expect(() =>
|
|
74
|
+
decodePackageBundleResultV1({
|
|
75
|
+
...bundled([page("main", "b")]),
|
|
76
|
+
uiHtml: "<!doctype html>",
|
|
77
|
+
}),
|
|
78
|
+
).toThrow("invalid fields");
|
|
79
|
+
});
|
|
80
|
+
|
|
34
81
|
test("decodes a failed result exactly", () => {
|
|
35
82
|
expect(
|
|
36
83
|
decodePackageBundleResultV1({
|
package/src/authoring.ts
CHANGED
|
@@ -18,11 +18,29 @@ export const PACKAGE_BUNDLE_ENTRY = "package.ts";
|
|
|
18
18
|
/** Raw HTML is content-addressed without transforming it. */
|
|
19
19
|
export const PACKAGE_UI_ARTIFACT_VERSION = "frockbot-inline-html@1";
|
|
20
20
|
|
|
21
|
+
/** Manifest v5: 1..8 UI pages, each hashed and stored under its own id. */
|
|
22
|
+
export const PACKAGE_UI_MAX_PAGES = 8;
|
|
23
|
+
export const PACKAGE_UI_PAGE_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
24
|
+
|
|
21
25
|
export interface PackageBundleSourceV1 {
|
|
22
26
|
path: string;
|
|
23
27
|
text: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** One inline-only HTML page in a bundle request. */
|
|
31
|
+
export interface PackageUiPageSourceV1 {
|
|
32
|
+
id: string;
|
|
33
|
+
html: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One content-addressed HTML page in a bundle result. */
|
|
37
|
+
export interface PackageUiArtifactPageV1 {
|
|
38
|
+
id: string;
|
|
39
|
+
artifact: PackageUiArtifactV1;
|
|
40
|
+
/** Exact HTML bytes; the Durable Object owns the immutable write. */
|
|
41
|
+
html: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export interface PackageBundleRequestV1 {
|
|
27
45
|
schemaVersion: 1;
|
|
28
46
|
/** Idempotency key: the Durable-Object-recorded authorship intent id. */
|
|
@@ -31,8 +49,8 @@ export interface PackageBundleRequestV1 {
|
|
|
31
49
|
compatibilityDate: string;
|
|
32
50
|
entry: "package.ts";
|
|
33
51
|
sources: PackageBundleSourceV1[];
|
|
34
|
-
/**
|
|
35
|
-
|
|
52
|
+
/** Immutable inline-only HTML pages; never compiled into app code. */
|
|
53
|
+
uiPages?: PackageUiPageSourceV1[];
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
export interface PackageBundleArtifactV1 {
|
|
@@ -57,9 +75,7 @@ export type PackageBundleResultV1 =
|
|
|
57
75
|
effectId: string;
|
|
58
76
|
status: "bundled";
|
|
59
77
|
artifact: PackageBundleArtifactV1;
|
|
60
|
-
|
|
61
|
-
/** Exact HTML bytes; the Durable Object owns the immutable write. */
|
|
62
|
-
uiHtml?: string;
|
|
78
|
+
uiArtifacts?: PackageUiArtifactPageV1[];
|
|
63
79
|
/** The module bytes as text; the Durable Object owns the artifact write. */
|
|
64
80
|
module: string;
|
|
65
81
|
diagnostics: string[];
|
|
@@ -191,12 +207,7 @@ export function decodePackageBundleResultV1(
|
|
|
191
207
|
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
192
208
|
const effectId = boundedString(value.effectId, `${label}.effectId`, 200);
|
|
193
209
|
if (value.status === "bundled") {
|
|
194
|
-
const hasUi = value.
|
|
195
|
-
if ((value.uiArtifact === undefined) !== (value.uiHtml === undefined)) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`${label} must return UI artifact metadata and HTML together`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
210
|
+
const hasUi = value.uiArtifacts !== undefined;
|
|
200
211
|
exactKeys(
|
|
201
212
|
value,
|
|
202
213
|
[
|
|
@@ -206,7 +217,7 @@ export function decodePackageBundleResultV1(
|
|
|
206
217
|
"artifact",
|
|
207
218
|
"module",
|
|
208
219
|
"diagnostics",
|
|
209
|
-
...(hasUi ? ["
|
|
220
|
+
...(hasUi ? ["uiArtifacts"] : []),
|
|
210
221
|
],
|
|
211
222
|
label,
|
|
212
223
|
);
|
|
@@ -216,14 +227,39 @@ export function decodePackageBundleResultV1(
|
|
|
216
227
|
);
|
|
217
228
|
if (typeof value.module !== "string" || value.module.length === 0)
|
|
218
229
|
throw new Error(`${label}.module must be a non-empty string`);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
let uiArtifacts: PackageUiArtifactPageV1[] | undefined;
|
|
231
|
+
if (hasUi) {
|
|
232
|
+
if (
|
|
233
|
+
!Array.isArray(value.uiArtifacts) ||
|
|
234
|
+
value.uiArtifacts.length === 0 ||
|
|
235
|
+
value.uiArtifacts.length > PACKAGE_UI_MAX_PAGES
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${label}.uiArtifacts must be a non-empty bounded array`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
uiArtifacts = value.uiArtifacts.map((candidate, index) => {
|
|
242
|
+
const pageLabel = `${label}.uiArtifacts[${index}]`;
|
|
243
|
+
const page = record(candidate, pageLabel);
|
|
244
|
+
exactKeys(page, ["id", "artifact", "html"], pageLabel);
|
|
245
|
+
const id = boundedString(page.id, `${pageLabel}.id`, 32);
|
|
246
|
+
if (!PACKAGE_UI_PAGE_ID.test(id))
|
|
247
|
+
throw new Error(`${pageLabel}.id is invalid`);
|
|
248
|
+
if (typeof page.html !== "string" || page.html.length === 0)
|
|
249
|
+
throw new Error(`${pageLabel}.html must be a non-empty string`);
|
|
250
|
+
return {
|
|
251
|
+
id,
|
|
252
|
+
artifact: decodePackageUiArtifactV1(
|
|
253
|
+
page.artifact,
|
|
254
|
+
`${pageLabel}.artifact`,
|
|
255
|
+
),
|
|
256
|
+
html: page.html,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
if (
|
|
260
|
+
new Set(uiArtifacts.map((page) => page.id)).size !== uiArtifacts.length
|
|
261
|
+
)
|
|
262
|
+
throw new Error(`${label}.uiArtifacts contains duplicate ids`);
|
|
227
263
|
}
|
|
228
264
|
return {
|
|
229
265
|
schemaVersion: 1,
|
|
@@ -231,7 +267,7 @@ export function decodePackageBundleResultV1(
|
|
|
231
267
|
status: "bundled",
|
|
232
268
|
artifact,
|
|
233
269
|
module: value.module,
|
|
234
|
-
...(
|
|
270
|
+
...(uiArtifacts ? { uiArtifacts } : {}),
|
|
235
271
|
diagnostics: diagnostics(value.diagnostics, `${label}.diagnostics`),
|
|
236
272
|
};
|
|
237
273
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Plugin } from "cordis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a first-party Contribution names the code that implements it.
|
|
5
|
+
*
|
|
6
|
+
* The constitution's rule is that every Contribution kind is resolved from the
|
|
7
|
+
* manifest and an artifact, never from a switch over Package identity. A
|
|
8
|
+
* non-first-party Package satisfies that by carrying an immutable artifact the
|
|
9
|
+
* loader mounts. A first-party Package that still runs in the kernel's own
|
|
10
|
+
* isolate satisfies it by exporting a **descriptor** beside its factory: the
|
|
11
|
+
* Contribution specifier its manifest declares, the host it belongs in, and
|
|
12
|
+
* the one function that builds it. An application then owns a table keyed by
|
|
13
|
+
* specifier and nothing else — no `if`, no `switch`, no comparison against a
|
|
14
|
+
* Package's name — and a specifier in a plan that reaches neither the table
|
|
15
|
+
* nor an artifact is an error when the application is compiled.
|
|
16
|
+
*
|
|
17
|
+
* These types deliberately know nothing about any Package: a descriptor is a
|
|
18
|
+
* specifier, a host, and a closure whose host object the *application*
|
|
19
|
+
* supplies. That is what lets the kernel declare them while importing no
|
|
20
|
+
* Package.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The execution host a backend Contribution's factory is mounted in. */
|
|
24
|
+
export type BackendContributionHostV1 = "gateway" | "bot" | "user";
|
|
25
|
+
|
|
26
|
+
/** What a Contribution's factory is handed to publish the value it mounts. */
|
|
27
|
+
export interface ContributionLifecycleV1<Contribution> {
|
|
28
|
+
mount(contribution: Contribution): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A first-party backend Contribution: the specifier its manifest declares, the
|
|
33
|
+
* host it belongs in, and the factory that mounts it into a Cordis fiber.
|
|
34
|
+
*
|
|
35
|
+
* `Host` is contravariant, so a descriptor written against the narrow host
|
|
36
|
+
* interface its own Package declares is usable by an application whose wide
|
|
37
|
+
* host object satisfies every Package's slice of it.
|
|
38
|
+
*/
|
|
39
|
+
export interface BackendContributionDescriptorV1<Host, Contribution> {
|
|
40
|
+
readonly kind: "backend";
|
|
41
|
+
readonly specifier: string;
|
|
42
|
+
readonly host: BackendContributionHostV1;
|
|
43
|
+
create(host: Host, lifecycle: ContributionLifecycleV1<Contribution>): Plugin;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A first-party client Contribution. A client Plugin takes no host — the
|
|
48
|
+
* client runtime is its host — so the descriptor carries the Plugin itself.
|
|
49
|
+
* The Plugin type stays generic so this module imports no client package.
|
|
50
|
+
*/
|
|
51
|
+
export interface ClientContributionDescriptorV1<ClientPlugin> {
|
|
52
|
+
readonly kind: "client";
|
|
53
|
+
readonly specifier: string;
|
|
54
|
+
readonly plugin: ClientPlugin;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Any first-party Contribution descriptor an application table can hold. */
|
|
58
|
+
export type ContributionDescriptorV1<ClientPlugin = unknown> =
|
|
59
|
+
| BackendContributionDescriptorV1<never, unknown>
|
|
60
|
+
| ClientContributionDescriptorV1<ClientPlugin>;
|
|
61
|
+
|
|
62
|
+
export function defineBackendContribution<Host, Contribution>(
|
|
63
|
+
descriptor: Omit<BackendContributionDescriptorV1<Host, Contribution>, "kind">,
|
|
64
|
+
): BackendContributionDescriptorV1<Host, Contribution> {
|
|
65
|
+
return { kind: "backend", ...descriptor };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `defineBackendContribution` with the host fixed to the gateway. */
|
|
69
|
+
export function defineGatewayContribution<Host, Contribution>(
|
|
70
|
+
descriptor: Omit<
|
|
71
|
+
BackendContributionDescriptorV1<Host, Contribution>,
|
|
72
|
+
"kind" | "host"
|
|
73
|
+
>,
|
|
74
|
+
): BackendContributionDescriptorV1<Host, Contribution> {
|
|
75
|
+
return { kind: "backend", host: "gateway", ...descriptor };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `defineBackendContribution` with the host fixed to the User Durable Object. */
|
|
79
|
+
export function defineUserBackendContribution<Host, Contribution>(
|
|
80
|
+
descriptor: Omit<
|
|
81
|
+
BackendContributionDescriptorV1<Host, Contribution>,
|
|
82
|
+
"kind" | "host"
|
|
83
|
+
>,
|
|
84
|
+
): BackendContributionDescriptorV1<Host, Contribution> {
|
|
85
|
+
return { kind: "backend", host: "user", ...descriptor };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `defineBackendContribution` with the host fixed to the Bot Durable Object. */
|
|
89
|
+
export function defineBotBackendContribution<Host, Contribution>(
|
|
90
|
+
descriptor: Omit<
|
|
91
|
+
BackendContributionDescriptorV1<Host, Contribution>,
|
|
92
|
+
"kind" | "host"
|
|
93
|
+
>,
|
|
94
|
+
): BackendContributionDescriptorV1<Host, Contribution> {
|
|
95
|
+
return { kind: "backend", host: "bot", ...descriptor };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function defineClientContribution<ClientPlugin>(
|
|
99
|
+
descriptor: Omit<ClientContributionDescriptorV1<ClientPlugin>, "kind">,
|
|
100
|
+
): ClientContributionDescriptorV1<ClientPlugin> {
|
|
101
|
+
return { kind: "client", ...descriptor };
|
|
102
|
+
}
|
package/src/iframe-ui.test.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
decodePackageIframeCatalogV1,
|
|
4
|
-
|
|
4
|
+
decodePackageIframePageMessageV2,
|
|
5
5
|
decodePackageIframeToolCommandV1,
|
|
6
|
+
iframePageSlotAllowedV1,
|
|
7
|
+
packageIframeExternalUrlAllowedV2,
|
|
8
|
+
packageIframeFocusAllowedV2,
|
|
6
9
|
packageIframeToolAllowedV1,
|
|
10
|
+
PACKAGE_IFRAME_BRIDGE_VERSION,
|
|
7
11
|
} from "./iframe-ui.js";
|
|
8
12
|
|
|
9
|
-
describe("Package iframe bridge
|
|
13
|
+
describe("Package iframe bridge v2", () => {
|
|
10
14
|
test("exactly decodes the two page-to-host messages", () => {
|
|
11
15
|
expect(
|
|
12
|
-
|
|
16
|
+
decodePackageIframePageMessageV2({
|
|
13
17
|
schemaVersion: 1,
|
|
14
18
|
type: "callTool",
|
|
15
19
|
name: "weather_lookup",
|
|
@@ -17,14 +21,14 @@ describe("Package iframe bridge v1", () => {
|
|
|
17
21
|
}),
|
|
18
22
|
).toMatchObject({ type: "callTool", name: "weather_lookup" });
|
|
19
23
|
expect(
|
|
20
|
-
|
|
24
|
+
decodePackageIframePageMessageV2({
|
|
21
25
|
schemaVersion: 1,
|
|
22
26
|
type: "resize",
|
|
23
27
|
height: 320,
|
|
24
28
|
}),
|
|
25
29
|
).toEqual({ schemaVersion: 1, type: "resize", height: 320 });
|
|
26
30
|
expect(() =>
|
|
27
|
-
|
|
31
|
+
decodePackageIframePageMessageV2({
|
|
28
32
|
schemaVersion: 1,
|
|
29
33
|
type: "resize",
|
|
30
34
|
height: 320,
|
|
@@ -33,6 +37,105 @@ describe("Package iframe bridge v1", () => {
|
|
|
33
37
|
).toThrow("invalid fields");
|
|
34
38
|
});
|
|
35
39
|
|
|
40
|
+
test("keeps a version 1 page working and adds the version 2 messages", () => {
|
|
41
|
+
// A v1 page's message decodes as v1 and is answered at v1, so a page
|
|
42
|
+
// published before this bridge existed is unaffected by the bump.
|
|
43
|
+
expect(
|
|
44
|
+
decodePackageIframePageMessageV2({
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
type: "resize",
|
|
47
|
+
height: 120,
|
|
48
|
+
}).schemaVersion,
|
|
49
|
+
).toBe(1);
|
|
50
|
+
expect(PACKAGE_IFRAME_BRIDGE_VERSION).toBe(2);
|
|
51
|
+
expect(
|
|
52
|
+
decodePackageIframePageMessageV2({
|
|
53
|
+
schemaVersion: 2,
|
|
54
|
+
type: "hello",
|
|
55
|
+
bridgeVersion: 2,
|
|
56
|
+
}),
|
|
57
|
+
).toEqual({ schemaVersion: 2, type: "hello", bridgeVersion: 2 });
|
|
58
|
+
expect(
|
|
59
|
+
decodePackageIframePageMessageV2({
|
|
60
|
+
schemaVersion: 2,
|
|
61
|
+
type: "focus",
|
|
62
|
+
appletId: "todo.abc123",
|
|
63
|
+
}),
|
|
64
|
+
).toEqual({ schemaVersion: 2, type: "focus", appletId: "todo.abc123" });
|
|
65
|
+
expect(
|
|
66
|
+
decodePackageIframePageMessageV2({
|
|
67
|
+
schemaVersion: 2,
|
|
68
|
+
type: "focus",
|
|
69
|
+
appletId: null,
|
|
70
|
+
}),
|
|
71
|
+
).toEqual({ schemaVersion: 2, type: "focus", appletId: null });
|
|
72
|
+
expect(
|
|
73
|
+
decodePackageIframePageMessageV2({
|
|
74
|
+
schemaVersion: 2,
|
|
75
|
+
type: "openExternal",
|
|
76
|
+
url: "https://ui.example.com/packages/a.html",
|
|
77
|
+
}),
|
|
78
|
+
).toMatchObject({ type: "openExternal" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("fails closed on v2 messages claiming version 1 and on bad payloads", () => {
|
|
82
|
+
// A page cannot reach a v2 capability by claiming the older version.
|
|
83
|
+
for (const type of ["hello", "focus", "openExternal"]) {
|
|
84
|
+
expect(() =>
|
|
85
|
+
decodePackageIframePageMessageV2({
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
type,
|
|
88
|
+
appletId: null,
|
|
89
|
+
url: "https://ui.example.com/",
|
|
90
|
+
bridgeVersion: 1,
|
|
91
|
+
}),
|
|
92
|
+
).toThrow("type is invalid");
|
|
93
|
+
}
|
|
94
|
+
expect(() =>
|
|
95
|
+
decodePackageIframePageMessageV2({
|
|
96
|
+
schemaVersion: 2,
|
|
97
|
+
type: "focus",
|
|
98
|
+
appletId: "Not An Applet",
|
|
99
|
+
}),
|
|
100
|
+
).toThrow("appletId is invalid");
|
|
101
|
+
expect(() =>
|
|
102
|
+
decodePackageIframePageMessageV2({
|
|
103
|
+
schemaVersion: 2,
|
|
104
|
+
type: "openExternal",
|
|
105
|
+
url: "javascript:alert(1)",
|
|
106
|
+
}),
|
|
107
|
+
).toThrow("url is invalid");
|
|
108
|
+
expect(() =>
|
|
109
|
+
decodePackageIframePageMessageV2({
|
|
110
|
+
schemaVersion: 3,
|
|
111
|
+
type: "resize",
|
|
112
|
+
height: 1,
|
|
113
|
+
}),
|
|
114
|
+
).toThrow("schemaVersion is unsupported");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("an external open is limited to the artifact origin", () => {
|
|
118
|
+
const origin = "https://ui.example.com";
|
|
119
|
+
expect(
|
|
120
|
+
packageIframeExternalUrlAllowedV2(`${origin}/packages/a.html`, origin),
|
|
121
|
+
).toBe(true);
|
|
122
|
+
expect(
|
|
123
|
+
packageIframeExternalUrlAllowedV2("https://evil.example.com/", origin),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
expect(packageIframeExternalUrlAllowedV2("not a url", origin)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("only the Package that owns the focus tool may change the focus", () => {
|
|
129
|
+
expect(
|
|
130
|
+
packageIframeFocusAllowedV2({
|
|
131
|
+
declaredTools: ["applet_focus", "applet_list"],
|
|
132
|
+
}),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
expect(
|
|
135
|
+
packageIframeFocusAllowedV2({ declaredTools: ["weather_lookup"] }),
|
|
136
|
+
).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
36
139
|
test("refuses a tool the Package did not declare", () => {
|
|
37
140
|
const contribution = { declaredTools: ["weather_lookup"] };
|
|
38
141
|
expect(packageIframeToolAllowedV1(contribution, "weather_lookup")).toBe(
|
|
@@ -58,42 +161,140 @@ describe("Package iframe bridge v1", () => {
|
|
|
58
161
|
).toThrow("invalid fields");
|
|
59
162
|
});
|
|
60
163
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
164
|
+
const artifact = (hash: string) => ({
|
|
165
|
+
contentHash: hash.repeat(64),
|
|
166
|
+
size: 256 * 1024,
|
|
167
|
+
mediaType: "text/html",
|
|
168
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const catalog = () => ({
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
botId: "bot",
|
|
174
|
+
generationId: "generation-1",
|
|
175
|
+
artifactOrigin: "https://ui.bot.frockbot.com",
|
|
176
|
+
contributions: [
|
|
177
|
+
{
|
|
178
|
+
packageId: "weather-page",
|
|
179
|
+
displayName: "Weather page",
|
|
180
|
+
provenance: "Bot-authored",
|
|
181
|
+
pages: [
|
|
182
|
+
{
|
|
183
|
+
id: "main",
|
|
184
|
+
artifact: artifact("a"),
|
|
185
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
77
186
|
},
|
|
78
|
-
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
79
|
-
declaredTools: ["weather_lookup"],
|
|
80
|
-
},
|
|
81
|
-
],
|
|
82
|
-
};
|
|
83
|
-
expect(decodePackageIframeCatalogV1(catalog).contributions).toHaveLength(1);
|
|
84
|
-
expect(() =>
|
|
85
|
-
decodePackageIframeCatalogV1({
|
|
86
|
-
...catalog,
|
|
87
|
-
contributions: [
|
|
88
187
|
{
|
|
89
|
-
|
|
90
|
-
artifact:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
id: "board",
|
|
189
|
+
artifact: artifact("b"),
|
|
190
|
+
mounts: [
|
|
191
|
+
{ slot: "frockbot.surface:board" },
|
|
192
|
+
{ slot: "frockbot.right-panel" },
|
|
193
|
+
],
|
|
94
194
|
},
|
|
95
195
|
],
|
|
96
|
-
|
|
196
|
+
entries: [
|
|
197
|
+
{
|
|
198
|
+
id: "open",
|
|
199
|
+
slot: "frockbot.sidebar-actions",
|
|
200
|
+
order: 5,
|
|
201
|
+
label: "Weather board",
|
|
202
|
+
icon: "sparkle",
|
|
203
|
+
opens: { kind: "surface", page: "board" },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
declaredTools: ["weather_lookup"],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const withContribution = (patch: Record<string, unknown>) => ({
|
|
212
|
+
...catalog(),
|
|
213
|
+
contributions: [{ ...catalog().contributions[0]!, ...patch }],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("bounds projected HTML artifacts at the catalog seam", () => {
|
|
217
|
+
const decoded = decodePackageIframeCatalogV1(catalog());
|
|
218
|
+
expect(decoded.contributions).toHaveLength(1);
|
|
219
|
+
expect(decoded.contributions[0]!.pages.map((page) => page.id)).toEqual([
|
|
220
|
+
"main",
|
|
221
|
+
"board",
|
|
222
|
+
]);
|
|
223
|
+
expect(decoded.contributions[0]!.entries).toHaveLength(1);
|
|
224
|
+
expect(() =>
|
|
225
|
+
decodePackageIframeCatalogV1(
|
|
226
|
+
withContribution({
|
|
227
|
+
pages: [
|
|
228
|
+
{
|
|
229
|
+
...catalog().contributions[0]!.pages[0]!,
|
|
230
|
+
artifact: { ...artifact("a"), size: 256 * 1024 + 1 },
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
entries: [],
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
97
236
|
).toThrow("metadata is invalid");
|
|
98
237
|
});
|
|
238
|
+
|
|
239
|
+
test("refuses catalog pages and entries outside the manifest rules", () => {
|
|
240
|
+
expect(() =>
|
|
241
|
+
decodePackageIframeCatalogV1(withContribution({ pages: [] })),
|
|
242
|
+
).toThrow("non-empty bounded array");
|
|
243
|
+
expect(() =>
|
|
244
|
+
decodePackageIframeCatalogV1(
|
|
245
|
+
withContribution({
|
|
246
|
+
pages: [
|
|
247
|
+
{
|
|
248
|
+
id: "main",
|
|
249
|
+
artifact: artifact("a"),
|
|
250
|
+
mounts: [{ slot: "frockbot.surface:absent" }],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
entries: [],
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
).toThrow("unsafe slot");
|
|
257
|
+
expect(() =>
|
|
258
|
+
decodePackageIframeCatalogV1(
|
|
259
|
+
withContribution({
|
|
260
|
+
entries: [
|
|
261
|
+
{
|
|
262
|
+
...catalog().contributions[0]!.entries[0]!,
|
|
263
|
+
opens: { kind: "surface", page: "main" },
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
).toThrow("names no surface page");
|
|
269
|
+
expect(() =>
|
|
270
|
+
decodePackageIframeCatalogV1(
|
|
271
|
+
withContribution({
|
|
272
|
+
pages: [
|
|
273
|
+
catalog().contributions[0]!.pages[0]!,
|
|
274
|
+
{ ...catalog().contributions[0]!.pages[1]!, id: "main" },
|
|
275
|
+
],
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
).toThrow("duplicate ids");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("names one slot rule for every iframe seam", () => {
|
|
282
|
+
const context = { declaredTools: ["weather_lookup"], pageIds: ["board"] };
|
|
283
|
+
for (const slot of [
|
|
284
|
+
"frockbot.bot-settings-sections",
|
|
285
|
+
"frockbot.right-panel",
|
|
286
|
+
"frockbot.tool-result:weather_lookup",
|
|
287
|
+
"frockbot.surface:board",
|
|
288
|
+
]) {
|
|
289
|
+
expect(iframePageSlotAllowedV1(slot, context)).toBe(true);
|
|
290
|
+
}
|
|
291
|
+
for (const slot of [
|
|
292
|
+
"root",
|
|
293
|
+
"frockbot.sidebar-actions",
|
|
294
|
+
"frockbot.tool-result:package_author",
|
|
295
|
+
"frockbot.surface:list",
|
|
296
|
+
]) {
|
|
297
|
+
expect(iframePageSlotAllowedV1(slot, context)).toBe(false);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
99
300
|
});
|