@frockbot/computer-core 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 +22 -6
- package/src/core.ts +990 -0
- package/src/host-protocol.test.ts +69 -0
- package/src/host-protocol.ts +369 -0
- package/src/index.test.ts +357 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context } from "cordis";
|
|
3
|
+
import {
|
|
4
|
+
ComputerError,
|
|
5
|
+
ComputerRegistry,
|
|
6
|
+
computerIdentityKeyV1,
|
|
7
|
+
decodeComputerDoctorReportV1,
|
|
8
|
+
workspaceMountPathV1,
|
|
9
|
+
type ComputerHandle,
|
|
10
|
+
type ComputerProvider,
|
|
11
|
+
type WorkspaceLayoutV1,
|
|
12
|
+
normalizeComputerPath,
|
|
13
|
+
} from "./core.js";
|
|
14
|
+
|
|
15
|
+
function provider(id: string, opened: string[]): ComputerProvider {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
async open(identity, tenant, assignment): Promise<ComputerHandle> {
|
|
19
|
+
opened.push(
|
|
20
|
+
`${identity.userId}:${tenant.botId}:${assignment.generation}`,
|
|
21
|
+
);
|
|
22
|
+
return {
|
|
23
|
+
assignment,
|
|
24
|
+
identity,
|
|
25
|
+
tenant: {
|
|
26
|
+
botId: tenant.botId,
|
|
27
|
+
directory: `agents/${tenant.botId}`,
|
|
28
|
+
display: `:${100 + opened.length}`,
|
|
29
|
+
},
|
|
30
|
+
close: () => Promise.resolve(),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LAYOUT: WorkspaceLayoutV1 = {
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
home: "/home/box",
|
|
39
|
+
roots: [
|
|
40
|
+
{
|
|
41
|
+
kind: "bot-instructions",
|
|
42
|
+
scope: "bot",
|
|
43
|
+
mountPath: "/home/box/agent-data/agents/{bot}/skills",
|
|
44
|
+
access: "read-write",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: "user-memory",
|
|
48
|
+
scope: "user",
|
|
49
|
+
mountPath: "/home/box/agent-data/user-memory",
|
|
50
|
+
access: "read-only",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
kind: "package-declared",
|
|
54
|
+
scope: "user",
|
|
55
|
+
mountPath: "/home/box/agent-data/user-packages/{package}/{root}",
|
|
56
|
+
access: "read-write",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("Computer paths", () => {
|
|
62
|
+
test("accepts relative POSIX paths and rejects namespace escapes", () => {
|
|
63
|
+
expect(normalizeComputerPath("memory/profile.md")).toBe(
|
|
64
|
+
"memory/profile.md",
|
|
65
|
+
);
|
|
66
|
+
for (const path of [
|
|
67
|
+
"/etc/passwd",
|
|
68
|
+
"../secret",
|
|
69
|
+
"a/./b",
|
|
70
|
+
"a//b",
|
|
71
|
+
"a\\b",
|
|
72
|
+
" spaced.md ",
|
|
73
|
+
"line\nbreak.md",
|
|
74
|
+
]) {
|
|
75
|
+
expect(() => normalizeComputerPath(path)).toThrow(ComputerError);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("Workspace layout", () => {
|
|
81
|
+
// Constitution — Computer and Workspace: durable roots are "declared by the
|
|
82
|
+
// Computer Package's Workspace layout"; a root is named by kind and owner
|
|
83
|
+
// and never by an absolute path.
|
|
84
|
+
test("resolves a declared root to its mount path and refuses an undeclared one", () => {
|
|
85
|
+
expect(
|
|
86
|
+
workspaceMountPathV1(
|
|
87
|
+
LAYOUT,
|
|
88
|
+
{ kind: "bot-instructions", userId: "u", botId: "b" },
|
|
89
|
+
(botId) => `${botId}-abc`,
|
|
90
|
+
),
|
|
91
|
+
).toBe("/home/box/agent-data/agents/b-abc/skills");
|
|
92
|
+
expect(
|
|
93
|
+
workspaceMountPathV1(LAYOUT, { kind: "user-memory", userId: "u" }),
|
|
94
|
+
).toBe("/home/box/agent-data/user-memory");
|
|
95
|
+
expect(
|
|
96
|
+
workspaceMountPathV1(LAYOUT, {
|
|
97
|
+
kind: "package-declared",
|
|
98
|
+
userId: "u",
|
|
99
|
+
packageId: "@frockbot/plugin-memory",
|
|
100
|
+
rootId: "notes",
|
|
101
|
+
}),
|
|
102
|
+
).toBe("/home/box/agent-data/user-packages/frockbot-plugin-memory/notes");
|
|
103
|
+
expect(() =>
|
|
104
|
+
workspaceMountPathV1(LAYOUT, {
|
|
105
|
+
kind: "bot-memory",
|
|
106
|
+
userId: "u",
|
|
107
|
+
botId: "b",
|
|
108
|
+
}),
|
|
109
|
+
).toThrow(ComputerError);
|
|
110
|
+
expect(() =>
|
|
111
|
+
workspaceMountPathV1(LAYOUT, {
|
|
112
|
+
kind: "bot-instructions",
|
|
113
|
+
userId: "u",
|
|
114
|
+
botId: "b",
|
|
115
|
+
}),
|
|
116
|
+
).toThrow("needs a Bot");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("ComputerRegistry", () => {
|
|
121
|
+
test("opens a Bot's selected provider without exposing provider selection to consumers", async () => {
|
|
122
|
+
const root = new Context();
|
|
123
|
+
await root.plugin(ComputerRegistry);
|
|
124
|
+
const opened: string[] = [];
|
|
125
|
+
root.computers.register(provider("sprites", opened));
|
|
126
|
+
const assignment = root.computers.assign({ userId: "user-1" }, "sprites");
|
|
127
|
+
|
|
128
|
+
const computer = await root.computers.open(
|
|
129
|
+
{ userId: "user-1" },
|
|
130
|
+
{ botId: "bot-1" },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(computer.assignment).toEqual(assignment);
|
|
134
|
+
expect(opened).toEqual(["user-1:bot-1:1"]);
|
|
135
|
+
await root.fiber.dispose();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ADR 0012: "One Computer serves all of a User's Bots." Two Bots of one User
|
|
139
|
+
// must resolve to one assignment and one generation, not two.
|
|
140
|
+
test("keys the Computer assignment per User, so a User's Bots share one Computer", async () => {
|
|
141
|
+
const root = new Context();
|
|
142
|
+
await root.plugin(ComputerRegistry);
|
|
143
|
+
const opened: string[] = [];
|
|
144
|
+
root.computers.register(provider("sprites", opened));
|
|
145
|
+
|
|
146
|
+
const assignment = root.computers.assign({ userId: "user-1" }, "sprites");
|
|
147
|
+
|
|
148
|
+
expect(root.computers.assignment({ userId: "user-1" })).toEqual(assignment);
|
|
149
|
+
const first = await root.computers.open(
|
|
150
|
+
{ userId: "user-1" },
|
|
151
|
+
{ botId: "bot-1" },
|
|
152
|
+
);
|
|
153
|
+
const second = await root.computers.open(
|
|
154
|
+
{ userId: "user-1" },
|
|
155
|
+
{ botId: "bot-2" },
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(opened).toEqual(["user-1:bot-1:1", "user-1:bot-2:1"]);
|
|
159
|
+
expect(first.assignment.generation).toBe(second.assignment.generation);
|
|
160
|
+
expect(first.identity).toEqual(second.identity);
|
|
161
|
+
// Separation between tenants is organizational: each gets its own
|
|
162
|
+
// directory and desktop on the one Computer.
|
|
163
|
+
expect(first.tenant.directory).not.toBe(second.tenant.directory);
|
|
164
|
+
expect(first.tenant.display).not.toBe(second.tenant.display);
|
|
165
|
+
await root.fiber.dispose();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a second User gets a separate Computer assignment", async () => {
|
|
169
|
+
const root = new Context();
|
|
170
|
+
await root.plugin(ComputerRegistry);
|
|
171
|
+
root.computers.register(provider("sprites", []));
|
|
172
|
+
|
|
173
|
+
root.computers.assign({ userId: "user-1" }, "sprites");
|
|
174
|
+
|
|
175
|
+
expect(root.computers.assignment({ userId: "user-2" })).toBeUndefined();
|
|
176
|
+
expect(computerIdentityKeyV1({ userId: "user-1" })).not.toBe(
|
|
177
|
+
computerIdentityKeyV1({ userId: "user-2" }),
|
|
178
|
+
);
|
|
179
|
+
await root.fiber.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("increments the generation when a User changes provider", async () => {
|
|
183
|
+
const root = new Context();
|
|
184
|
+
await root.plugin(ComputerRegistry);
|
|
185
|
+
root.computers.register(provider("sprites", []));
|
|
186
|
+
root.computers.register(provider("local", []));
|
|
187
|
+
const identity = { userId: "user-1" };
|
|
188
|
+
|
|
189
|
+
expect(root.computers.assign(identity, "sprites").generation).toBe(1);
|
|
190
|
+
expect(root.computers.assign(identity, "local")).toMatchObject({
|
|
191
|
+
providerId: "local",
|
|
192
|
+
generation: 2,
|
|
193
|
+
});
|
|
194
|
+
await root.fiber.dispose();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rejects operations through a stale handle after provider reassignment", async () => {
|
|
198
|
+
const root = new Context();
|
|
199
|
+
await root.plugin(ComputerRegistry);
|
|
200
|
+
const executable = (id: string): ComputerProvider => ({
|
|
201
|
+
id,
|
|
202
|
+
open: async (identity, tenant, assignment) => ({
|
|
203
|
+
assignment,
|
|
204
|
+
identity,
|
|
205
|
+
tenant,
|
|
206
|
+
exec: {
|
|
207
|
+
execute: async () => ({
|
|
208
|
+
exitCode: 0,
|
|
209
|
+
stdout: new Uint8Array(),
|
|
210
|
+
stderr: new Uint8Array(),
|
|
211
|
+
outputTruncated: false,
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
close: () => Promise.resolve(),
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
root.computers.register(executable("sprites"));
|
|
218
|
+
root.computers.register(executable("local"));
|
|
219
|
+
const identity = { userId: "user-1" };
|
|
220
|
+
root.computers.assign(identity, "sprites");
|
|
221
|
+
const oldComputer = await root.computers.open(identity, {
|
|
222
|
+
botId: "bot-1",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
root.computers.assign(identity, "local");
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
oldComputer.exec?.execute({ executable: "true" }),
|
|
229
|
+
).rejects.toMatchObject({ code: "stale-assignment" });
|
|
230
|
+
await root.fiber.dispose();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("fails clearly when a User has no Computer assignment", async () => {
|
|
234
|
+
const root = new Context();
|
|
235
|
+
await root.plugin(ComputerRegistry);
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
root.computers.open({ userId: "user-1" }, { botId: "bot-1" }),
|
|
239
|
+
).rejects.toMatchObject({
|
|
240
|
+
code: "not-assigned",
|
|
241
|
+
} satisfies Partial<ComputerError>);
|
|
242
|
+
await root.fiber.dispose();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("refuses an empty User or Bot identifier", async () => {
|
|
246
|
+
const root = new Context();
|
|
247
|
+
await root.plugin(ComputerRegistry);
|
|
248
|
+
root.computers.register(provider("sprites", []));
|
|
249
|
+
root.computers.assign({ userId: "user-1" }, "sprites");
|
|
250
|
+
|
|
251
|
+
expect(() => computerIdentityKeyV1({ userId: " " })).toThrow(
|
|
252
|
+
ComputerError,
|
|
253
|
+
);
|
|
254
|
+
await expect(
|
|
255
|
+
root.computers.open({ userId: "user-1" }, { botId: " " }),
|
|
256
|
+
).rejects.toMatchObject({ code: "invalid-request" });
|
|
257
|
+
await root.fiber.dispose();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Parity row 27: the report a Computer's self-check prints, decoded at the
|
|
262
|
+
// seam it crosses. Exact-field, no migrations: a Computer that answered
|
|
263
|
+
// something else is a Computer that answered something else.
|
|
264
|
+
describe("the self-check report", () => {
|
|
265
|
+
const report = {
|
|
266
|
+
schemaVersion: 2,
|
|
267
|
+
generation: 3,
|
|
268
|
+
capturedAt: "2026-09-01T00:00:00Z",
|
|
269
|
+
checks: [{ name: "disk-root", status: "pass", detail: "12% full" }],
|
|
270
|
+
browserIdentity: null,
|
|
271
|
+
summary: "1 checks, 1 passed, 0 failed",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
test("decodes a report the Computer printed", () => {
|
|
275
|
+
expect(decodeComputerDoctorReportV1(report)).toEqual({
|
|
276
|
+
schemaVersion: 2,
|
|
277
|
+
generation: 3,
|
|
278
|
+
capturedAt: "2026-09-01T00:00:00Z",
|
|
279
|
+
checks: [{ name: "disk-root", status: "pass", detail: "12% full" }],
|
|
280
|
+
summary: "1 checks, 1 passed, 0 failed",
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Parity row 34b. The measurement is optional because a Computer with no
|
|
285
|
+
// browser running has nothing to measure, and `null` says that rather than
|
|
286
|
+
// pretending to an empty user agent.
|
|
287
|
+
test("keeps the browser measurement when the Computer made one", () => {
|
|
288
|
+
expect(
|
|
289
|
+
decodeComputerDoctorReportV1({
|
|
290
|
+
...report,
|
|
291
|
+
browserIdentity: {
|
|
292
|
+
userAgent: "Mozilla/5.0 … Chrome/141.0.0.0 Safari/537.36",
|
|
293
|
+
webdriver: false,
|
|
294
|
+
brands: ["Chromium/141", "Not?A_Brand/24"],
|
|
295
|
+
},
|
|
296
|
+
})?.browserIdentity,
|
|
297
|
+
).toEqual({
|
|
298
|
+
userAgent: "Mozilla/5.0 … Chrome/141.0.0.0 Safari/537.36",
|
|
299
|
+
webdriver: false,
|
|
300
|
+
brands: ["Chromium/141", "Not?A_Brand/24"],
|
|
301
|
+
});
|
|
302
|
+
expect(
|
|
303
|
+
decodeComputerDoctorReportV1({ ...report, browserIdentity: undefined }),
|
|
304
|
+
).not.toHaveProperty("browserIdentity");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("keeps a failing check as a report, not as a decode failure", () => {
|
|
308
|
+
const failing = {
|
|
309
|
+
...report,
|
|
310
|
+
checks: [{ name: "dns", status: "fail", detail: "no resolver" }],
|
|
311
|
+
};
|
|
312
|
+
expect(decodeComputerDoctorReportV1(failing)?.checks[0]?.status).toBe(
|
|
313
|
+
"fail",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("refuses anything that is not this schema", () => {
|
|
318
|
+
for (const value of [
|
|
319
|
+
undefined,
|
|
320
|
+
null,
|
|
321
|
+
"{}",
|
|
322
|
+
{ ...report, schemaVersion: 1 },
|
|
323
|
+
{ ...report, schemaVersion: 3 },
|
|
324
|
+
{ ...report, browserIdentity: "chrome" },
|
|
325
|
+
{ ...report, browserIdentity: { webdriver: false, brands: [] } },
|
|
326
|
+
{
|
|
327
|
+
...report,
|
|
328
|
+
browserIdentity: { userAgent: "", webdriver: false, brands: [] },
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
...report,
|
|
332
|
+
browserIdentity: { userAgent: "Chrome", webdriver: "no", brands: [] },
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
...report,
|
|
336
|
+
browserIdentity: { userAgent: "Chrome", webdriver: false, brands: "x" },
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
...report,
|
|
340
|
+
browserIdentity: { userAgent: "Chrome", webdriver: false, brands: [7] },
|
|
341
|
+
},
|
|
342
|
+
{ ...report, generation: "3" },
|
|
343
|
+
{ ...report, capturedAt: "" },
|
|
344
|
+
{ ...report, summary: 12 },
|
|
345
|
+
{ ...report, checks: [] },
|
|
346
|
+
{ ...report, checks: "none" },
|
|
347
|
+
{ ...report, checks: [{ name: "x", status: "maybe", detail: "" }] },
|
|
348
|
+
{ ...report, checks: [{ name: "", status: "pass", detail: "" }] },
|
|
349
|
+
{ ...report, checks: [{ status: "pass", detail: "" }] },
|
|
350
|
+
]) {
|
|
351
|
+
expect(
|
|
352
|
+
decodeComputerDoctorReportV1(value),
|
|
353
|
+
JSON.stringify(value),
|
|
354
|
+
).toBeUndefined();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
});
|
package/tsconfig.json
ADDED
package/README.md
DELETED