@frockbot/architecture-checks 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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
package/package.json
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/architecture-checks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Automated checks for the constitutional rules that can be enforced mechanically.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test src",
|
|
9
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@frockbot/computer-core": "0.1.1",
|
|
13
|
+
"@frockbot/connection-core": "0.1.1",
|
|
14
|
+
"@frockbot/kernel-agent-loop": "0.1.1",
|
|
15
|
+
"@frockbot/kernel-composition": "0.1.1",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
17
|
+
"@frockbot/kernel-do": "0.1.1",
|
|
18
|
+
"@frockbot/plugin-authoring": "0.1.1",
|
|
19
|
+
"@frockbot/plugin-computer": "0.1.1",
|
|
20
|
+
"@frockbot/plugin-memory": "0.1.1",
|
|
21
|
+
"@frockbot/plugin-models": "0.1.1",
|
|
22
|
+
"@frockbot/plugin-prompt": "0.1.1",
|
|
23
|
+
"@frockbot/plugin-provider-foundation": "0.1.1",
|
|
24
|
+
"@frockbot/plugin-provider-ollama-cloud": "0.1.1",
|
|
25
|
+
"@frockbot/plugin-skills": "0.1.1",
|
|
26
|
+
"@frockbot/plugin-tools": "0.1.1",
|
|
27
|
+
"cordis": "4.0.0-rc.8"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "1.4.0",
|
|
31
|
+
"typescript": "^7.0.2"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
6
36
|
"repository": {
|
|
7
37
|
"type": "git",
|
|
8
38
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
39
|
"directory": "packages/architecture-checks"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
40
|
}
|
|
14
41
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ADR 0004 — "the Fly Sprites SDK is loaded in exactly one place". The SDK's
|
|
2
|
+
// HTTP exec protocol depends on response chunk boundaries workerd does not
|
|
3
|
+
// preserve, so it may only run in the Node container app; every other package
|
|
4
|
+
// reaches the Computer through the `COMPUTER_HOST` service binding. The rule is
|
|
5
|
+
// a source-graph fact, so `scripts/check-computer-host-imports.ts` enforces it
|
|
6
|
+
// and this file proves both that the tree obeys it and that the linter bites.
|
|
7
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
// Spelled in halves so this file's own fixtures are not a violation of the
|
|
19
|
+
// rule it proves.
|
|
20
|
+
const SDK = ["@fly", "sprites"].join("/");
|
|
21
|
+
|
|
22
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
23
|
+
const checkScript = join(repoRoot, "scripts", "check-computer-host-imports.ts");
|
|
24
|
+
|
|
25
|
+
function runCheck(cwd: string): { output: string; exitCode: number | null } {
|
|
26
|
+
const check = Bun.spawnSync({
|
|
27
|
+
cmd: ["bun", "scripts/check-computer-host-imports.ts"],
|
|
28
|
+
cwd,
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
output: `${check.stdout.toString()}${check.stderr.toString()}`,
|
|
34
|
+
exitCode: check.exitCode,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fixtures: string[] = [];
|
|
39
|
+
|
|
40
|
+
// A miniature repo with the linter copied in, so a violation can be staged
|
|
41
|
+
// without writing one into this tree.
|
|
42
|
+
function fixtureRepo(files: Record<string, string>): string {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), "computer-host-lint-"));
|
|
44
|
+
fixtures.push(root);
|
|
45
|
+
mkdirSync(join(root, "scripts"), { recursive: true });
|
|
46
|
+
copyFileSync(
|
|
47
|
+
checkScript,
|
|
48
|
+
join(root, "scripts", "check-computer-host-imports.ts"),
|
|
49
|
+
);
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(root, "package.json"),
|
|
52
|
+
JSON.stringify({ name: "fixture" }),
|
|
53
|
+
);
|
|
54
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
55
|
+
const target = join(root, path);
|
|
56
|
+
mkdirSync(resolve(target, ".."), { recursive: true });
|
|
57
|
+
writeFileSync(target, contents);
|
|
58
|
+
}
|
|
59
|
+
return root;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
for (const root of fixtures) rmSync(root, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("computer host boundaries", () => {
|
|
67
|
+
test("the Fly Sprites SDK is imported only by the Computer host", () => {
|
|
68
|
+
const { output, exitCode } = runCheck(repoRoot);
|
|
69
|
+
expect(output).toContain("Computer host import contract passed");
|
|
70
|
+
expect(exitCode).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the check refuses a static import outside the Computer host", () => {
|
|
74
|
+
const root = fixtureRepo({
|
|
75
|
+
"packages/example/package.json": JSON.stringify({ name: "example" }),
|
|
76
|
+
"packages/example/src/index.ts": `import { SpritesClient } from "${SDK}";\n`,
|
|
77
|
+
});
|
|
78
|
+
const { output, exitCode } = runCheck(root);
|
|
79
|
+
expect(exitCode).toBe(1);
|
|
80
|
+
expect(output).toContain("packages/example/src/index.ts:1");
|
|
81
|
+
expect(output).toContain(SDK);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the check refuses a dynamic import and a Vue single-file component", () => {
|
|
85
|
+
const root = fixtureRepo({
|
|
86
|
+
"packages/example/package.json": JSON.stringify({ name: "example" }),
|
|
87
|
+
"packages/example/src/lazy.ts": `export const sdk = () => import("${SDK}/exec");\n`,
|
|
88
|
+
"packages/example/src/Panel.vue": `<script setup lang="ts">\nimport { SpritesClient } from "${SDK}";\n</script>\n`,
|
|
89
|
+
});
|
|
90
|
+
const { output, exitCode } = runCheck(root);
|
|
91
|
+
expect(exitCode).toBe(1);
|
|
92
|
+
expect(output).toContain("packages/example/src/lazy.ts");
|
|
93
|
+
expect(output).toContain("packages/example/src/Panel.vue");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the check refuses a manifest that declares the SDK outside the host", () => {
|
|
97
|
+
const root = fixtureRepo({
|
|
98
|
+
"apps/cloudflare/package.json": JSON.stringify({
|
|
99
|
+
name: "cloudflare",
|
|
100
|
+
devDependencies: { [SDK]: "0.1.0" },
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const { output, exitCode } = runCheck(root);
|
|
104
|
+
expect(exitCode).toBe(1);
|
|
105
|
+
expect(output).toContain("apps/cloudflare/package.json");
|
|
106
|
+
expect(output).toContain("devDependencies");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("the Computer host itself may import the SDK and declare it", () => {
|
|
110
|
+
const root = fixtureRepo({
|
|
111
|
+
"apps/computer-host/package.json": JSON.stringify({
|
|
112
|
+
name: "computer-host",
|
|
113
|
+
dependencies: { [SDK]: "0.1.0" },
|
|
114
|
+
}),
|
|
115
|
+
"apps/computer-host/container/server.ts": `import { SpritesClient } from "${SDK}";\n`,
|
|
116
|
+
"apps/computer-host/container/package.json": JSON.stringify({
|
|
117
|
+
name: "container",
|
|
118
|
+
dependencies: { [SDK]: "0.1.0" },
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
const { output, exitCode } = runCheck(root);
|
|
122
|
+
expect(output).toContain("Computer host import contract passed");
|
|
123
|
+
expect(exitCode).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Constitutional check — "Provider types stay in their adapter".
|
|
2
|
+
//
|
|
3
|
+
// The registered machine's agent is the first Package that can run a shell
|
|
4
|
+
// command on somebody's laptop, and the rule that keeps that honest is where
|
|
5
|
+
// the two dangerous imports may appear:
|
|
6
|
+
//
|
|
7
|
+
// * `electron` — the widest authority FrockBot has — only inside the Electron
|
|
8
|
+
// main process and its preload, which is `apps/desktop/src` (and the
|
|
9
|
+
// Electron proof-of-concept app that exists to exercise it).
|
|
10
|
+
// * `child_process` — only in the Electron main process, where a
|
|
11
|
+
// `trusted-main` desktop Contribution reaches it through a capability, and
|
|
12
|
+
// in the end-to-end harness that starts the dev server.
|
|
13
|
+
//
|
|
14
|
+
// No Package under `packages/` may import either. That is what makes
|
|
15
|
+
// `@frockbot/plugin-user-machine`'s agent loop testable in CI: it cannot
|
|
16
|
+
// silently acquire a process to spawn, so every decision it makes has to be
|
|
17
|
+
// expressible over an injected seam.
|
|
18
|
+
//
|
|
19
|
+
// Like the other authority checks, the rule is a pure function of a scan so a
|
|
20
|
+
// violation can be staged without writing one into this tree.
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from "bun:test";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
27
|
+
|
|
28
|
+
/** Spelled in halves so this file is not itself a violation of its own rule. */
|
|
29
|
+
const ELECTRON = ["elec", "tron"].join("");
|
|
30
|
+
const CHILD_PROCESS = ["child", "_process"].join("");
|
|
31
|
+
|
|
32
|
+
interface ScannedSource {
|
|
33
|
+
/** Repo-relative path. */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Every module specifier the file imports or requires. */
|
|
36
|
+
specifiers: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SPECIFIER = /(?:from|import|require\()\s*["']([^"']+)["']/g;
|
|
40
|
+
|
|
41
|
+
function scanSources(): ScannedSource[] {
|
|
42
|
+
return [
|
|
43
|
+
...new Bun.Glob("{packages,apps,applications}/**/*.{ts,vue}").scanSync({
|
|
44
|
+
cwd: repoRoot,
|
|
45
|
+
}),
|
|
46
|
+
]
|
|
47
|
+
.filter((path) => !path.includes("node_modules/"))
|
|
48
|
+
.filter((path) => !path.includes("/dist/"))
|
|
49
|
+
.filter((path) => !path.includes("/out/"))
|
|
50
|
+
.sort()
|
|
51
|
+
.map((path) => {
|
|
52
|
+
const source = readFileSync(resolve(repoRoot, path), "utf8");
|
|
53
|
+
return {
|
|
54
|
+
path,
|
|
55
|
+
specifiers: [...source.matchAll(SPECIFIER)].map((match) => match[1]!),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Where each dangerous import is permitted, and nowhere else. */
|
|
61
|
+
const ALLOWED = {
|
|
62
|
+
electron: [/^apps\/desktop\/src\//, /^apps\/cordis-poc\/src\//],
|
|
63
|
+
childProcess: [
|
|
64
|
+
/^apps\/desktop\/src\/main\//,
|
|
65
|
+
// The Playwright harness starts `wrangler dev`; it is a test runner, not a
|
|
66
|
+
// Package, and it never ships.
|
|
67
|
+
/^apps\/cloudflare\/e2e\//,
|
|
68
|
+
],
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
export function desktopProviderOffenders(
|
|
72
|
+
sources: readonly ScannedSource[],
|
|
73
|
+
): string[] {
|
|
74
|
+
const offenders: string[] = [];
|
|
75
|
+
for (const { path, specifiers } of sources) {
|
|
76
|
+
for (const specifier of specifiers) {
|
|
77
|
+
const electron =
|
|
78
|
+
specifier === ELECTRON || specifier.startsWith(`${ELECTRON}/`);
|
|
79
|
+
const child =
|
|
80
|
+
specifier === CHILD_PROCESS || specifier === `node:${CHILD_PROCESS}`;
|
|
81
|
+
if (!electron && !child) continue;
|
|
82
|
+
const allowed = electron ? ALLOWED.electron : ALLOWED.childProcess;
|
|
83
|
+
if (allowed.some((pattern) => pattern.test(path))) continue;
|
|
84
|
+
offenders.push(`${path}: ${specifier}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return offenders;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("desktop provider boundaries", () => {
|
|
91
|
+
test("Electron and child_process are imported only where they may be", () => {
|
|
92
|
+
expect(desktopProviderOffenders(scanSources())).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("no Package under packages/ reaches either of them", () => {
|
|
96
|
+
const offenders = scanSources()
|
|
97
|
+
.filter((source) => source.path.startsWith("packages/"))
|
|
98
|
+
.flatMap((source) =>
|
|
99
|
+
source.specifiers
|
|
100
|
+
.filter(
|
|
101
|
+
(specifier) =>
|
|
102
|
+
specifier === ELECTRON ||
|
|
103
|
+
specifier.startsWith(`${ELECTRON}/`) ||
|
|
104
|
+
specifier === CHILD_PROCESS ||
|
|
105
|
+
specifier === `node:${CHILD_PROCESS}`,
|
|
106
|
+
)
|
|
107
|
+
.map((specifier) => `${source.path}: ${specifier}`),
|
|
108
|
+
);
|
|
109
|
+
expect(offenders).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the rule bites on a staged violation", () => {
|
|
113
|
+
expect(
|
|
114
|
+
desktopProviderOffenders([
|
|
115
|
+
{
|
|
116
|
+
path: "packages/plugin-user-machine/src/desktop.ts",
|
|
117
|
+
specifiers: [`node:${CHILD_PROCESS}`, "@frockbot/desktop-core"],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
path: "packages/plugin-user-machine/src/device.ts",
|
|
121
|
+
specifiers: [ELECTRON],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
path: "apps/cloudflare/src/index.ts",
|
|
125
|
+
specifiers: [`${ELECTRON}/main`],
|
|
126
|
+
},
|
|
127
|
+
// Permitted, and must stay permitted.
|
|
128
|
+
{
|
|
129
|
+
path: "apps/desktop/src/main/machine-host.ts",
|
|
130
|
+
specifiers: [`node:${CHILD_PROCESS}`],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
path: "apps/desktop/src/preload/index.ts",
|
|
134
|
+
specifiers: [ELECTRON],
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
).toEqual([
|
|
138
|
+
`packages/plugin-user-machine/src/desktop.ts: node:${CHILD_PROCESS}`,
|
|
139
|
+
`packages/plugin-user-machine/src/device.ts: ${ELECTRON}`,
|
|
140
|
+
`apps/cloudflare/src/index.ts: ${ELECTRON}/main`,
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a lookalike specifier is not the real thing", () => {
|
|
145
|
+
expect(
|
|
146
|
+
desktopProviderOffenders([
|
|
147
|
+
{
|
|
148
|
+
path: "apps/cloudflare/src/index.ts",
|
|
149
|
+
specifiers: [
|
|
150
|
+
`@better-auth/${ELECTRON}/client`,
|
|
151
|
+
`${CHILD_PROCESS}-promise`,
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
]),
|
|
155
|
+
).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Constitutional checks that are pure source-graph facts: what the kernel may
|
|
2
|
+
// import, what a client bundle or protocol may carry, and what core runtime
|
|
3
|
+
// code may depend on. Each rule is one named test so `docs/architecture-checks.md`
|
|
4
|
+
// can point at it.
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
|
10
|
+
|
|
11
|
+
function sourceFiles(...patterns: string[]): string[] {
|
|
12
|
+
const files: string[] = [];
|
|
13
|
+
for (const pattern of patterns) {
|
|
14
|
+
files.push(
|
|
15
|
+
...new Bun.Glob(pattern).scanSync({
|
|
16
|
+
cwd: repoRoot,
|
|
17
|
+
onlyFiles: true,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return files
|
|
22
|
+
.filter((path) => !path.includes("node_modules/"))
|
|
23
|
+
.filter((path) => !path.includes("/dist/"))
|
|
24
|
+
.sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function read(path: string): string {
|
|
28
|
+
return readFileSync(join(repoRoot, path), "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("kernel boundaries", () => {
|
|
32
|
+
// Constitution — Minimal kernel: "The kernel imports no Package and contains
|
|
33
|
+
// no product policy."
|
|
34
|
+
test("the kernel imports no Package", async () => {
|
|
35
|
+
const check = Bun.spawnSync({
|
|
36
|
+
cmd: ["bun", "scripts/check-kernel-imports.ts"],
|
|
37
|
+
cwd: repoRoot,
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
const output = `${check.stdout.toString()}${check.stderr.toString()}`;
|
|
42
|
+
expect(output).toContain("Kernel import contract passed");
|
|
43
|
+
expect(check.exitCode).toBe(0);
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Constitution — Architecture checks: "two provider Packages satisfy the
|
|
48
|
+
// model interface with no kernel diff". The behavioural half is in
|
|
49
|
+
// model-interface.test.ts; this is the source half.
|
|
50
|
+
test("no kernel source names a model provider Package", () => {
|
|
51
|
+
const providerTerms = [
|
|
52
|
+
"provider-foundation",
|
|
53
|
+
"provider-ollama-cloud",
|
|
54
|
+
"openai-compatible",
|
|
55
|
+
"ollama.com",
|
|
56
|
+
];
|
|
57
|
+
const offenders: string[] = [];
|
|
58
|
+
for (const path of sourceFiles("packages/kernel-*/src/**/*.ts")) {
|
|
59
|
+
if (path.endsWith(".test.ts")) continue;
|
|
60
|
+
const source = read(path);
|
|
61
|
+
for (const term of providerTerms) {
|
|
62
|
+
if (source.includes(term)) offenders.push(`${path}: ${term}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
expect(offenders).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Constitution — Explicit seams: "Core and runtime modules are independent of
|
|
69
|
+
// Electron and client-framework authority."
|
|
70
|
+
test("core runtime code has no Electron dependency", () => {
|
|
71
|
+
const offenders: string[] = [];
|
|
72
|
+
for (const path of sourceFiles(
|
|
73
|
+
"packages/kernel-*/src/**/*.ts",
|
|
74
|
+
"packages/client-core/src/**/*.ts",
|
|
75
|
+
"packages/configuration-core/src/**/*.ts",
|
|
76
|
+
"packages/connection-core/src/**/*.ts",
|
|
77
|
+
"packages/computer-core/src/**/*.ts",
|
|
78
|
+
"packages/protocol/src/**/*.ts",
|
|
79
|
+
"packages/plugin-shell/src/backend*.ts",
|
|
80
|
+
"packages/plugin-settings/src/backend*.ts",
|
|
81
|
+
"applications/foundation/src/runtime.ts",
|
|
82
|
+
"applications/foundation/src/user.ts",
|
|
83
|
+
"apps/cloudflare/src/**/*.ts",
|
|
84
|
+
)) {
|
|
85
|
+
if (/from\s+"electron(\/|")/.test(read(path))) offenders.push(path);
|
|
86
|
+
}
|
|
87
|
+
expect(offenders).toEqual([]);
|
|
88
|
+
|
|
89
|
+
const manifests = [
|
|
90
|
+
"packages/kernel-contracts",
|
|
91
|
+
"packages/kernel-agent-loop",
|
|
92
|
+
"packages/kernel-composition",
|
|
93
|
+
"packages/kernel-do",
|
|
94
|
+
"packages/client-core",
|
|
95
|
+
"packages/protocol",
|
|
96
|
+
"packages/plugin-shell",
|
|
97
|
+
"applications/foundation",
|
|
98
|
+
"apps/cloudflare",
|
|
99
|
+
];
|
|
100
|
+
for (const manifest of manifests) {
|
|
101
|
+
const declared = read(`${manifest}/package.json`);
|
|
102
|
+
expect({ manifest, electron: declared.includes('"electron"') }).toEqual({
|
|
103
|
+
manifest,
|
|
104
|
+
electron: false,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Constitution — Plugin-owned integrations: "Secrets remain server-side and
|
|
110
|
+
// cross interfaces only as opaque references when necessary."
|
|
111
|
+
test("client bundles and protocols contain no secrets", () => {
|
|
112
|
+
const secretNames = [
|
|
113
|
+
"CREDENTIAL_KEYRING",
|
|
114
|
+
"BETTER_AUTH_SECRET",
|
|
115
|
+
"GOOGLE_CLIENT_SECRET",
|
|
116
|
+
"SPRITES_TOKEN",
|
|
117
|
+
"OLLAMA_API_KEY",
|
|
118
|
+
];
|
|
119
|
+
const offenders: string[] = [];
|
|
120
|
+
for (const path of sourceFiles(
|
|
121
|
+
"packages/*/src/client/**/*.{ts,vue}",
|
|
122
|
+
"packages/client-core/src/**/*.ts",
|
|
123
|
+
"packages/client-ui/src/**/*.{ts,vue}",
|
|
124
|
+
"packages/protocol/src/**/*.ts",
|
|
125
|
+
"packages/webui-shell/src/**/*.{ts,vue}",
|
|
126
|
+
)) {
|
|
127
|
+
if (path.endsWith(".test.ts")) continue;
|
|
128
|
+
const source = read(path);
|
|
129
|
+
for (const name of secretNames) {
|
|
130
|
+
if (source.includes(name)) offenders.push(`${path}: ${name}`);
|
|
131
|
+
}
|
|
132
|
+
// A client may name a credential slot, never carry its plaintext.
|
|
133
|
+
if (/plaintextCredential|apiKeyPlaintext|credentialSecret/.test(source)) {
|
|
134
|
+
offenders.push(`${path}: plaintext credential field`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
expect(offenders).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Constitution — Architecture checks: "Memory is readable and writable with no
|
|
2
|
+
// Computer interface call".
|
|
3
|
+
//
|
|
4
|
+
// The Computer is assigned and its tools are mounted, so the Turn *could*
|
|
5
|
+
// reach it. Every entry point of the provider-neutral Computer interface
|
|
6
|
+
// records itself, and a whole Memory cycle — write, read, inject, forget —
|
|
7
|
+
// runs through the Memory Package. The check is that not one of those
|
|
8
|
+
// recordings happens.
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
ComputerRegistry,
|
|
12
|
+
type ComputerProvider,
|
|
13
|
+
} from "@frockbot/computer-core";
|
|
14
|
+
import { AgentRegistry } from "@frockbot/kernel-agent-loop/agent";
|
|
15
|
+
import { AgentLoop } from "@frockbot/kernel-agent-loop";
|
|
16
|
+
import {
|
|
17
|
+
SessionStore,
|
|
18
|
+
type LlmProvider,
|
|
19
|
+
type NormalizedModelRequest,
|
|
20
|
+
} from "@frockbot/kernel-contracts";
|
|
21
|
+
import { createComputerAgentPlugin } from "@frockbot/plugin-computer/agent";
|
|
22
|
+
import {
|
|
23
|
+
botMemoryRootV1,
|
|
24
|
+
createMemoryRuntimePlugin,
|
|
25
|
+
createTestMemoryFilesV1,
|
|
26
|
+
MemoryStore,
|
|
27
|
+
userMemoryRootV1,
|
|
28
|
+
} from "@frockbot/plugin-memory";
|
|
29
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
30
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
31
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
32
|
+
import { Context, type Plugin } from "cordis";
|
|
33
|
+
|
|
34
|
+
const COMPOSITION = {
|
|
35
|
+
generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
|
|
36
|
+
artifactSetHash: "a".repeat(64),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("Memory boundaries", () => {
|
|
40
|
+
test("Memory is readable and writable with no Computer interface call", async () => {
|
|
41
|
+
const computerCalls: string[] = [];
|
|
42
|
+
const provider: ComputerProvider = {
|
|
43
|
+
id: "recording",
|
|
44
|
+
open: (identity, tenant, assignment) => {
|
|
45
|
+
computerCalls.push(`open:${identity.userId}:${tenant.botId}`);
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
assignment,
|
|
48
|
+
identity,
|
|
49
|
+
tenant,
|
|
50
|
+
exec: {
|
|
51
|
+
execute: () => {
|
|
52
|
+
computerCalls.push("exec");
|
|
53
|
+
return Promise.resolve({
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
stdout: new Uint8Array(),
|
|
56
|
+
stderr: new Uint8Array(),
|
|
57
|
+
outputTruncated: false,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
browser: {
|
|
62
|
+
perform: () => {
|
|
63
|
+
computerCalls.push("browser");
|
|
64
|
+
return Promise.resolve({ accessibilitySnapshot: "" });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
close: () => Promise.resolve(),
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const owner = { userId: "user-1", botId: "bot-1" };
|
|
73
|
+
const files = createTestMemoryFilesV1({ userId: owner.userId });
|
|
74
|
+
const store = new MemoryStore({ files, owner });
|
|
75
|
+
const writer = {
|
|
76
|
+
kind: "bot" as const,
|
|
77
|
+
botId: owner.botId,
|
|
78
|
+
sessionId: "user-1:bot-1",
|
|
79
|
+
turnId: "turn-0",
|
|
80
|
+
runId: "run-0",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// WRITE — the Bot's own root and its shard of the shared User root.
|
|
84
|
+
expect(
|
|
85
|
+
(
|
|
86
|
+
await store.write({
|
|
87
|
+
root: botMemoryRootV1(owner),
|
|
88
|
+
tier: "profile",
|
|
89
|
+
fact: "Tim prefers blunt answers.",
|
|
90
|
+
writer,
|
|
91
|
+
})
|
|
92
|
+
).status,
|
|
93
|
+
).toBe("ok");
|
|
94
|
+
expect(
|
|
95
|
+
(
|
|
96
|
+
await store.write({
|
|
97
|
+
root: userMemoryRootV1(owner),
|
|
98
|
+
tier: "log",
|
|
99
|
+
fact: "The gym build starts in spring.",
|
|
100
|
+
writer,
|
|
101
|
+
})
|
|
102
|
+
).status,
|
|
103
|
+
).toBe("ok");
|
|
104
|
+
|
|
105
|
+
// READ — both tiers, merged.
|
|
106
|
+
expect((await store.read(botMemoryRootV1(owner))).profile).toHaveLength(1);
|
|
107
|
+
expect((await store.read(userMemoryRootV1(owner))).recent).toHaveLength(1);
|
|
108
|
+
|
|
109
|
+
const requests: NormalizedModelRequest[] = [];
|
|
110
|
+
const model: LlmProvider = {
|
|
111
|
+
id: "memory-reader",
|
|
112
|
+
async *stream(request) {
|
|
113
|
+
requests.push(request);
|
|
114
|
+
yield { type: "text-delta", text: "read" };
|
|
115
|
+
yield { type: "finish", reason: "completed" };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const root = new Context();
|
|
120
|
+
await root.plugin(SessionStore, {});
|
|
121
|
+
await root.plugin(SystemPromptRegistry);
|
|
122
|
+
await root.plugin(LlmRegistry);
|
|
123
|
+
await root.plugin(ToolRegistry);
|
|
124
|
+
await root.plugin(ComputerRegistry);
|
|
125
|
+
await root.plugin(AgentRegistry);
|
|
126
|
+
const providerPlugin: Plugin.Function = (ctx) => {
|
|
127
|
+
const disposeModel = ctx.llm.register(model);
|
|
128
|
+
const disposeComputer = ctx.computers.register(provider);
|
|
129
|
+
return () => {
|
|
130
|
+
disposeComputer();
|
|
131
|
+
disposeModel();
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
providerPlugin.inject = ["llm", "computers"];
|
|
135
|
+
await root.plugin(providerPlugin);
|
|
136
|
+
await root.plugin(
|
|
137
|
+
createComputerAgentPlugin({
|
|
138
|
+
userId: owner.userId,
|
|
139
|
+
defaultProviderId: "recording",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
await root.plugin(
|
|
143
|
+
createMemoryRuntimePlugin({
|
|
144
|
+
owner,
|
|
145
|
+
store,
|
|
146
|
+
writer: { sessionId: "user-1:bot-1", turnId: "turn-1", runId: "run-1" },
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
await root.plugin(AgentLoop, { maxSteps: 2, composition: COMPOSITION });
|
|
150
|
+
|
|
151
|
+
// The Computer tools really are mounted: this Turn simply never uses them.
|
|
152
|
+
expect(
|
|
153
|
+
root.tools.schemas({ turnType: "chat" }).map((schema) => schema.name),
|
|
154
|
+
).toContain("computer_exec");
|
|
155
|
+
expect(
|
|
156
|
+
root.tools.schemas({ turnType: "chat" }).map((schema) => schema.name),
|
|
157
|
+
).toEqual(
|
|
158
|
+
expect.arrayContaining([
|
|
159
|
+
"memory_write",
|
|
160
|
+
"memory_forget",
|
|
161
|
+
"memory_search",
|
|
162
|
+
"memory_rebuild_index",
|
|
163
|
+
]),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const handle = await root.agents.create({
|
|
167
|
+
botId: owner.botId,
|
|
168
|
+
sessionId: "user-1:bot-1",
|
|
169
|
+
provider: model.id,
|
|
170
|
+
model: "test-model",
|
|
171
|
+
admitEffect: () => Promise.resolve(true),
|
|
172
|
+
});
|
|
173
|
+
handle.agent.send("What do you remember?");
|
|
174
|
+
await handle.agent.whenIdle();
|
|
175
|
+
|
|
176
|
+
// INJECT — both tiers reached the model request, in GrokBot's shape.
|
|
177
|
+
const system = requests.at(0)?.system ?? "";
|
|
178
|
+
expect(system).toContain("User memory:");
|
|
179
|
+
expect(system).toContain("Memory:");
|
|
180
|
+
expect(system).toContain("Tim prefers blunt answers.");
|
|
181
|
+
expect(system).toContain("[via bot-1] The gym build starts in spring.");
|
|
182
|
+
|
|
183
|
+
// …and it is recorded in durable state, generations included.
|
|
184
|
+
const injected = handle.agent.session.events.find(
|
|
185
|
+
(event) => event.type === "memory/injected",
|
|
186
|
+
);
|
|
187
|
+
if (injected?.type !== "memory/injected") throw new Error("unreachable");
|
|
188
|
+
expect(injected.facts.map((fact) => fact.text).sort()).toEqual([
|
|
189
|
+
"The gym build starts in spring.",
|
|
190
|
+
"Tim prefers blunt answers.",
|
|
191
|
+
]);
|
|
192
|
+
expect(injected.sources.every((source) => source.generationId)).toBe(true);
|
|
193
|
+
|
|
194
|
+
// FORGET — the last leg of the cycle, still with no Computer.
|
|
195
|
+
expect(
|
|
196
|
+
(
|
|
197
|
+
await store.forget({
|
|
198
|
+
root: botMemoryRootV1(owner),
|
|
199
|
+
fact: "Tim prefers blunt answers.",
|
|
200
|
+
writer,
|
|
201
|
+
})
|
|
202
|
+
).status,
|
|
203
|
+
).toBe("ok");
|
|
204
|
+
|
|
205
|
+
expect(computerCalls).toEqual([]);
|
|
206
|
+
await root.fiber.dispose();
|
|
207
|
+
});
|
|
208
|
+
});
|