@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,353 @@
|
|
|
1
|
+
// Constitution — Architecture checks: "a Turn that does not use the Computer
|
|
2
|
+
// makes no Computer interface call" and "a Skill written outside the Bot's own
|
|
3
|
+
// authority is not loaded as an instruction".
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
ComputerRegistry,
|
|
7
|
+
type ComputerProvider,
|
|
8
|
+
} from "@frockbot/computer-core";
|
|
9
|
+
import { AgentRegistry } from "@frockbot/kernel-agent-loop/agent";
|
|
10
|
+
import { AgentLoop } from "@frockbot/kernel-agent-loop";
|
|
11
|
+
import {
|
|
12
|
+
SessionStore,
|
|
13
|
+
type LlmProvider,
|
|
14
|
+
type NormalizedModelRequest,
|
|
15
|
+
type ToolDefinition,
|
|
16
|
+
} from "@frockbot/kernel-contracts";
|
|
17
|
+
import { createComputerAgentPlugin } from "@frockbot/plugin-computer/agent";
|
|
18
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
19
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
20
|
+
import {
|
|
21
|
+
botInstructionRootV1,
|
|
22
|
+
createSkillsRuntimePlugin,
|
|
23
|
+
} from "@frockbot/plugin-skills";
|
|
24
|
+
import { FakeWorkspace, skillMarkdown } from "@frockbot/plugin-skills/testing";
|
|
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
|
+
describe("Turn boundaries", () => {
|
|
34
|
+
test("a Turn that does not use the Computer makes no Computer interface call", async () => {
|
|
35
|
+
// Every entry point of the provider-neutral Computer interface records
|
|
36
|
+
// itself. The Computer is assigned and its tools are mounted, so the Turn
|
|
37
|
+
// *could* reach it; the check is that it does not.
|
|
38
|
+
const computerCalls: string[] = [];
|
|
39
|
+
const provider: ComputerProvider = {
|
|
40
|
+
id: "recording",
|
|
41
|
+
open: (identity, tenant, assignment) => {
|
|
42
|
+
computerCalls.push(`open:${identity.userId}:${tenant.botId}`);
|
|
43
|
+
return Promise.resolve({
|
|
44
|
+
assignment,
|
|
45
|
+
identity,
|
|
46
|
+
tenant,
|
|
47
|
+
exec: {
|
|
48
|
+
execute: () => {
|
|
49
|
+
computerCalls.push("exec");
|
|
50
|
+
return Promise.resolve({
|
|
51
|
+
exitCode: 0,
|
|
52
|
+
stdout: new Uint8Array(),
|
|
53
|
+
stderr: new Uint8Array(),
|
|
54
|
+
outputTruncated: false,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
browser: {
|
|
59
|
+
perform: () => {
|
|
60
|
+
computerCalls.push("browser");
|
|
61
|
+
return Promise.resolve({ accessibilitySnapshot: "" });
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
close: () => Promise.resolve(),
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const echo: ToolDefinition = {
|
|
70
|
+
name: "echo",
|
|
71
|
+
description: "Echo the input back.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: { text: { type: "string" } },
|
|
75
|
+
required: ["text"],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
idempotent: true,
|
|
79
|
+
execute: (input) =>
|
|
80
|
+
Promise.resolve({
|
|
81
|
+
content: String((input as { text: string }).text),
|
|
82
|
+
isError: false,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let calledTool = false;
|
|
87
|
+
const model: LlmProvider = {
|
|
88
|
+
id: "computer-free",
|
|
89
|
+
async *stream() {
|
|
90
|
+
if (!calledTool) {
|
|
91
|
+
calledTool = true;
|
|
92
|
+
yield {
|
|
93
|
+
type: "tool-call",
|
|
94
|
+
call: {
|
|
95
|
+
id: "call-1",
|
|
96
|
+
name: "echo",
|
|
97
|
+
input: { text: "no computer" },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
yield { type: "text-delta", text: "done" };
|
|
104
|
+
yield { type: "finish", reason: "completed" };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const root = new Context();
|
|
109
|
+
await root.plugin(SessionStore, {});
|
|
110
|
+
await root.plugin(SystemPromptRegistry);
|
|
111
|
+
await root.plugin(LlmRegistry);
|
|
112
|
+
await root.plugin(ToolRegistry);
|
|
113
|
+
await root.plugin(ComputerRegistry);
|
|
114
|
+
await root.plugin(AgentRegistry);
|
|
115
|
+
const providerPlugin: Plugin.Function = (ctx) => {
|
|
116
|
+
const disposeModel = ctx.llm.register(model);
|
|
117
|
+
const disposeTool = ctx.tools.register(echo);
|
|
118
|
+
const disposeComputer = ctx.computers.register(provider);
|
|
119
|
+
return () => {
|
|
120
|
+
disposeComputer();
|
|
121
|
+
disposeTool();
|
|
122
|
+
disposeModel();
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
providerPlugin.inject = ["llm", "tools", "computers"];
|
|
126
|
+
await root.plugin(providerPlugin);
|
|
127
|
+
await root.plugin(
|
|
128
|
+
createComputerAgentPlugin({
|
|
129
|
+
userId: "user-1",
|
|
130
|
+
defaultProviderId: "recording",
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
await root.plugin(AgentLoop, { maxSteps: 4, composition: COMPOSITION });
|
|
134
|
+
|
|
135
|
+
// The Computer tools really are mounted: this Turn declines them.
|
|
136
|
+
expect(
|
|
137
|
+
root.tools.schemas({ turnType: "chat" }).map((schema) => schema.name),
|
|
138
|
+
).toContain("computer_exec");
|
|
139
|
+
|
|
140
|
+
const handle = await root.agents.create({
|
|
141
|
+
botId: "bot-1",
|
|
142
|
+
sessionId: "session-1",
|
|
143
|
+
provider: model.id,
|
|
144
|
+
model: "test-model",
|
|
145
|
+
admitEffect: () => Promise.resolve(true),
|
|
146
|
+
});
|
|
147
|
+
handle.agent.send("Say something without the Computer");
|
|
148
|
+
await handle.agent.whenIdle();
|
|
149
|
+
|
|
150
|
+
expect(calledTool).toBe(true);
|
|
151
|
+
expect(computerCalls).toEqual([]);
|
|
152
|
+
await root.fiber.dispose();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test(
|
|
156
|
+
"a Skill written outside the Bot's own authority is not loaded as an instruction — " +
|
|
157
|
+
'"The kernel treats every Workspace file as data. Only Skills under the ' +
|
|
158
|
+
"Bot's own instruction root, written under the Bot's own authority or its " +
|
|
159
|
+
"User's, are loaded as instructions.\"",
|
|
160
|
+
async () => {
|
|
161
|
+
// Five candidates sit in the Workspace, all named SKILL.md, all parsing.
|
|
162
|
+
// Two are the Bot's own authority; three are not. A sixth sits under the
|
|
163
|
+
// Bot's own *Memory* root, which is not an instruction root at all.
|
|
164
|
+
const owner = { userId: "user-1", botId: "bot-1" };
|
|
165
|
+
const ownRoot = botInstructionRootV1(owner);
|
|
166
|
+
const botWriter = {
|
|
167
|
+
kind: "bot" as const,
|
|
168
|
+
botId: "bot-1",
|
|
169
|
+
sessionId: "user-1:bot-1",
|
|
170
|
+
turnId: "turn-1",
|
|
171
|
+
runId: "run-1",
|
|
172
|
+
};
|
|
173
|
+
const workspace = await FakeWorkspace.seeded([
|
|
174
|
+
{
|
|
175
|
+
root: ownRoot,
|
|
176
|
+
path: "skills/own-bot/SKILL.md",
|
|
177
|
+
text: skillMarkdown(
|
|
178
|
+
"own-bot",
|
|
179
|
+
"Use this when the Bot itself wrote the Skill.",
|
|
180
|
+
"OWN-BOT-BODY",
|
|
181
|
+
),
|
|
182
|
+
writer: botWriter,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
root: ownRoot,
|
|
186
|
+
path: "skills/own-user/SKILL.md",
|
|
187
|
+
text: skillMarkdown(
|
|
188
|
+
"own-user",
|
|
189
|
+
"Use this when the Bot's User wrote the Skill.",
|
|
190
|
+
"OWN-USER-BODY",
|
|
191
|
+
),
|
|
192
|
+
writer: { kind: "user", userId: "user-1" },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
root: ownRoot,
|
|
196
|
+
path: "skills/first-party/SKILL.md",
|
|
197
|
+
text: skillMarkdown(
|
|
198
|
+
"first-party-skill",
|
|
199
|
+
"PACKAGE-INSTRUCTION",
|
|
200
|
+
"PACKAGE-BODY",
|
|
201
|
+
),
|
|
202
|
+
writer: { kind: "first-party", packageId: "memory" },
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
root: ownRoot,
|
|
206
|
+
path: "skills/other-bot/SKILL.md",
|
|
207
|
+
text: skillMarkdown(
|
|
208
|
+
"other-bot-skill",
|
|
209
|
+
"OTHER-BOT-INSTRUCTION",
|
|
210
|
+
"OTHER-BOT-BODY",
|
|
211
|
+
),
|
|
212
|
+
writer: { ...botWriter, botId: "bot-2" },
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
root: ownRoot,
|
|
216
|
+
path: "skills/other-user/SKILL.md",
|
|
217
|
+
text: skillMarkdown(
|
|
218
|
+
"other-user-skill",
|
|
219
|
+
"OTHER-USER-INSTRUCTION",
|
|
220
|
+
"OTHER-USER-BODY",
|
|
221
|
+
),
|
|
222
|
+
writer: { kind: "user", userId: "user-2" },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
root: { kind: "bot-memory", userId: "user-1", botId: "bot-1" },
|
|
226
|
+
path: "skills/memory-root/SKILL.md",
|
|
227
|
+
text: skillMarkdown(
|
|
228
|
+
"memory-root-skill",
|
|
229
|
+
"MEMORY-ROOT-INSTRUCTION",
|
|
230
|
+
"MEMORY-ROOT-BODY",
|
|
231
|
+
),
|
|
232
|
+
writer: botWriter,
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const requests: NormalizedModelRequest[] = [];
|
|
237
|
+
const model: LlmProvider = {
|
|
238
|
+
id: "skill-reader",
|
|
239
|
+
async *stream(request) {
|
|
240
|
+
requests.push(request);
|
|
241
|
+
yield { type: "text-delta", text: "read" };
|
|
242
|
+
yield { type: "finish", reason: "completed" };
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const root = new Context();
|
|
247
|
+
await root.plugin(SessionStore, {});
|
|
248
|
+
await root.plugin(SystemPromptRegistry);
|
|
249
|
+
await root.plugin(LlmRegistry);
|
|
250
|
+
await root.plugin(ToolRegistry);
|
|
251
|
+
await root.plugin(AgentRegistry);
|
|
252
|
+
const providerPlugin: Plugin.Function = (ctx) => ctx.llm.register(model);
|
|
253
|
+
providerPlugin.inject = ["llm"];
|
|
254
|
+
await root.plugin(providerPlugin);
|
|
255
|
+
await root.plugin(
|
|
256
|
+
createSkillsRuntimePlugin({
|
|
257
|
+
owner,
|
|
258
|
+
reads: workspace,
|
|
259
|
+
files: workspace,
|
|
260
|
+
writer: {
|
|
261
|
+
sessionId: "user-1:bot-1",
|
|
262
|
+
turnId: "turn-1",
|
|
263
|
+
runId: "run-1",
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
await root.plugin(AgentLoop, { maxSteps: 2, composition: COMPOSITION });
|
|
268
|
+
|
|
269
|
+
const handle = await root.agents.create({
|
|
270
|
+
botId: "bot-1",
|
|
271
|
+
sessionId: "user-1:bot-1",
|
|
272
|
+
provider: model.id,
|
|
273
|
+
model: "test-model",
|
|
274
|
+
admitEffect: () => Promise.resolve(true),
|
|
275
|
+
});
|
|
276
|
+
handle.agent.send("What Skills do you have?");
|
|
277
|
+
await handle.agent.whenIdle();
|
|
278
|
+
|
|
279
|
+
const system = requests.at(0)?.system ?? "";
|
|
280
|
+
expect(system).toContain("<agent_skills>");
|
|
281
|
+
expect(system).toContain("skills/own-bot/SKILL.md");
|
|
282
|
+
expect(system).toContain("skills/own-user/SKILL.md");
|
|
283
|
+
// The refused four reach the model in no form at all: not their paths,
|
|
284
|
+
// not their descriptions, not their bodies.
|
|
285
|
+
for (const forbidden of [
|
|
286
|
+
"PACKAGE-INSTRUCTION",
|
|
287
|
+
"OTHER-BOT-INSTRUCTION",
|
|
288
|
+
"OTHER-USER-INSTRUCTION",
|
|
289
|
+
"MEMORY-ROOT-INSTRUCTION",
|
|
290
|
+
"skills/first-party/SKILL.md",
|
|
291
|
+
"skills/other-bot/SKILL.md",
|
|
292
|
+
"skills/other-user/SKILL.md",
|
|
293
|
+
"skills/memory-root/SKILL.md",
|
|
294
|
+
]) {
|
|
295
|
+
expect(system).not.toContain(forbidden);
|
|
296
|
+
}
|
|
297
|
+
// Nor through the on-demand disclosure tool.
|
|
298
|
+
const disclosure = await root.tools.executePrepared(
|
|
299
|
+
{
|
|
300
|
+
kind: "ready",
|
|
301
|
+
call: {
|
|
302
|
+
id: "call-1",
|
|
303
|
+
name: "skill_load",
|
|
304
|
+
input: { path: "skills/other-bot/SKILL.md" },
|
|
305
|
+
},
|
|
306
|
+
idempotent: true,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
botId: "bot-1",
|
|
310
|
+
agentId: handle.agent.id,
|
|
311
|
+
sessionId: "user-1:bot-1",
|
|
312
|
+
compositionGenerationId: COMPOSITION.generationId,
|
|
313
|
+
turnType: "chat" as const,
|
|
314
|
+
effectId: "tool:1:1:0",
|
|
315
|
+
signal: new AbortController().signal,
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
expect(disclosure.isError).toBe(true);
|
|
319
|
+
expect(disclosure.content).not.toContain("OTHER-BOT-BODY");
|
|
320
|
+
|
|
321
|
+
// The injection is visible in durable state, refusals included.
|
|
322
|
+
const injected = handle.agent.session.events.find(
|
|
323
|
+
(event) => event.type === "skill/injected",
|
|
324
|
+
);
|
|
325
|
+
expect(injected).toMatchObject({ type: "skill/injected", turn: 1 });
|
|
326
|
+
if (injected?.type !== "skill/injected") throw new Error("unreachable");
|
|
327
|
+
// The Bot's own root first, then the managed set the Skills Package
|
|
328
|
+
// compiles into its artifact. A managed Skill is not a Workspace file
|
|
329
|
+
// and never meets the predicate this test is about; it is Package-
|
|
330
|
+
// contributed prompt content, pinned by the Turn's Composition.
|
|
331
|
+
expect(injected.skills.map((skill) => skill.path)).toEqual([
|
|
332
|
+
"skills/own-bot/SKILL.md",
|
|
333
|
+
"skills/own-user/SKILL.md",
|
|
334
|
+
"managed/add-connector/SKILL.md",
|
|
335
|
+
"managed/export-bot-template/SKILL.md",
|
|
336
|
+
"managed/import-bot-template/SKILL.md",
|
|
337
|
+
"managed/learn-from-demonstration/SKILL.md",
|
|
338
|
+
]);
|
|
339
|
+
expect(injected.refusals.map((refusal) => refusal.path)).toEqual([
|
|
340
|
+
"skills/first-party/SKILL.md",
|
|
341
|
+
"skills/other-bot/SKILL.md",
|
|
342
|
+
"skills/other-user/SKILL.md",
|
|
343
|
+
]);
|
|
344
|
+
expect(
|
|
345
|
+
injected.refusals.every((refusal) =>
|
|
346
|
+
refusal.reason.startsWith("authority:"),
|
|
347
|
+
),
|
|
348
|
+
).toBe(true);
|
|
349
|
+
|
|
350
|
+
await root.fiber.dispose();
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED