@frockbot/architecture-checks 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.
- package/package.json +33 -6
- package/src/computer-host-boundaries.test.ts +125 -0
- package/src/desktop-provider-boundaries.test.ts +157 -0
- package/src/kernel-boundaries.test.ts +139 -0
- package/src/memory-boundaries.test.ts +208 -0
- package/src/model-interface.test.ts +175 -0
- package/src/package-authority-boundaries.test.ts +292 -0
- package/src/skill-invocation.test.ts +184 -0
- package/src/turn-boundaries.test.ts +353 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Constitution — Architecture checks: "two provider Packages satisfy the model
|
|
2
|
+
// interface with no kernel diff." Both providers are mounted into the *same*
|
|
3
|
+
// kernel `ModelInvocation` surface, by identical kernel code, and both stream
|
|
4
|
+
// through it. Nothing kernel-side changes between the two mounts; the only
|
|
5
|
+
// difference is which Package was plugged in.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
openCredentialV1,
|
|
9
|
+
parseCredentialKeyringV1,
|
|
10
|
+
sealCredentialV1,
|
|
11
|
+
type CredentialLeaseV1,
|
|
12
|
+
} from "@frockbot/connection-core";
|
|
13
|
+
import type {
|
|
14
|
+
LlmStreamEvent,
|
|
15
|
+
ModelInvocation,
|
|
16
|
+
NormalizedModelRequest,
|
|
17
|
+
} from "@frockbot/kernel-contracts";
|
|
18
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
19
|
+
import foundationProviderPlugin from "@frockbot/plugin-provider-foundation/runtime";
|
|
20
|
+
import { createOllamaCloudRuntimePlugin } from "@frockbot/plugin-provider-ollama-cloud/runtime";
|
|
21
|
+
import { Context, Service, type Plugin } from "cordis";
|
|
22
|
+
|
|
23
|
+
const KEYRING = JSON.stringify({
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
currentKeyId: "primary",
|
|
26
|
+
keys: { primary: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
class TestCredentialLease extends Service {
|
|
30
|
+
constructor(ctx: Context) {
|
|
31
|
+
super(ctx, "credentialLease");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
open(input: {
|
|
35
|
+
accountId: string;
|
|
36
|
+
connectionId: string;
|
|
37
|
+
packageId: string;
|
|
38
|
+
lease: CredentialLeaseV1;
|
|
39
|
+
}): Promise<string> {
|
|
40
|
+
return openCredentialV1({
|
|
41
|
+
keyring: parseCredentialKeyringV1(KEYRING),
|
|
42
|
+
context: {
|
|
43
|
+
accountId: input.accountId,
|
|
44
|
+
connectionId: input.connectionId,
|
|
45
|
+
packageId: input.packageId,
|
|
46
|
+
credentialGeneration: input.lease.credentialGeneration,
|
|
47
|
+
},
|
|
48
|
+
envelope: input.lease.envelope,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The kernel half, written once. It mounts the kernel's `ModelInvocation`
|
|
55
|
+
* implementation, plugs in whatever provider Package it is handed, and streams.
|
|
56
|
+
* A second provider must require no edit here — that is the check.
|
|
57
|
+
*/
|
|
58
|
+
async function streamThroughTheKernel(
|
|
59
|
+
providerPlugin: Plugin.Function,
|
|
60
|
+
request: NormalizedModelRequest,
|
|
61
|
+
extra?: Plugin.Function,
|
|
62
|
+
): Promise<{ events: LlmStreamEvent[]; invocation: ModelInvocation }> {
|
|
63
|
+
const root = new Context();
|
|
64
|
+
await root.plugin(LlmRegistry);
|
|
65
|
+
if (extra) await root.plugin(extra);
|
|
66
|
+
await root.plugin(providerPlugin);
|
|
67
|
+
const invocation: ModelInvocation = root.llm;
|
|
68
|
+
const events: LlmStreamEvent[] = [];
|
|
69
|
+
for await (const event of invocation.stream(
|
|
70
|
+
request,
|
|
71
|
+
new AbortController().signal,
|
|
72
|
+
)) {
|
|
73
|
+
events.push(event);
|
|
74
|
+
}
|
|
75
|
+
await root.fiber.dispose();
|
|
76
|
+
return { events, invocation };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const message = { role: "user" as const, content: "hello" };
|
|
80
|
+
|
|
81
|
+
describe("model interface", () => {
|
|
82
|
+
test("two provider Packages satisfy the model interface with no kernel diff", async () => {
|
|
83
|
+
const foundation = await streamThroughTheKernel(foundationProviderPlugin, {
|
|
84
|
+
requestId: "foundation-1",
|
|
85
|
+
provider: "foundation",
|
|
86
|
+
model: "deterministic-v1",
|
|
87
|
+
system: "",
|
|
88
|
+
messages: [message],
|
|
89
|
+
tools: [],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const envelope = await sealCredentialV1({
|
|
93
|
+
keyring: parseCredentialKeyringV1(KEYRING),
|
|
94
|
+
context: {
|
|
95
|
+
accountId: "account-1",
|
|
96
|
+
connectionId: "connection-1",
|
|
97
|
+
packageId: "provider-ollama-cloud",
|
|
98
|
+
credentialGeneration: "generation-1",
|
|
99
|
+
},
|
|
100
|
+
plaintext: "account-secret",
|
|
101
|
+
});
|
|
102
|
+
const credentialLease: Plugin.Function = (ctx) => {
|
|
103
|
+
new TestCredentialLease(ctx);
|
|
104
|
+
};
|
|
105
|
+
const ollama = await streamThroughTheKernel(
|
|
106
|
+
createOllamaCloudRuntimePlugin({
|
|
107
|
+
accountId: "account-1",
|
|
108
|
+
connectionId: "connection-1",
|
|
109
|
+
packageId: "provider-ollama-cloud",
|
|
110
|
+
leaseCredential: (effectId) =>
|
|
111
|
+
Promise.resolve({
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
leaseId: "lease-1",
|
|
114
|
+
effectId,
|
|
115
|
+
connectionId: "connection-1",
|
|
116
|
+
credentialGeneration: "generation-1",
|
|
117
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
118
|
+
envelope,
|
|
119
|
+
}),
|
|
120
|
+
settleCredential: () => Promise.resolve(),
|
|
121
|
+
fetch: () =>
|
|
122
|
+
Promise.resolve(
|
|
123
|
+
new Response(
|
|
124
|
+
'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n' +
|
|
125
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
|
|
126
|
+
"data: [DONE]\n\n",
|
|
127
|
+
{
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: { "content-type": "text/event-stream" },
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
}),
|
|
134
|
+
{
|
|
135
|
+
requestId: "ollama-1",
|
|
136
|
+
provider: "ollama-cloud",
|
|
137
|
+
model: "glm-5.3-flash:cloud",
|
|
138
|
+
system: "",
|
|
139
|
+
messages: [message],
|
|
140
|
+
tools: [],
|
|
141
|
+
modelBinding: {
|
|
142
|
+
connectionId: "connection-1",
|
|
143
|
+
connectionGeneration: "generation-1",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
credentialLease,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Both answer the same kernel interface with the same event vocabulary.
|
|
150
|
+
expect(foundation.events.at(-1)).toEqual({
|
|
151
|
+
type: "finish",
|
|
152
|
+
reason: "completed",
|
|
153
|
+
});
|
|
154
|
+
expect(ollama.events.at(-1)).toEqual({
|
|
155
|
+
type: "finish",
|
|
156
|
+
reason: "completed",
|
|
157
|
+
});
|
|
158
|
+
expect(
|
|
159
|
+
foundation.events
|
|
160
|
+
.filter((event) => event.type === "text-delta")
|
|
161
|
+
.map((event) => event.text)
|
|
162
|
+
.join(""),
|
|
163
|
+
).toBe("Cordis runtime: hello");
|
|
164
|
+
expect(
|
|
165
|
+
ollama.events
|
|
166
|
+
.filter((event) => event.type === "text-delta")
|
|
167
|
+
.map((event) => event.text)
|
|
168
|
+
.join(""),
|
|
169
|
+
).toBe("hello");
|
|
170
|
+
for (const invocation of [foundation.invocation, ollama.invocation]) {
|
|
171
|
+
expect(typeof invocation.stream).toBe("function");
|
|
172
|
+
expect(typeof invocation.reconcile).toBe("function");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Constitutional checks over who may declare the strongest execution hosts.
|
|
2
|
+
//
|
|
3
|
+
// Constitution — Package composition: "Every Package whose recorded provenance
|
|
4
|
+
// is not first-party executes in a Dynamic Worker isolate … First-party
|
|
5
|
+
// Packages may run in the kernel's isolate only when reviewed and shipped with
|
|
6
|
+
// FrockBot." `trusted-main` is the widest host FrockBot has — the Electron main
|
|
7
|
+
// process, outside every sandbox — so a manifest asking for it is asking for
|
|
8
|
+
// first-party trust. These are source-graph and synthesis facts, so each rule
|
|
9
|
+
// is one named test `docs/architecture-checks.md` can point at.
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
AUTHOR_PACKAGE_INPUT_SCHEMA_V1,
|
|
15
|
+
authoredManifestV1,
|
|
16
|
+
decodeAuthorPackageInputV1,
|
|
17
|
+
} from "@frockbot/plugin-authoring/shared";
|
|
18
|
+
|
|
19
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
20
|
+
|
|
21
|
+
function read(path: string): string {
|
|
22
|
+
return readFileSync(join(repoRoot, path), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ScannedManifest {
|
|
26
|
+
/** Repo-relative path of the `frockbot.json`. */
|
|
27
|
+
path: string;
|
|
28
|
+
manifest: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every Package manifest this tree holds, wherever it sits. */
|
|
32
|
+
function scanManifests(): ScannedManifest[] {
|
|
33
|
+
return [...new Bun.Glob("**/frockbot.json").scanSync({ cwd: repoRoot })]
|
|
34
|
+
.filter((path) => !path.includes("node_modules/"))
|
|
35
|
+
.filter((path) => !path.includes("/dist/"))
|
|
36
|
+
.sort()
|
|
37
|
+
.map((path) => ({
|
|
38
|
+
path,
|
|
39
|
+
manifest: JSON.parse(read(path)) as Record<string, unknown>,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function desktopContribution(
|
|
44
|
+
manifest: Record<string, unknown>,
|
|
45
|
+
): Record<string, unknown> | undefined {
|
|
46
|
+
const contributions = manifest.contributions;
|
|
47
|
+
if (!contributions || typeof contributions !== "object") return undefined;
|
|
48
|
+
const desktop = (contributions as Record<string, unknown>).desktop;
|
|
49
|
+
if (!desktop || typeof desktop !== "object") return undefined;
|
|
50
|
+
return desktop as Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The rule, as a function of what was scanned, so the tests below can stage a
|
|
55
|
+
* violation without writing one into this tree: a manifest declaring a
|
|
56
|
+
* `trusted-main` Contribution is a first-party workspace Package under
|
|
57
|
+
* `packages/`, published to the workspace under the `@frockbot/` scope.
|
|
58
|
+
*/
|
|
59
|
+
function trustedMainOffenders(
|
|
60
|
+
manifests: readonly ScannedManifest[],
|
|
61
|
+
packageName: (manifestPath: string) => string | undefined,
|
|
62
|
+
): string[] {
|
|
63
|
+
const offenders: string[] = [];
|
|
64
|
+
for (const { path, manifest } of manifests) {
|
|
65
|
+
if (desktopContribution(manifest)?.execution !== "trusted-main") continue;
|
|
66
|
+
if (!/^packages\/[^/]+\/frockbot\.json$/.test(path)) {
|
|
67
|
+
offenders.push(`${path}: trusted-main outside packages/`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const name = packageName(path);
|
|
71
|
+
if (!name?.startsWith("@frockbot/")) {
|
|
72
|
+
offenders.push(`${path}: trusted-main in a package named ${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return offenders;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function workspacePackageName(manifestPath: string): string | undefined {
|
|
79
|
+
const declared = JSON.parse(
|
|
80
|
+
read(join(dirname(manifestPath), "package.json")),
|
|
81
|
+
);
|
|
82
|
+
return typeof declared?.name === "string" ? declared.name : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `@frockbot/plugin-fly-sprite` + `./host` → `@frockbot/plugin-fly-sprite/host`. */
|
|
86
|
+
function contributionSpecifier(scanned: ScannedManifest): string {
|
|
87
|
+
const entry = String(desktopContribution(scanned.manifest)?.entry ?? "");
|
|
88
|
+
return `${workspacePackageName(scanned.path)}${entry.slice(1)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("package authority boundaries", () => {
|
|
92
|
+
// Constitution — Package composition: "First-party Packages may run in the
|
|
93
|
+
// kernel's isolate only when reviewed and shipped with FrockBot."
|
|
94
|
+
test("a trusted-main Contribution is declared only by a first-party workspace Package", () => {
|
|
95
|
+
const manifests = scanManifests();
|
|
96
|
+
// The scan really does reach manifests outside `packages/` — nothing here
|
|
97
|
+
// narrows the search to the answer it wants.
|
|
98
|
+
expect(manifests.length).toBeGreaterThan(0);
|
|
99
|
+
expect(
|
|
100
|
+
new Bun.Glob("**/frockbot.json").scanSync({ cwd: repoRoot }).next().done,
|
|
101
|
+
).toBe(false);
|
|
102
|
+
expect(trustedMainOffenders(manifests, workspacePackageName)).toEqual([]);
|
|
103
|
+
|
|
104
|
+
// …and the root workspace really does claim `packages/*`, so "under
|
|
105
|
+
// packages/" means "a workspace member", not merely "in a directory".
|
|
106
|
+
const workspaces = JSON.parse(read("package.json")).workspaces;
|
|
107
|
+
expect(workspaces).toContain("packages/*");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the check refuses a trusted-main manifest that is not a first-party workspace Package", () => {
|
|
111
|
+
const desktop = {
|
|
112
|
+
contributions: {
|
|
113
|
+
desktop: { entry: "./desktop", execution: "trusted-main" },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const offenders = trustedMainOffenders(
|
|
117
|
+
[
|
|
118
|
+
{ path: "apps/cloudflare/frockbot.json", manifest: desktop },
|
|
119
|
+
{ path: "vendor/downloaded/frockbot.json", manifest: desktop },
|
|
120
|
+
{ path: "packages/third-party/frockbot.json", manifest: desktop },
|
|
121
|
+
{
|
|
122
|
+
path: "packages/plugin-ok/frockbot.json",
|
|
123
|
+
manifest: {
|
|
124
|
+
contributions: {
|
|
125
|
+
desktop: { entry: "./desktop", execution: "sandboxed-renderer" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
(path) =>
|
|
131
|
+
path === "packages/third-party/frockbot.json"
|
|
132
|
+
? "@acme/third-party"
|
|
133
|
+
: "@frockbot/plugin-ok",
|
|
134
|
+
);
|
|
135
|
+
expect(offenders).toEqual([
|
|
136
|
+
"apps/cloudflare/frockbot.json: trusted-main outside packages/",
|
|
137
|
+
"vendor/downloaded/frockbot.json: trusted-main outside packages/",
|
|
138
|
+
"packages/third-party/frockbot.json: trusted-main in a package named @acme/third-party",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Constitution — Explicit seams: "any other host must be declared in the
|
|
143
|
+
// manifest and remains non-authoritative". The manifest is a declaration, not
|
|
144
|
+
// a grant: Electron main runs a `trusted-main` Contribution only from the map
|
|
145
|
+
// of plugins the shipped application imported statically, so a Package that
|
|
146
|
+
// arrives by any other path — installed, published, Bot-authored — has
|
|
147
|
+
// nowhere to land however its manifest is written.
|
|
148
|
+
test("Electron main loads a trusted-main Contribution only from its statically imported first-party map", () => {
|
|
149
|
+
const source = read("applications/foundation/src/desktop.ts");
|
|
150
|
+
const mapped = [...source.matchAll(/\["(@frockbot\/[^"]+)",\s*\w+\]/g)].map(
|
|
151
|
+
(match) => match[1],
|
|
152
|
+
);
|
|
153
|
+
const imported = new Set(
|
|
154
|
+
[...source.matchAll(/^import\s[^"]*"([^"]+)";$/gm)].map(
|
|
155
|
+
(match) => match[1],
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const declared = scanManifests()
|
|
160
|
+
.filter(
|
|
161
|
+
(scanned) =>
|
|
162
|
+
desktopContribution(scanned.manifest)?.execution === "trusted-main",
|
|
163
|
+
)
|
|
164
|
+
.map(contributionSpecifier)
|
|
165
|
+
.sort();
|
|
166
|
+
|
|
167
|
+
expect(declared.length).toBeGreaterThan(0);
|
|
168
|
+
expect(mapped.slice().sort()).toEqual(declared);
|
|
169
|
+
for (const specifier of declared) {
|
|
170
|
+
expect({ specifier, imported: imported.has(specifier) }).toEqual({
|
|
171
|
+
specifier,
|
|
172
|
+
imported: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// The map is reached only after the plan declares the Package and its
|
|
176
|
+
// manifest says `trusted-main`; an unknown specifier throws rather than
|
|
177
|
+
// loading.
|
|
178
|
+
expect(source).toContain('contribution.execution !== "trusted-main"');
|
|
179
|
+
expect(source).toContain("unknown foundation desktop contribution");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Constitution — Self-modification: "Self-modification never widens
|
|
183
|
+
// authority." The manifest of a Bot-authored Package is synthesized, never
|
|
184
|
+
// authored, so a Bot cannot declare a host the kernel did not offer it.
|
|
185
|
+
test("a Bot-authored manifest declares only Bot isolate Contributions, never a desktop or trusted-main one", () => {
|
|
186
|
+
const base = {
|
|
187
|
+
packageId: "bot-tool",
|
|
188
|
+
displayName: "Bot Tool",
|
|
189
|
+
version: "0.0.1",
|
|
190
|
+
tool: { name: "do_it", description: "does it", inputSchema: {} },
|
|
191
|
+
};
|
|
192
|
+
for (const manifest of [
|
|
193
|
+
authoredManifestV1(base),
|
|
194
|
+
authoredManifestV1({
|
|
195
|
+
...base,
|
|
196
|
+
model: { providerId: "foundation", modelId: "small" },
|
|
197
|
+
}),
|
|
198
|
+
]) {
|
|
199
|
+
const contributions = manifest.contributions as Record<string, unknown>;
|
|
200
|
+
expect(Object.keys(contributions).sort()).toEqual(
|
|
201
|
+
expect.arrayContaining(["runtime"]),
|
|
202
|
+
);
|
|
203
|
+
for (const [kind, contribution] of Object.entries(contributions)) {
|
|
204
|
+
expect({ kind, host: (contribution as { host: string }).host }).toEqual(
|
|
205
|
+
{
|
|
206
|
+
kind,
|
|
207
|
+
host: "bot-isolate",
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
expect(
|
|
212
|
+
Object.keys(contributions).every((kind) =>
|
|
213
|
+
["runtime", "model"].includes(kind),
|
|
214
|
+
),
|
|
215
|
+
).toBe(true);
|
|
216
|
+
const serialized = JSON.stringify(manifest);
|
|
217
|
+
expect(serialized).not.toContain("trusted-main");
|
|
218
|
+
expect(serialized).not.toContain("desktop");
|
|
219
|
+
expect(serialized).not.toContain("sandboxed-renderer");
|
|
220
|
+
expect(manifest.permissions).toEqual([]);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// …and the tool the model sees offers no field to smuggle one in: the input
|
|
225
|
+
// is exact, so a Contribution, host, or permission key is refused at the seam.
|
|
226
|
+
test("the package_author input carries no Contribution, host, or permission field", () => {
|
|
227
|
+
expect(AUTHOR_PACKAGE_INPUT_SCHEMA_V1.additionalProperties).toBe(false);
|
|
228
|
+
expect(
|
|
229
|
+
Object.keys(AUTHOR_PACKAGE_INPUT_SCHEMA_V1.properties).sort(),
|
|
230
|
+
).toEqual(["displayName", "model", "packageId", "source", "tool"]);
|
|
231
|
+
const valid = {
|
|
232
|
+
packageId: "bot-tool",
|
|
233
|
+
displayName: "Bot Tool",
|
|
234
|
+
tool: { name: "do_it", description: "does it", inputSchema: {} },
|
|
235
|
+
source: "export const tools = [];\n",
|
|
236
|
+
};
|
|
237
|
+
for (const smuggled of [
|
|
238
|
+
{
|
|
239
|
+
contributions: { desktop: { entry: "./d", execution: "trusted-main" } },
|
|
240
|
+
},
|
|
241
|
+
{ host: "trusted-main" },
|
|
242
|
+
{ permissions: ["desktop:clipboard:read"] },
|
|
243
|
+
]) {
|
|
244
|
+
expect(() =>
|
|
245
|
+
decodeAuthorPackageInputV1({ ...valid, ...smuggled }),
|
|
246
|
+
).toThrow();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Constitution — Self-modification: "A Bot may author or change anything
|
|
251
|
+
// above the kernel *for itself*." The behavioural half of this rule is
|
|
252
|
+
// `packages/plugin-shell/src/backend-authoring.test.ts`; this is the
|
|
253
|
+
// source-level pin that the guard exists and is a refusal, not a warning.
|
|
254
|
+
test("the authoring backend refuses a packageId a non-Bot member already holds", () => {
|
|
255
|
+
const source = read("packages/plugin-shell/src/backend-authoring.ts");
|
|
256
|
+
expect(shadowGuardFindings(source)).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("the shadow-guard check refuses a backend that drops or softens the guard", () => {
|
|
260
|
+
const guarded = read("packages/plugin-shell/src/backend-authoring.ts");
|
|
261
|
+
expect(
|
|
262
|
+
shadowGuardFindings(
|
|
263
|
+
guarded.replaceAll('provenance.kind !== "bot"', "false"),
|
|
264
|
+
),
|
|
265
|
+
).toEqual([
|
|
266
|
+
"no non-Bot provenance comparison over the Composition members",
|
|
267
|
+
]);
|
|
268
|
+
expect(
|
|
269
|
+
shadowGuardFindings(
|
|
270
|
+
guarded.replaceAll("member.packageId === packageId &&", "false &&"),
|
|
271
|
+
),
|
|
272
|
+
).toEqual(["the comparison is not keyed by packageId"]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The shadowing rule as a function of the backend's source: the Composition's
|
|
278
|
+
* members are compared by `packageId`, and a member whose provenance is not the
|
|
279
|
+
* Bot's own is refused.
|
|
280
|
+
*/
|
|
281
|
+
function shadowGuardFindings(source: string): string[] {
|
|
282
|
+
const findings: string[] = [];
|
|
283
|
+
if (!source.includes('provenance.kind !== "bot"')) {
|
|
284
|
+
findings.push(
|
|
285
|
+
"no non-Bot provenance comparison over the Composition members",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (!/member\.packageId === packageId/.test(source)) {
|
|
289
|
+
findings.push("the comparison is not keyed by packageId");
|
|
290
|
+
}
|
|
291
|
+
return findings;
|
|
292
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Invocation, end to end through the Agent loop.
|
|
2
|
+
//
|
|
3
|
+
// GrokBot's users invoke a Skill with `/` or `@`
|
|
4
|
+
// (`docs/research/grokbot-computer.md` §2.8, register row 22). Invoking is not
|
|
5
|
+
// mentioning: an invoked Skill's body is expanded into the Turn's first step,
|
|
6
|
+
// while every other Skill stays a catalog line the Bot may read on demand.
|
|
7
|
+
// This proves both halves against a fake provider, and proves the third: an
|
|
8
|
+
// unknown ref fails the command visibly instead of being dropped.
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { AgentLoop } from "@frockbot/kernel-agent-loop";
|
|
11
|
+
import { AgentRegistry } from "@frockbot/kernel-agent-loop/agent";
|
|
12
|
+
import {
|
|
13
|
+
SessionStore,
|
|
14
|
+
type LlmProvider,
|
|
15
|
+
type NormalizedModelRequest,
|
|
16
|
+
type SkillRefV1,
|
|
17
|
+
} from "@frockbot/kernel-contracts";
|
|
18
|
+
import {
|
|
19
|
+
botInstructionRootV1,
|
|
20
|
+
createSkillsRuntimePlugin,
|
|
21
|
+
} from "@frockbot/plugin-skills";
|
|
22
|
+
import { FakeWorkspace, skillMarkdown } from "@frockbot/plugin-skills/testing";
|
|
23
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
24
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
25
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
26
|
+
import { Context, type Plugin } from "cordis";
|
|
27
|
+
|
|
28
|
+
const COMPOSITION = {
|
|
29
|
+
generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
|
|
30
|
+
artifactSetHash: "a".repeat(64),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const OWNER = { userId: "user-1", botId: "bot-1" };
|
|
34
|
+
const WRITER = {
|
|
35
|
+
kind: "bot" as const,
|
|
36
|
+
botId: "bot-1",
|
|
37
|
+
sessionId: "user-1:bot-1",
|
|
38
|
+
turnId: "turn-1",
|
|
39
|
+
runId: "run-1",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
async function turnWith(skills?: SkillRefV1[]) {
|
|
43
|
+
const root = botInstructionRootV1(OWNER);
|
|
44
|
+
const workspace = await FakeWorkspace.seeded([
|
|
45
|
+
{
|
|
46
|
+
root,
|
|
47
|
+
path: "skills/daily-standup/SKILL.md",
|
|
48
|
+
text: skillMarkdown(
|
|
49
|
+
"Daily standup",
|
|
50
|
+
"Use this when assembling the weekday standup.",
|
|
51
|
+
"INVOKED-STANDUP-BODY",
|
|
52
|
+
),
|
|
53
|
+
writer: WRITER,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
root,
|
|
57
|
+
path: "skills/weekly-report/SKILL.md",
|
|
58
|
+
text: skillMarkdown(
|
|
59
|
+
"Weekly report",
|
|
60
|
+
"Use this when writing the weekly report.",
|
|
61
|
+
"UNINVOKED-REPORT-BODY",
|
|
62
|
+
),
|
|
63
|
+
writer: WRITER,
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const requests: NormalizedModelRequest[] = [];
|
|
68
|
+
const model: LlmProvider = {
|
|
69
|
+
id: "skill-invoker",
|
|
70
|
+
async *stream(request) {
|
|
71
|
+
requests.push(request);
|
|
72
|
+
yield { type: "text-delta", text: "done" };
|
|
73
|
+
yield { type: "finish", reason: "completed" };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const context = new Context();
|
|
78
|
+
await context.plugin(SessionStore, {});
|
|
79
|
+
await context.plugin(SystemPromptRegistry);
|
|
80
|
+
await context.plugin(LlmRegistry);
|
|
81
|
+
await context.plugin(ToolRegistry);
|
|
82
|
+
await context.plugin(AgentRegistry);
|
|
83
|
+
const providerPlugin: Plugin.Function = (ctx) => ctx.llm.register(model);
|
|
84
|
+
providerPlugin.inject = ["llm"];
|
|
85
|
+
await context.plugin(providerPlugin);
|
|
86
|
+
await context.plugin(
|
|
87
|
+
createSkillsRuntimePlugin({ owner: OWNER, reads: workspace }),
|
|
88
|
+
);
|
|
89
|
+
await context.plugin(AgentLoop, { maxSteps: 2, composition: COMPOSITION });
|
|
90
|
+
|
|
91
|
+
const handle = await context.agents.create({
|
|
92
|
+
botId: OWNER.botId,
|
|
93
|
+
sessionId: "user-1:bot-1",
|
|
94
|
+
provider: model.id,
|
|
95
|
+
model: "test-model",
|
|
96
|
+
admitEffect: () => Promise.resolve(true),
|
|
97
|
+
});
|
|
98
|
+
handle.agent.send({
|
|
99
|
+
text: "Run the standup.",
|
|
100
|
+
...(skills ? { skills } : {}),
|
|
101
|
+
});
|
|
102
|
+
await handle.agent.whenIdle();
|
|
103
|
+
const events = [...handle.agent.session.events];
|
|
104
|
+
await context.fiber.dispose();
|
|
105
|
+
return { requests, events };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("invoking a Skill from the composer", () => {
|
|
109
|
+
test("expands the invoked body into the Turn's first step, and no other", async () => {
|
|
110
|
+
const { requests } = await turnWith([
|
|
111
|
+
{ schemaVersion: 1, source: "bot", slug: "daily-standup" },
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const first = requests.at(0)?.system ?? "";
|
|
115
|
+
expect(first).toContain("<invoked_skills>");
|
|
116
|
+
expect(first).toContain("INVOKED-STANDUP-BODY");
|
|
117
|
+
// The un-invoked Skill is a catalog line and nothing more: mentioning a
|
|
118
|
+
// Skill is not running it.
|
|
119
|
+
expect(first).toContain("skills/weekly-report/SKILL.md");
|
|
120
|
+
expect(first).not.toContain("UNINVOKED-REPORT-BODY");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("expands nothing when nothing was invoked", async () => {
|
|
124
|
+
const { requests } = await turnWith();
|
|
125
|
+
const first = requests.at(0)?.system ?? "";
|
|
126
|
+
expect(first).toContain("<agent_skills>");
|
|
127
|
+
expect(first).not.toContain("<invoked_skills>");
|
|
128
|
+
expect(first).not.toContain("INVOKED-STANDUP-BODY");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("records the ref, the generation and the content hash it resolved", async () => {
|
|
132
|
+
const { events } = await turnWith([
|
|
133
|
+
{ schemaVersion: 1, source: "bot", slug: "daily-standup" },
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const queued = events.find((event) => event.type === "input/queued");
|
|
137
|
+
expect(queued).toMatchObject({
|
|
138
|
+
skills: [{ schemaVersion: 1, source: "bot", slug: "daily-standup" }],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const invoked = events.find((event) => event.type === "skill/invoked");
|
|
142
|
+
if (invoked?.type !== "skill/invoked") throw new Error("no skill/invoked");
|
|
143
|
+
expect(invoked.turn).toBe(1);
|
|
144
|
+
expect(invoked.ref).toEqual({
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
source: "bot",
|
|
147
|
+
slug: "daily-standup",
|
|
148
|
+
});
|
|
149
|
+
// The exact generation the Turn ran on, so the prompt is reconstructable.
|
|
150
|
+
const injected = events.find((event) => event.type === "skill/injected");
|
|
151
|
+
if (injected?.type !== "skill/injected") throw new Error("no injection");
|
|
152
|
+
const listed = injected.skills.find(
|
|
153
|
+
(skill) => skill.path === "skills/daily-standup/SKILL.md",
|
|
154
|
+
);
|
|
155
|
+
expect(invoked.generationId).toBe(listed!.generationId);
|
|
156
|
+
expect(invoked.contentHash).toBe(listed!.contentHash);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("fails the command with a reason when the ref names no Skill", async () => {
|
|
160
|
+
const { requests, events } = await turnWith([
|
|
161
|
+
{ schemaVersion: 1, source: "bot", slug: "no-such-skill" },
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// Never silently dropped: the Turn does not reach the model at all.
|
|
165
|
+
expect(requests).toHaveLength(0);
|
|
166
|
+
const ended = events.find((event) => event.type === "turn/end");
|
|
167
|
+
if (ended?.type !== "turn/end") throw new Error("no turn/end");
|
|
168
|
+
expect(ended.outcome).toBe("blocked");
|
|
169
|
+
expect(ended.reason).toContain("bot/no-such-skill");
|
|
170
|
+
expect(events.some((event) => event.type === "skill/invoked")).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("fails a ref whose source no Bot catalog can serve yet", async () => {
|
|
174
|
+
// K1 and K2 add `user`, `managed` and `plugin`. Until then the codec
|
|
175
|
+
// admits the value and the resolver refuses it, visibly.
|
|
176
|
+
const { events } = await turnWith([
|
|
177
|
+
{ schemaVersion: 1, source: "user", slug: "daily-standup" },
|
|
178
|
+
]);
|
|
179
|
+
const ended = events.find((event) => event.type === "turn/end");
|
|
180
|
+
if (ended?.type !== "turn/end") throw new Error("no turn/end");
|
|
181
|
+
expect(ended.outcome).toBe("blocked");
|
|
182
|
+
expect(ended.reason).toContain("user/daily-standup");
|
|
183
|
+
});
|
|
184
|
+
});
|