@frockbot/architecture-checks 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/architecture-checks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Automated checks for the constitutional rules that can be enforced mechanically.",
|
|
@@ -9,21 +9,23 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@frockbot/
|
|
13
|
-
"@frockbot/
|
|
14
|
-
"@frockbot/
|
|
15
|
-
"@frockbot/kernel-
|
|
16
|
-
"@frockbot/kernel-
|
|
17
|
-
"@frockbot/kernel-
|
|
18
|
-
"@frockbot/
|
|
19
|
-
"@frockbot/plugin-
|
|
20
|
-
"@frockbot/plugin-
|
|
21
|
-
"@frockbot/plugin-
|
|
22
|
-
"@frockbot/plugin-
|
|
23
|
-
"@frockbot/plugin-
|
|
24
|
-
"@frockbot/plugin-
|
|
25
|
-
"@frockbot/plugin-
|
|
26
|
-
"@frockbot/plugin-
|
|
12
|
+
"@frockbot/application-foundation": "0.3.2",
|
|
13
|
+
"@frockbot/computer-core": "0.3.2",
|
|
14
|
+
"@frockbot/connection-core": "0.3.2",
|
|
15
|
+
"@frockbot/kernel-agent-loop": "0.3.2",
|
|
16
|
+
"@frockbot/kernel-composition": "0.3.2",
|
|
17
|
+
"@frockbot/kernel-contracts": "0.3.2",
|
|
18
|
+
"@frockbot/kernel-do": "0.3.2",
|
|
19
|
+
"@frockbot/plugin-applets": "0.3.2",
|
|
20
|
+
"@frockbot/plugin-authoring": "0.3.2",
|
|
21
|
+
"@frockbot/plugin-computer": "0.3.2",
|
|
22
|
+
"@frockbot/plugin-memory": "0.3.2",
|
|
23
|
+
"@frockbot/plugin-models": "0.3.2",
|
|
24
|
+
"@frockbot/plugin-prompt": "0.3.2",
|
|
25
|
+
"@frockbot/plugin-provider-foundation": "0.3.2",
|
|
26
|
+
"@frockbot/plugin-provider-ollama-cloud": "0.3.2",
|
|
27
|
+
"@frockbot/plugin-skills": "0.3.2",
|
|
28
|
+
"@frockbot/plugin-tools": "0.3.2",
|
|
27
29
|
"cordis": "4.0.0-rc.8"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The Applet rules of `AGENTS.md`, enforced where they are source-graph facts.
|
|
2
|
+
//
|
|
3
|
+
// The behavioural half — facet storage surviving a generation change and a
|
|
4
|
+
// revert, a failed health check leaving the prior facet resident, delete
|
|
5
|
+
// removing storage, versions, and entry, and the viewer token's scope — runs
|
|
6
|
+
// against the real Durable Object in workerd:
|
|
7
|
+
// `apps/cloudflare/test/applets.workerd.ts`. What is checked here is what only
|
|
8
|
+
// the source can answer: that a facet exists nowhere but under the Applet
|
|
9
|
+
// Durable Object, that an Applet's page carries no credential but its token,
|
|
10
|
+
// and that the kernel's Applet records name no Package.
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
16
|
+
|
|
17
|
+
function sourceFiles(...patterns: string[]): string[] {
|
|
18
|
+
const files: string[] = [];
|
|
19
|
+
for (const pattern of patterns) {
|
|
20
|
+
files.push(
|
|
21
|
+
...new Bun.Glob(pattern).scanSync({ cwd: repoRoot, onlyFiles: true }),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return files
|
|
25
|
+
.filter((path) => !path.includes("node_modules/"))
|
|
26
|
+
.filter((path) => !path.includes("/dist/"))
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function read(path: string): string {
|
|
31
|
+
return readFileSync(join(repoRoot, path), "utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Applet boundaries", () => {
|
|
35
|
+
// Constitution — Package composition: "Composition Packages never own
|
|
36
|
+
// storage; facets exist only under the Applet Durable Object."
|
|
37
|
+
test("only the Applet Durable Object mounts a facet", () => {
|
|
38
|
+
const allowed = new Set([
|
|
39
|
+
"apps/cloudflare/src/applet-state.ts",
|
|
40
|
+
// The lane S1 spike, deliberately kept out of `test:workerd`.
|
|
41
|
+
"apps/cloudflare/test/spike-applet-facet-worker.ts",
|
|
42
|
+
]);
|
|
43
|
+
const offenders: string[] = [];
|
|
44
|
+
for (const path of sourceFiles(
|
|
45
|
+
"packages/**/src/**/*.ts",
|
|
46
|
+
"apps/*/src/**/*.ts",
|
|
47
|
+
"apps/*/test/**/*.ts",
|
|
48
|
+
"applications/*/src/**/*.ts",
|
|
49
|
+
)) {
|
|
50
|
+
if (allowed.has(path)) continue;
|
|
51
|
+
const source = read(path);
|
|
52
|
+
if (/\bctx\.facets\.|\bstate\.facets\.|\bfacets\.get\(/.test(source)) {
|
|
53
|
+
offenders.push(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
expect(offenders).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Constitution — Package composition: the kernel "mounts the instance's
|
|
60
|
+
// server artifact ... through Worker Loader with `globalOutbound` disabled".
|
|
61
|
+
test("the Applet loader mounts with no egress and a two-key env", () => {
|
|
62
|
+
const source = read("apps/cloudflare/src/applet-state.ts");
|
|
63
|
+
expect(source).toContain("globalOutbound: null");
|
|
64
|
+
// Exactly `IDENTITY` and `CAPABILITIES`, and nothing else, is placed in the
|
|
65
|
+
// loaded worker's env. The workerd test asserts the same from inside.
|
|
66
|
+
const env = source.slice(source.indexOf("env: {"));
|
|
67
|
+
expect(env).toContain("IDENTITY:");
|
|
68
|
+
expect(env).toContain("CAPABILITIES:");
|
|
69
|
+
for (const forbidden of [
|
|
70
|
+
"APPLICATION_ARTIFACTS:",
|
|
71
|
+
"APPLET_STATES:",
|
|
72
|
+
"APPLETS:",
|
|
73
|
+
"SECRET_TOKEN",
|
|
74
|
+
]) {
|
|
75
|
+
expect(env.slice(0, env.indexOf("limits:")).includes(forbidden)).toBe(
|
|
76
|
+
false,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Spike finding 1 (`docs/research/spike-applet-facets.md` §7): a loader id is
|
|
82
|
+
// served from cache with the `env` it was first loaded with, process-wide, so
|
|
83
|
+
// two Applets of one User with identical code would otherwise share one
|
|
84
|
+
// IDENTITY and one CAPABILITIES stub.
|
|
85
|
+
test("the Applet loader id includes the Applet instance", () => {
|
|
86
|
+
const source = read("packages/kernel-do/src/applets.ts");
|
|
87
|
+
const start = source.indexOf("export function appletLoaderIdV1");
|
|
88
|
+
const digest = source.indexOf("JSON.stringify({", start);
|
|
89
|
+
const body = source.slice(digest, source.indexOf("})", digest));
|
|
90
|
+
for (const input of [
|
|
91
|
+
"contract",
|
|
92
|
+
"appletId",
|
|
93
|
+
"serverHash",
|
|
94
|
+
"bindingDigest",
|
|
95
|
+
]) {
|
|
96
|
+
expect(body).toContain(`${input}: input.${input}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Spike finding 2 (§5b): `ctx.storage.setAlarm` inside a facet throws, and the
|
|
101
|
+
// throw is not catchable by the Applet, so the kernel object owns the alarm.
|
|
102
|
+
test("the Applet capability surface is the alarm and the model, and nothing else", () => {
|
|
103
|
+
const source = read("apps/cloudflare/src/applet-state.ts");
|
|
104
|
+
const capabilities = source.slice(
|
|
105
|
+
source.indexOf("export class AppletCapabilities"),
|
|
106
|
+
source.indexOf("export class AppletState"),
|
|
107
|
+
);
|
|
108
|
+
const methods = [
|
|
109
|
+
...capabilities.matchAll(/^ {2}(?:async )?([a-zA-Z]+)\(/gmu),
|
|
110
|
+
].map((match) => match[1]);
|
|
111
|
+
expect(methods.toSorted()).toEqual(["invokeModel", "scheduleAlarm"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Spike finding 4 (§8): "Stubs pointing to Durable Object facets are not
|
|
115
|
+
// serializable" — the kernel object forwards, and never hands the stub out.
|
|
116
|
+
test("no facet stub is returned across an RPC boundary", () => {
|
|
117
|
+
const source = read("apps/cloudflare/src/applet-state.ts");
|
|
118
|
+
// `#facet` is private, and every public method that uses it consumes it in
|
|
119
|
+
// place. A public method returning it would have to name it in a return.
|
|
120
|
+
expect(/return\s+(await\s+)?this\.#facet\(/.test(source)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Constitution — Architecture checks: "an open Applet's page carries no
|
|
124
|
+
// credential and reaches its facet only through a short-lived viewer token
|
|
125
|
+
// scoped to that Applet and User."
|
|
126
|
+
test("the viewer token is HMAC-signed, scoped, and short-lived", () => {
|
|
127
|
+
const source = read("packages/kernel-do/src/applets.ts");
|
|
128
|
+
expect(source).toContain('name: "HMAC", hash: "SHA-256"');
|
|
129
|
+
expect(source).toContain("APPLET_VIEWER_TOKEN_TTL_MS = 15 * 60_000");
|
|
130
|
+
// The claims are exactly the scope: User, Applet, generation, expiry.
|
|
131
|
+
const minted = source.slice(
|
|
132
|
+
source.indexOf("export async function mintAppletViewerTokenV1"),
|
|
133
|
+
);
|
|
134
|
+
const payload = minted.slice(
|
|
135
|
+
minted.indexOf("JSON.stringify({"),
|
|
136
|
+
minted.indexOf("}),"),
|
|
137
|
+
);
|
|
138
|
+
expect(payload).toContain("u: claims.u");
|
|
139
|
+
expect(payload).toContain("a: claims.a");
|
|
140
|
+
expect(payload).toContain("g: claims.g");
|
|
141
|
+
expect(payload).toContain("exp: claims.exp");
|
|
142
|
+
// Constant-time comparison: the signature check is the whole of the door.
|
|
143
|
+
expect(source).toContain("constantTimeEqualsV1(presented, expected)");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("the socket route compares the token's Applet against the path", () => {
|
|
147
|
+
const source = read("apps/cloudflare/src/gateway.ts");
|
|
148
|
+
expect(source).toContain("if (claims.a !== appletId)");
|
|
149
|
+
// The token never travels on to the Durable Object; the verified claims do.
|
|
150
|
+
expect(source).toContain('forwarded.searchParams.delete("token")');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Constitution — Minimal kernel: "The kernel imports no Package."
|
|
154
|
+
test("the kernel's Applet records import no Package", () => {
|
|
155
|
+
const source = read("packages/kernel-do/src/applets.ts");
|
|
156
|
+
expect(/from "@frockbot\/plugin-/.test(source)).toBe(false);
|
|
157
|
+
// The Applets Package's root ids are declared, not imported, and the TODO
|
|
158
|
+
// says why and names the lane that removes the duplication.
|
|
159
|
+
expect(source).toContain("TODO(lane C1)");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ADR 0022 decision 4: an Applet's tools are Composition members, so an
|
|
163
|
+
// admitted Turn records the Applet generation it ran under.
|
|
164
|
+
test("Applet members are covered by the artifact set hash", () => {
|
|
165
|
+
const source = read("packages/kernel-composition/src/generation.ts");
|
|
166
|
+
const hash = source.slice(
|
|
167
|
+
source.indexOf("export function compositionArtifactSetHashV1"),
|
|
168
|
+
);
|
|
169
|
+
expect(hash.slice(0, hash.indexOf("\n}"))).toContain("applets");
|
|
170
|
+
// And a Bot cannot author, install, or remove one directly: no origin kind
|
|
171
|
+
// names an Applet.
|
|
172
|
+
expect(source).not.toContain('kind: "applet-install"');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
|
|
5
|
+
import { compileFoundationApplication } from "@frockbot/application-foundation/runtime";
|
|
6
|
+
import { APPLETS_PACKAGE_ARTIFACT_V1 } from "@frockbot/application-foundation/generated/applets-artifact";
|
|
7
|
+
import appletsManifest from "@frockbot/plugin-applets/manifest";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `AGENTS.md`, Package composition:
|
|
11
|
+
*
|
|
12
|
+
* > Every Contribution kind is resolved from the manifest and an artifact,
|
|
13
|
+
* > never from a switch over Package identity. A first-party Package that
|
|
14
|
+
* > declares only Bot-authorable Contribution kinds ships as an artifact-backed
|
|
15
|
+
* > member and loads through the same path as a Bot-authored one.
|
|
16
|
+
*
|
|
17
|
+
* ADR 0022 decision 8 makes the Applets Package the standing proof of that
|
|
18
|
+
* sentence, and `docs/plans/applets.md` D6 makes it a hard requirement: "The
|
|
19
|
+
* Applets Package itself must be buildable inside a Bot with identical
|
|
20
|
+
* functionality. It therefore uses only Bot-authorable Contribution kinds."
|
|
21
|
+
*
|
|
22
|
+
* These checks are the cheap half — the manifest's shape and the application's
|
|
23
|
+
* declaration. The expensive half, authoring the same Package through
|
|
24
|
+
* `package_author` and mounting it, is
|
|
25
|
+
* `apps/cloudflare/test/applets-package.workerd.ts`, because it needs a real
|
|
26
|
+
* Worker Loader.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
30
|
+
const APPLETS_SPECIFIER = "@frockbot/plugin-applets";
|
|
31
|
+
|
|
32
|
+
const manifest = decodeFrockBotManifest(appletsManifest);
|
|
33
|
+
|
|
34
|
+
describe("the Applets Package declares only what a Bot could declare", () => {
|
|
35
|
+
test("its manifest is v5 and its runtime is a Bot isolate", () => {
|
|
36
|
+
expect(manifest.schemaVersion).toBe(5);
|
|
37
|
+
expect(manifest.contributions.runtime).toEqual({
|
|
38
|
+
entry: "./package",
|
|
39
|
+
host: "bot-isolate",
|
|
40
|
+
});
|
|
41
|
+
expect(manifest.tools?.map((tool) => tool.name)).toEqual([
|
|
42
|
+
"applet_list",
|
|
43
|
+
"applet_create",
|
|
44
|
+
"applet_publish",
|
|
45
|
+
"applet_revert",
|
|
46
|
+
"applet_delete",
|
|
47
|
+
"applet_focus",
|
|
48
|
+
"applet_generations",
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("it contributes no backend and no client module", () => {
|
|
53
|
+
// A `backend` Contribution runs in the kernel's own Worker and a module
|
|
54
|
+
// `client` Contribution is compiled into the hosted bundle. Neither is
|
|
55
|
+
// something a Bot can author, so either one would make this the Package a
|
|
56
|
+
// Bot could not have written.
|
|
57
|
+
expect(manifest.contributions.backend).toBeUndefined();
|
|
58
|
+
expect(manifest.contributions.desktop).toBeUndefined();
|
|
59
|
+
expect(manifest.contributions.mobile).toBeUndefined();
|
|
60
|
+
const client = manifest.contributions.client;
|
|
61
|
+
expect(client).toBeDefined();
|
|
62
|
+
expect(client && "kind" in client && client.kind).toBe("iframe");
|
|
63
|
+
expect(client && "entry" in client).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("its pages and entry are declarative, on Bot-authorable slots", () => {
|
|
67
|
+
const client = manifest.contributions.client;
|
|
68
|
+
if (!client || !("pages" in client)) throw new Error("no iframe client");
|
|
69
|
+
expect(
|
|
70
|
+
client.pages.map((page) => [page.id, page.mounts.map((m) => m.slot)]),
|
|
71
|
+
).toEqual([
|
|
72
|
+
["list", ["frockbot.surface:list"]],
|
|
73
|
+
["canvas", ["frockbot.right-panel"]],
|
|
74
|
+
]);
|
|
75
|
+
for (const page of client.pages) {
|
|
76
|
+
expect(page.artifact.mediaType).toBe("text/html");
|
|
77
|
+
expect(page.artifact.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
78
|
+
expect(page.artifact.size).toBeGreaterThan(0);
|
|
79
|
+
}
|
|
80
|
+
expect(client.entries).toEqual([
|
|
81
|
+
{
|
|
82
|
+
id: "open",
|
|
83
|
+
slot: "frockbot.sidebar-actions",
|
|
84
|
+
order: 5,
|
|
85
|
+
label: "Applets",
|
|
86
|
+
icon: "applets",
|
|
87
|
+
opens: { kind: "surface", page: "list" },
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("it declares its own durable source root, User-scoped", () => {
|
|
93
|
+
expect(manifest.roots).toEqual([{ id: "source", scope: "user" }]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the package ships no in-process entry point", () => {
|
|
97
|
+
const packageJson = JSON.parse(
|
|
98
|
+
readFileSync(
|
|
99
|
+
join(repoRoot, "packages/plugin-applets/package.json"),
|
|
100
|
+
"utf8",
|
|
101
|
+
),
|
|
102
|
+
) as { exports: Record<string, string> };
|
|
103
|
+
// `./root` is the durable-root addressing the kernel needs to reach the
|
|
104
|
+
// Applet's source, and `./manifest` is the manifest itself. Neither is a
|
|
105
|
+
// Contribution: an `./agent`, `./backend`, or `./client` export would be.
|
|
106
|
+
expect(Object.keys(packageJson.exports).toSorted()).toEqual([
|
|
107
|
+
"./manifest",
|
|
108
|
+
"./package.json",
|
|
109
|
+
"./root",
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("the foundation member is artifact-backed", () => {
|
|
115
|
+
test("the application declares an artifact for it, and the plan carries it", async () => {
|
|
116
|
+
const application = JSON.parse(
|
|
117
|
+
readFileSync(
|
|
118
|
+
join(repoRoot, "applications/foundation/frockbot.application.json"),
|
|
119
|
+
"utf8",
|
|
120
|
+
),
|
|
121
|
+
) as { packages: Array<{ specifier: string; artifact?: unknown }> };
|
|
122
|
+
const declared = application.packages.find(
|
|
123
|
+
(entry) => entry.specifier === APPLETS_SPECIFIER,
|
|
124
|
+
);
|
|
125
|
+
expect(declared).toBeDefined();
|
|
126
|
+
expect(declared!.artifact).toEqual(APPLETS_PACKAGE_ARTIFACT_V1);
|
|
127
|
+
|
|
128
|
+
const plan = await compileFoundationApplication();
|
|
129
|
+
const member = plan.packages.find(
|
|
130
|
+
(pkg) => pkg.specifier === APPLETS_SPECIFIER,
|
|
131
|
+
);
|
|
132
|
+
expect(member?.artifact).toEqual(APPLETS_PACKAGE_ARTIFACT_V1);
|
|
133
|
+
expect(member?.artifact?.mediaType).toBe("application/javascript");
|
|
134
|
+
expect(member?.artifact?.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("it is the only artifact-backed member, and it is not in either Contribution table", async () => {
|
|
138
|
+
const plan = await compileFoundationApplication();
|
|
139
|
+
const artifactBacked = plan.packages.filter(
|
|
140
|
+
(pkg) => pkg.artifact !== undefined,
|
|
141
|
+
);
|
|
142
|
+
expect(artifactBacked.map((pkg) => pkg.id)).toEqual(["applets"]);
|
|
143
|
+
|
|
144
|
+
// The tables are keyed by contribution specifier, and an artifact-backed
|
|
145
|
+
// member has none: it loads through the isolate host instead. If one ever
|
|
146
|
+
// appeared in a table, the member would mount twice.
|
|
147
|
+
const tables = [
|
|
148
|
+
readFileSync(
|
|
149
|
+
join(repoRoot, "applications/foundation/src/contributions.ts"),
|
|
150
|
+
"utf8",
|
|
151
|
+
),
|
|
152
|
+
readFileSync(
|
|
153
|
+
join(repoRoot, "applications/foundation/src/client-contributions.ts"),
|
|
154
|
+
"utf8",
|
|
155
|
+
),
|
|
156
|
+
];
|
|
157
|
+
for (const table of tables) {
|
|
158
|
+
expect(table).not.toContain(APPLETS_SPECIFIER);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
foundationBackendContributions,
|
|
6
|
+
assertFoundationBackendContributionsResolvable,
|
|
7
|
+
} from "@frockbot/application-foundation/contributions";
|
|
8
|
+
import { compileFoundationApplication } from "@frockbot/application-foundation/runtime";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `AGENTS.md`, Package composition:
|
|
12
|
+
*
|
|
13
|
+
* > Every Contribution kind is resolved from the manifest and an artifact,
|
|
14
|
+
* > never from a switch over Package identity.
|
|
15
|
+
*
|
|
16
|
+
* The application resolves a Contribution by looking the specifier its
|
|
17
|
+
* manifest declares up in one table. These checks hold the rule mechanically:
|
|
18
|
+
* no application or kernel module may compare a specifier against a Package's
|
|
19
|
+
* name, and every Contribution the application declares must reach the table
|
|
20
|
+
* (or an artifact, which loads through the isolate host instead).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
24
|
+
const read = (path: string) => readFileSync(join(repoRoot, path), "utf8");
|
|
25
|
+
|
|
26
|
+
/** The modules that own the table, and are therefore allowed to name Packages. */
|
|
27
|
+
const tableModules = new Set([
|
|
28
|
+
"applications/foundation/src/contributions.ts",
|
|
29
|
+
"applications/foundation/src/client-contributions.ts",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function resolutionModules(): string[] {
|
|
33
|
+
const files: string[] = ["apps/cloudflare/src/bot-state.ts"];
|
|
34
|
+
for (const entry of new Bun.Glob("applications/foundation/src/*.ts").scanSync(
|
|
35
|
+
repoRoot,
|
|
36
|
+
)) {
|
|
37
|
+
const path = entry.replaceAll("\\", "/");
|
|
38
|
+
const full = `applications/foundation/src/${path.split("/").pop()}`;
|
|
39
|
+
if (tableModules.has(full)) continue;
|
|
40
|
+
if (full.endsWith(".test.ts")) continue;
|
|
41
|
+
files.push(full);
|
|
42
|
+
}
|
|
43
|
+
return files.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("Contribution resolution is manifest-driven", () => {
|
|
47
|
+
test("no application or Bot Durable Object module switches on a Package specifier", () => {
|
|
48
|
+
const files = resolutionModules();
|
|
49
|
+
// The check is only worth anything if it is reading the modules that
|
|
50
|
+
// actually resolve Contributions.
|
|
51
|
+
expect(files).toContain("apps/cloudflare/src/bot-state.ts");
|
|
52
|
+
expect(files).toContain("applications/foundation/src/runtime.ts");
|
|
53
|
+
expect(files).toContain("applications/foundation/src/user.ts");
|
|
54
|
+
expect(files).toContain("applications/foundation/src/client.ts");
|
|
55
|
+
expect(files).not.toContain("applications/foundation/src/contributions.ts");
|
|
56
|
+
const offenders: string[] = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const source = read(file);
|
|
59
|
+
for (const [index, line] of source.split("\n").entries()) {
|
|
60
|
+
const at = `${file}:${index + 1}`;
|
|
61
|
+
// A comparison against a Package's own name is the switch this rule
|
|
62
|
+
// exists to forbid, in whichever shape it is written.
|
|
63
|
+
if (/\bspecifier\s*[=!]==?\s*["'`]@frockbot\//.test(line)) {
|
|
64
|
+
offenders.push(`${at}: compares a specifier against a Package name`);
|
|
65
|
+
}
|
|
66
|
+
if (/^\s*case\s+["'`]@frockbot\//.test(line)) {
|
|
67
|
+
offenders.push(`${at}: switches over a Package specifier`);
|
|
68
|
+
}
|
|
69
|
+
if (/\bswitch\s*\(\s*specifier\s*\)/.test(line)) {
|
|
70
|
+
offenders.push(`${at}: switches over a Contribution specifier`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
expect(offenders).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the kernel resolves a Contribution without naming a Package", () => {
|
|
78
|
+
const descriptors = read("packages/kernel-contracts/src/contributions.ts");
|
|
79
|
+
expect(descriptors).not.toContain("@frockbot/plugin-");
|
|
80
|
+
expect(descriptors).toContain("defineBackendContribution");
|
|
81
|
+
expect(descriptors).toContain("defineClientContribution");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("every backend Contribution the application declares resolves through the table", async () => {
|
|
85
|
+
const plan = await compileFoundationApplication();
|
|
86
|
+
const table = foundationBackendContributions();
|
|
87
|
+
const declared: string[] = [];
|
|
88
|
+
for (const pkg of plan.packages) {
|
|
89
|
+
for (const backend of pkg.manifest.contributions.backend ?? []) {
|
|
90
|
+
const specifier = `${pkg.specifier}${backend.entry.slice(1)}`;
|
|
91
|
+
declared.push(specifier);
|
|
92
|
+
// An artifact-backed member loads through the isolate host; every
|
|
93
|
+
// other member is first-party in-process and must be in the table.
|
|
94
|
+
if (pkg.artifact) {
|
|
95
|
+
expect(table.has(specifier)).toBe(false);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
expect(table.get(specifier)?.host).toBe(backend.host);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
expect(declared.length).toBeGreaterThan(0);
|
|
102
|
+
// And the compile-time assertion agrees.
|
|
103
|
+
expect(() =>
|
|
104
|
+
assertFoundationBackendContributionsResolvable(plan),
|
|
105
|
+
).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a declared Contribution that is neither in the table nor artifact-backed fails to compile", async () => {
|
|
109
|
+
const plan = await compileFoundationApplication();
|
|
110
|
+
const broken = {
|
|
111
|
+
...plan,
|
|
112
|
+
packages: plan.packages.map((pkg) =>
|
|
113
|
+
pkg.id === "admin"
|
|
114
|
+
? {
|
|
115
|
+
...pkg,
|
|
116
|
+
specifier: "@frockbot/plugin-not-in-the-table",
|
|
117
|
+
}
|
|
118
|
+
: pkg,
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
expect(() =>
|
|
122
|
+
assertFoundationBackendContributionsResolvable(broken),
|
|
123
|
+
).toThrow(
|
|
124
|
+
/neither in the application's Contribution table nor artifact-backed/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("every client Contribution the application declares exports a descriptor the table imports", async () => {
|
|
129
|
+
const plan = await compileFoundationApplication();
|
|
130
|
+
const table = read("applications/foundation/src/client-contributions.ts");
|
|
131
|
+
let checked = 0;
|
|
132
|
+
for (const pkg of plan.packages) {
|
|
133
|
+
const client = pkg.manifest.contributions.client;
|
|
134
|
+
if (!client || !("entry" in client)) continue;
|
|
135
|
+
const specifier = `${pkg.specifier}${client.entry.slice(1)}`;
|
|
136
|
+
const entryPath = packageEntryPath(pkg.specifier, client.entry);
|
|
137
|
+
const source = read(entryPath);
|
|
138
|
+
expect(
|
|
139
|
+
source.includes(`defineClientContribution`) &&
|
|
140
|
+
source.includes(JSON.stringify(specifier)),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
// The table imports the descriptor: by specifier, or (for the Computer
|
|
143
|
+
// Package, whose client entry the application reaches by path) by file.
|
|
144
|
+
expect(
|
|
145
|
+
table.includes(`"${specifier}"`) ||
|
|
146
|
+
table.includes(entryPath.replace("packages/", "").replace(".ts", "")),
|
|
147
|
+
).toBe(true);
|
|
148
|
+
checked += 1;
|
|
149
|
+
}
|
|
150
|
+
expect(checked).toBeGreaterThan(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** Where a Package's export subpath resolves on disk. */
|
|
155
|
+
function packageEntryPath(specifier: string, entry: string): string {
|
|
156
|
+
const name = specifier.split("/").slice(0, 2).join("/");
|
|
157
|
+
const directory = `packages/${name.split("/")[1]}`;
|
|
158
|
+
const manifest = JSON.parse(read(`${directory}/package.json`)) as {
|
|
159
|
+
exports?: Record<string, string>;
|
|
160
|
+
};
|
|
161
|
+
const target = manifest.exports?.[entry];
|
|
162
|
+
if (!target) throw new Error(`${name} does not export "${entry}"`);
|
|
163
|
+
return `${directory}/${target.replace("./", "")}`;
|
|
164
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
|
|
4
5
|
import { authoredManifestV1 } from "@frockbot/plugin-authoring/shared";
|
|
5
6
|
|
|
6
7
|
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
@@ -16,24 +17,30 @@ describe("non-first-party Package UI boundaries", () => {
|
|
|
16
17
|
{ name: "weather_lookup", description: "Weather", inputSchema: {} },
|
|
17
18
|
],
|
|
18
19
|
ui: {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
pages: [
|
|
21
|
+
{
|
|
22
|
+
id: "main",
|
|
23
|
+
artifact: {
|
|
24
|
+
contentHash: "a".repeat(64),
|
|
25
|
+
size: 1,
|
|
26
|
+
mediaType: "text/html",
|
|
27
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
28
|
+
},
|
|
29
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
26
32
|
},
|
|
27
33
|
});
|
|
34
|
+
// The synthesized manifest crosses the kernel's own strict decoder.
|
|
35
|
+
expect(decodeFrockBotManifest(manifest).schemaVersion).toBe(5);
|
|
28
36
|
const client = (manifest.contributions as Record<string, unknown>)
|
|
29
37
|
.client as Record<string, unknown>;
|
|
30
38
|
expect(client.kind).toBe("iframe");
|
|
31
39
|
expect(client).not.toHaveProperty("entry");
|
|
40
|
+
expect(client).not.toHaveProperty("artifact");
|
|
32
41
|
const host = read("packages/plugin-shell/src/client/PackageIframeHost.vue");
|
|
33
42
|
expect(host).toContain("<iframe");
|
|
34
|
-
expect(host).toContain(
|
|
35
|
-
"/packages/${props.contribution.artifact.contentHash}.html",
|
|
36
|
-
);
|
|
43
|
+
expect(host).toContain("/packages/${props.page.artifact.contentHash}.html");
|
|
37
44
|
});
|
|
38
45
|
|
|
39
46
|
test("a page cannot read the app's cookies or storage", () => {
|
|
@@ -332,6 +332,7 @@ describe("Turn boundaries", () => {
|
|
|
332
332
|
"skills/own-bot/SKILL.md",
|
|
333
333
|
"skills/own-user/SKILL.md",
|
|
334
334
|
"managed/add-connector/SKILL.md",
|
|
335
|
+
"managed/applets/SKILL.md",
|
|
335
336
|
"managed/export-bot-template/SKILL.md",
|
|
336
337
|
"managed/import-bot-template/SKILL.md",
|
|
337
338
|
"managed/learn-from-demonstration/SKILL.md",
|