@frockbot/plugin-fly-sprite 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.
@@ -0,0 +1,928 @@
1
+ /// <reference types="bun" />
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ isLoadableSkillSourceV1,
7
+ WORKSPACE_MAX_FILE_BYTES,
8
+ WORKSPACE_MAX_LIST_ENTRIES,
9
+ type WorkspaceGenerationsV1,
10
+ type WorkspaceRootV1,
11
+ type WorkspaceWriterV1,
12
+ } from "@frockbot/kernel-contracts";
13
+ import { createObjectWorkspaceFilesV1 } from "@frockbot/workspace-store";
14
+ import {
15
+ createInMemoryObjectBucketV1,
16
+ createInMemoryWorkspaceGenerationsV1,
17
+ } from "@frockbot/workspace-store/testing";
18
+ import {
19
+ computerBotKey,
20
+ type ComputerHostFactoryV1,
21
+ FlySpriteComputer,
22
+ } from "./computer.ts";
23
+ import { FakeComputerHost, type FakeComputerRunV1 } from "./host-double.ts";
24
+ import { FLY_WORKSPACE_LAYOUT, FlySpriteComputerProvider } from "./provider.ts";
25
+ import { FlyComputerWorkspace } from "./workspace.ts";
26
+
27
+ const USER = "owner";
28
+ const BOT = "health";
29
+ const OTHER_BOT = "general";
30
+
31
+ const USER_WRITER: WorkspaceWriterV1 = { kind: "user", userId: USER };
32
+ const BOT_WRITER: WorkspaceWriterV1 = {
33
+ kind: "bot",
34
+ botId: BOT,
35
+ sessionId: "session-1",
36
+ turnId: "turn-1",
37
+ runId: "run-1",
38
+ };
39
+ const PACKAGE_WRITER: WorkspaceWriterV1 = {
40
+ kind: "first-party",
41
+ packageId: "@frockbot/plugin-skills",
42
+ };
43
+
44
+ const skillsRoot: WorkspaceRootV1 = {
45
+ kind: "bot-instructions",
46
+ userId: USER,
47
+ botId: BOT,
48
+ };
49
+ const otherSkillsRoot: WorkspaceRootV1 = {
50
+ kind: "bot-instructions",
51
+ userId: USER,
52
+ botId: OTHER_BOT,
53
+ };
54
+ const botMemoryRoot: WorkspaceRootV1 = {
55
+ kind: "bot-memory",
56
+ userId: USER,
57
+ botId: BOT,
58
+ };
59
+ const userMemoryRoot: WorkspaceRootV1 = { kind: "user-memory", userId: USER };
60
+ const packageRoot: WorkspaceRootV1 = {
61
+ kind: "package-declared",
62
+ userId: USER,
63
+ packageId: "@frockbot/plugin-notes",
64
+ rootId: "notes",
65
+ };
66
+
67
+ function sha256(bytes: Uint8Array): string {
68
+ return createHash("sha256").update(bytes).digest("hex");
69
+ }
70
+
71
+ function quoted(shell: string, name: string): string | undefined {
72
+ return new RegExp(`${name}='([^']*)'`).exec(shell)?.[1];
73
+ }
74
+
75
+ /**
76
+ * A Computer whose durable filesystem is an in-memory map. It interprets the
77
+ * shell the Workspace surface emits rather than running it, because the
78
+ * scripts are GNU coreutils and the test host is not.
79
+ */
80
+ class FakeWorkspaceDisk {
81
+ readonly files = new Map<string, { bytes: Uint8Array; meta?: string }>();
82
+ offline = false;
83
+ modifiedSeconds = 1_700_000_000;
84
+
85
+ /** The runner the shared host double hands every script to. */
86
+ readonly run = (script: string): FakeComputerRunV1 => {
87
+ if (this.offline) return { exitCode: 1, stderr: "Sprite is paused" };
88
+ const root = quoted(script, "ROOT");
89
+ const relative = quoted(script, "REL");
90
+ if (!root) return {};
91
+ if (script.includes("__WRITTEN__") && relative) {
92
+ return { stdout: this.write(`${root}/${relative}`, script) };
93
+ }
94
+ if (script.includes("__DELETED__") && relative) {
95
+ return { stdout: this.remove(`${root}/${relative}`, script) };
96
+ }
97
+ if (script.includes('find "$ROOT"')) {
98
+ return { stdout: this.list(root, script) };
99
+ }
100
+ if (relative) {
101
+ return {
102
+ stdout: this.load(
103
+ `${root}/${relative}`,
104
+ script.includes('base64 -w0 "$TARGET"'),
105
+ ),
106
+ };
107
+ }
108
+ return {};
109
+ };
110
+
111
+ private current(path: string): string {
112
+ const entry = this.files.get(path);
113
+ if (!entry) return "";
114
+ if (!entry.meta) return "__UNRECORDED__";
115
+ return (
116
+ Buffer.from(entry.meta, "base64").toString("utf8").split("\n")[0] ?? ""
117
+ );
118
+ }
119
+
120
+ private expected(shell: string): string {
121
+ return /if \[ "\$CURRENT" != '([^']*)' \]/.exec(shell)?.[1] ?? "";
122
+ }
123
+
124
+ private write(path: string, shell: string): string {
125
+ if (this.current(path) !== this.expected(shell)) return "__CONFLICT__\n";
126
+ const bytes = /printf %s '([^']*)' \| base64 -d > "\$TMP"/.exec(shell)?.[1];
127
+ const meta = /printf %s '([^']*)' \| base64 -d > "\$MTMP"/.exec(shell)?.[1];
128
+ this.files.set(path, {
129
+ bytes: Uint8Array.from(Buffer.from(bytes ?? "", "base64")),
130
+ meta,
131
+ });
132
+ return "__WRITTEN__\n";
133
+ }
134
+
135
+ private remove(path: string, shell: string): string {
136
+ if (!this.files.has(path)) return "__MISSING__\n";
137
+ if (this.current(path) !== this.expected(shell)) return "__CONFLICT__\n";
138
+ this.files.delete(path);
139
+ return "__DELETED__\n";
140
+ }
141
+
142
+ private load(path: string, withBytes: boolean): string {
143
+ const entry = this.files.get(path);
144
+ if (!entry) return "__MISSING__\n";
145
+ if (entry.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
146
+ return "__TOO_LARGE__\n";
147
+ }
148
+ const lines = [
149
+ entry.meta ?? "",
150
+ sha256(entry.bytes),
151
+ String(entry.bytes.byteLength),
152
+ String(this.modifiedSeconds),
153
+ ];
154
+ if (withBytes) lines.push(Buffer.from(entry.bytes).toString("base64"));
155
+ return `${lines.join("\n")}\n`;
156
+ }
157
+
158
+ private list(root: string, shell: string): string {
159
+ const offset = Number(/OFFSET=(\d+)/.exec(shell)?.[1] ?? 0);
160
+ const limit = Number(/LIMIT=(\d+)/.exec(shell)?.[1] ?? 100);
161
+ const prefix = quoted(shell, "PREFIX") ?? "";
162
+ const rows = [...this.files.entries()]
163
+ .filter(([path]) => path.startsWith(`${root}/`))
164
+ .map(([path, entry]) => [path.slice(root.length + 1), entry] as const)
165
+ .filter(
166
+ ([relative]) =>
167
+ !prefix || relative === prefix || relative.startsWith(`${prefix}/`),
168
+ )
169
+ .sort(([left], [right]) => (left < right ? -1 : 1))
170
+ .slice(offset, offset + limit + 1)
171
+ .map(([relative, entry]) =>
172
+ [
173
+ Buffer.from(relative).toString("base64"),
174
+ entry.meta ?? "",
175
+ sha256(entry.bytes),
176
+ String(entry.bytes.byteLength),
177
+ String(this.modifiedSeconds),
178
+ ].join("\t"),
179
+ );
180
+ return rows.length ? `${rows.join("\n")}\n` : "";
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The host double over one such disk, with `open` and `viewer` fenced off:
186
+ * reaching a Workspace file must never provision a desktop or publish a
187
+ * viewer, and a fixture that answered them would hide it if one did.
188
+ */
189
+ function hostFor(disk: FakeWorkspaceDisk): {
190
+ host: FakeComputerHost;
191
+ factory: ComputerHostFactoryV1;
192
+ } {
193
+ const host = new FakeComputerHost(disk.run);
194
+ const factory: ComputerHostFactoryV1 = (identity, tenant) => ({
195
+ ...host.factory(identity, tenant),
196
+ open(): never {
197
+ throw new Error("Workspace access must not provision a desktop");
198
+ },
199
+ viewer(): never {
200
+ throw new Error("Workspace access must not publish a viewer");
201
+ },
202
+ });
203
+ return { host, factory };
204
+ }
205
+
206
+ /**
207
+ * The Workspace a User's own authority opens, rather than a Bot's. The
208
+ * shell and Turn paths open a Computer as the Bot, so this is the only shape
209
+ * in which a `user` writer is admitted.
210
+ */
211
+ function openUserWorkspace(
212
+ botId = BOT,
213
+ disk = new FakeWorkspaceDisk(),
214
+ generations: WorkspaceGenerationsV1 | "none" = ledger(),
215
+ ) {
216
+ const injected = generations === "none" ? undefined : generations;
217
+ const { host, factory } = hostFor(disk);
218
+ const computer = new FlySpriteComputer({
219
+ identity: { userId: "workspace-user" },
220
+ host: factory,
221
+ spriteName: "frockbot-test",
222
+ }).bot(botId);
223
+ return {
224
+ disk,
225
+ host,
226
+ generations: injected,
227
+ workspace: new FlyComputerWorkspace(FLY_WORKSPACE_LAYOUT, {
228
+ computer,
229
+ userId: USER,
230
+ botId,
231
+ botDirectoryKey: computerBotKey,
232
+ userAuthority: true,
233
+ ...(injected ? { generations: injected } : {}),
234
+ }),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * The Durable Object's generation ledger, in memory. The Computer's Workspace
240
+ * can attribute nothing without one — a sidecar on the Computer is a hint, and
241
+ * the ledger is the authority — so every handle a Turn opens carries it.
242
+ */
243
+ function ledger(): WorkspaceGenerationsV1 {
244
+ return createInMemoryWorkspaceGenerationsV1();
245
+ }
246
+
247
+ /** A sync host over one in-memory bucket, sharing the ledger with the handle. */
248
+ function syncHostFor(generations: WorkspaceGenerationsV1) {
249
+ return {
250
+ store: createObjectWorkspaceFilesV1({
251
+ bucket: createInMemoryObjectBucketV1(),
252
+ generations,
253
+ owner: { userId: USER },
254
+ surface: "sync" as const,
255
+ }),
256
+ generations,
257
+ };
258
+ }
259
+
260
+ async function openWorkspace(
261
+ botId = BOT,
262
+ disk = new FakeWorkspaceDisk(),
263
+ generations: WorkspaceGenerationsV1 | "none" = ledger(),
264
+ ) {
265
+ const injected = generations === "none" ? undefined : generations;
266
+ const { host, factory } = hostFor(disk);
267
+ const provider = new FlySpriteComputerProvider(
268
+ new FlySpriteComputer({
269
+ identity: { userId: "workspace-user" },
270
+ host: factory,
271
+ spriteName: "frockbot-test",
272
+ }),
273
+ factory,
274
+ injected ? syncHostFor(injected) : undefined,
275
+ );
276
+ const computer = await provider.open(
277
+ { userId: USER },
278
+ { botId },
279
+ { providerId: "fly-sprite", generation: 1 },
280
+ );
281
+ const workspace = computer.workspace;
282
+ if (!workspace) throw new Error("The Fly provider must expose a Workspace");
283
+ return { disk, host, workspace, computer, generations: injected };
284
+ }
285
+
286
+ describe("Fly Workspace layout", () => {
287
+ // Constitution — Computer and Workspace: "durable roots, declared by the
288
+ // Computer Package's Workspace layout"; ADR 0013: the Workspace presents
289
+ // Memory roots read-only.
290
+ test("declares instruction, Memory, and Package roots, with Memory and the User-global instruction root read-only", () => {
291
+ expect(FLY_WORKSPACE_LAYOUT.home).toBe("/home/box");
292
+ expect(
293
+ Object.fromEntries(
294
+ FLY_WORKSPACE_LAYOUT.roots.map((root) => [
295
+ root.kind,
296
+ [root.mountPath, root.access, root.scope],
297
+ ]),
298
+ ),
299
+ ).toEqual({
300
+ "bot-instructions": [
301
+ "/home/box/agent-data/agents/{bot}/skills",
302
+ "read-write",
303
+ "bot",
304
+ ],
305
+ // GrokBot's `agent-data/workflows`, read-only on the Computer because
306
+ // the Skills Package writes it through object storage (ADR 0016).
307
+ "user-instructions": [
308
+ "/home/box/agent-data/workflows",
309
+ "read-only",
310
+ "user",
311
+ ],
312
+ "bot-memory": [
313
+ "/home/box/agent-data/agents/{bot}/memory",
314
+ "read-only",
315
+ "bot",
316
+ ],
317
+ "user-memory": ["/home/box/agent-data/user-memory", "read-only", "user"],
318
+ "package-declared": [
319
+ "/home/box/agent-data/user-packages/{package}/{root}",
320
+ "read-write",
321
+ "user",
322
+ ],
323
+ });
324
+ });
325
+ });
326
+
327
+ describe("Fly Workspace files", () => {
328
+ // Constitution — Computer and Workspace: "every write to a durable root
329
+ // records its writer."
330
+ test("records the writer of every durable-root write and answers with the generation", async () => {
331
+ const { workspace } = await openWorkspace();
332
+
333
+ const written = await workspace.write({
334
+ path: { root: skillsRoot, path: "deploy/SKILL.md" },
335
+ bytes: new TextEncoder().encode("# deploy"),
336
+ writer: BOT_WRITER,
337
+ expectedGenerationId: null,
338
+ });
339
+
340
+ expect(written).toMatchObject({ status: "ok" });
341
+ if (written.status !== "ok") throw new Error(written.reason);
342
+ expect(written.generation).toMatchObject({
343
+ schemaVersion: 1,
344
+ contentHash: sha256(new TextEncoder().encode("# deploy")),
345
+ size: 8,
346
+ writer: BOT_WRITER,
347
+ });
348
+
349
+ const stat = await workspace.stat({
350
+ root: skillsRoot,
351
+ path: "deploy/SKILL.md",
352
+ });
353
+ expect(stat).toMatchObject({
354
+ status: "ok",
355
+ entry: { generation: { writer: BOT_WRITER } },
356
+ });
357
+ const read = await workspace.read({
358
+ root: skillsRoot,
359
+ path: "deploy/SKILL.md",
360
+ });
361
+ if (read.status !== "ok") throw new Error(read.reason);
362
+ expect(new TextDecoder().decode(read.file.bytes)).toBe("# deploy");
363
+ expect(read.file.generation.generationId).toBe(
364
+ written.generation.generationId,
365
+ );
366
+ });
367
+
368
+ // Constitution — Memory: "the Workspace presents Memory roots read-only".
369
+ test("refuses a write to either Memory root through the kernel-consumed surface", async () => {
370
+ const { workspace } = await openWorkspace();
371
+
372
+ for (const root of [botMemoryRoot, userMemoryRoot]) {
373
+ expect(
374
+ await workspace.write({
375
+ path: { root, path: "profile.md" },
376
+ bytes: new TextEncoder().encode("fact"),
377
+ writer: BOT_WRITER,
378
+ expectedGenerationId: null,
379
+ }),
380
+ ).toMatchObject({ status: "refused" });
381
+ expect(
382
+ await workspace.delete({
383
+ path: { root, path: "profile.md" },
384
+ writer: BOT_WRITER,
385
+ expectedGenerationId: "whatever",
386
+ }),
387
+ ).toMatchObject({ status: "refused" });
388
+ }
389
+ });
390
+
391
+ // A Memory root the sync materialized is readable through the kernel
392
+ // surface: read-only, not invisible.
393
+ test("reads a Memory root the sync materialized, and refuses to write it", async () => {
394
+ const { disk, workspace } = await openWorkspace();
395
+ disk.files.set(
396
+ `/home/box/agent-data/agents/${computerBotKey(BOT)}/memory/profile.md`,
397
+ { bytes: new TextEncoder().encode("fact") },
398
+ );
399
+
400
+ expect(
401
+ await workspace.read({ root: botMemoryRoot, path: "profile.md" }),
402
+ ).toMatchObject({ status: "ok" });
403
+ expect(
404
+ await workspace.write({
405
+ path: { root: botMemoryRoot, path: "profile.md" },
406
+ bytes: new TextEncoder().encode("other"),
407
+ writer: BOT_WRITER,
408
+ expectedGenerationId: null,
409
+ }),
410
+ ).toMatchObject({ status: "refused" });
411
+ });
412
+
413
+ // Constitution — Computer and Workspace: "a Bot's instruction root and Bot
414
+ // Memory root are writable only by that Bot or its User."
415
+ test("refuses a write to another Bot's instruction root and a first-party writer", async () => {
416
+ const { workspace } = await openWorkspace();
417
+
418
+ expect(
419
+ await workspace.write({
420
+ path: { root: otherSkillsRoot, path: "SKILL.md" },
421
+ bytes: new TextEncoder().encode("x"),
422
+ writer: BOT_WRITER,
423
+ expectedGenerationId: null,
424
+ }),
425
+ ).toMatchObject({ status: "refused" });
426
+ expect(
427
+ await workspace.write({
428
+ path: { root: skillsRoot, path: "SKILL.md" },
429
+ bytes: new TextEncoder().encode("x"),
430
+ writer: PACKAGE_WRITER,
431
+ expectedGenerationId: null,
432
+ }),
433
+ ).toMatchObject({ status: "refused" });
434
+ // The Bot's User may write it — from a handle the User opened.
435
+ expect(
436
+ await openUserWorkspace().workspace.write({
437
+ path: { root: skillsRoot, path: "SKILL.md" },
438
+ bytes: new TextEncoder().encode("x"),
439
+ writer: USER_WRITER,
440
+ expectedGenerationId: null,
441
+ }),
442
+ ).toMatchObject({ status: "ok" });
443
+ });
444
+
445
+ // Constitution — Computer and Workspace: "every write to a durable root
446
+ // records its writer." The writer a request names is a claim; the handle's
447
+ // tenant is the authority on it, or a Bot could record another Bot as the
448
+ // writer of a file and a Package root would accept it.
449
+ test("refuses a write naming a Bot that is not the handle's tenant", async () => {
450
+ const { workspace } = await openWorkspace();
451
+ const otherBotWriter: WorkspaceWriterV1 = {
452
+ ...BOT_WRITER,
453
+ botId: OTHER_BOT,
454
+ };
455
+
456
+ expect(
457
+ await workspace.write({
458
+ path: { root: otherSkillsRoot, path: "SKILL.md" },
459
+ bytes: new TextEncoder().encode("x"),
460
+ writer: otherBotWriter,
461
+ expectedGenerationId: null,
462
+ }),
463
+ ).toMatchObject({ status: "refused" });
464
+ expect(
465
+ await workspace.write({
466
+ path: { root: packageRoot, path: "shared.md" },
467
+ bytes: new TextEncoder().encode("x"),
468
+ writer: otherBotWriter,
469
+ expectedGenerationId: null,
470
+ }),
471
+ ).toMatchObject({ status: "refused" });
472
+ });
473
+
474
+ // "Only Skills under the Bot's own instruction root, written under the Bot's
475
+ // own authority or its User's, are loaded as instructions." A Bot that could
476
+ // name its User as the writer would author itself a loadable Skill under an
477
+ // authority it does not hold.
478
+ test("refuses a user writer from a handle opened for a Bot", async () => {
479
+ const { workspace } = await openWorkspace();
480
+
481
+ expect(
482
+ await workspace.write({
483
+ path: { root: skillsRoot, path: "SKILL.md" },
484
+ bytes: new TextEncoder().encode("x"),
485
+ writer: USER_WRITER,
486
+ expectedGenerationId: null,
487
+ }),
488
+ ).toMatchObject({ status: "refused" });
489
+ expect(
490
+ await workspace.delete({
491
+ path: { root: skillsRoot, path: "SKILL.md" },
492
+ writer: USER_WRITER,
493
+ expectedGenerationId: "whatever",
494
+ }),
495
+ ).toMatchObject({ status: "refused" });
496
+ // A User handle for a different User is refused by the root check too.
497
+ expect(
498
+ await openUserWorkspace().workspace.write({
499
+ path: { root: skillsRoot, path: "SKILL.md" },
500
+ bytes: new TextEncoder().encode("x"),
501
+ writer: { kind: "user", userId: "someone-else" },
502
+ expectedGenerationId: null,
503
+ }),
504
+ ).toMatchObject({ status: "refused" });
505
+ });
506
+
507
+ // ADR 0012: "Bots of one User may read each other's Workspace files" —
508
+ // separation between tenants is organizational, not a security boundary.
509
+ test("a Bot reads another Bot of the same User's Workspace file", async () => {
510
+ const disk = new FakeWorkspaceDisk();
511
+ const owner = await openWorkspace(OTHER_BOT, disk);
512
+ await owner.workspace.write({
513
+ path: { root: otherSkillsRoot, path: "notes.md" },
514
+ bytes: new TextEncoder().encode("shared"),
515
+ writer: { ...BOT_WRITER, botId: OTHER_BOT },
516
+ expectedGenerationId: null,
517
+ });
518
+
519
+ const reader = await openWorkspace(BOT, disk);
520
+ const read = await reader.workspace.read({
521
+ root: otherSkillsRoot,
522
+ path: "notes.md",
523
+ });
524
+
525
+ if (read.status !== "ok") throw new Error(read.reason);
526
+ expect(new TextDecoder().decode(read.file.bytes)).toBe("shared");
527
+ });
528
+
529
+ test("refuses every root belonging to another User", async () => {
530
+ const { workspace } = await openWorkspace();
531
+
532
+ expect(
533
+ await workspace.read({
534
+ root: { kind: "user-memory", userId: "someone-else" },
535
+ path: "profile.md",
536
+ }),
537
+ ).toMatchObject({ status: "refused" });
538
+ });
539
+
540
+ // ADR 0013: a write that would overwrite a generation its writer has not
541
+ // seen is never silently merged.
542
+ test("answers conflict when the expected generation is not the current one", async () => {
543
+ const { workspace } = await openWorkspace();
544
+ const first = await workspace.write({
545
+ path: { root: packageRoot, path: "a.md" },
546
+ bytes: new TextEncoder().encode("one"),
547
+ writer: BOT_WRITER,
548
+ expectedGenerationId: null,
549
+ });
550
+ if (first.status !== "ok") throw new Error(first.reason);
551
+
552
+ expect(
553
+ await workspace.write({
554
+ path: { root: packageRoot, path: "a.md" },
555
+ bytes: new TextEncoder().encode("two"),
556
+ writer: BOT_WRITER,
557
+ expectedGenerationId: null,
558
+ }),
559
+ ).toMatchObject({ status: "conflict" });
560
+ expect(
561
+ await workspace.write({
562
+ path: { root: packageRoot, path: "a.md" },
563
+ bytes: new TextEncoder().encode("two"),
564
+ writer: BOT_WRITER,
565
+ expectedGenerationId: first.generation.generationId,
566
+ }),
567
+ ).toMatchObject({ status: "ok" });
568
+ });
569
+
570
+ test("bounds a write at the contract's file size and refuses a traversal path", async () => {
571
+ const { workspace } = await openWorkspace();
572
+
573
+ expect(
574
+ await workspace.write({
575
+ path: { root: packageRoot, path: "big.bin" },
576
+ bytes: new Uint8Array(WORKSPACE_MAX_FILE_BYTES + 1),
577
+ writer: BOT_WRITER,
578
+ expectedGenerationId: null,
579
+ }),
580
+ ).toMatchObject({ status: "refused" });
581
+ expect(
582
+ await workspace.read({ root: packageRoot, path: "../escape" }),
583
+ ).toMatchObject({ status: "refused" });
584
+ });
585
+
586
+ test("lists a root by page and bounds a page at the contract limit", async () => {
587
+ const { host, workspace } = await openWorkspace();
588
+ for (let index = 0; index < 5; index += 1) {
589
+ await workspace.write({
590
+ path: { root: packageRoot, path: `note-${index}.md` },
591
+ bytes: new TextEncoder().encode(String(index)),
592
+ writer: BOT_WRITER,
593
+ expectedGenerationId: null,
594
+ });
595
+ }
596
+
597
+ const page = await workspace.list({ root: packageRoot, limit: 2 });
598
+ if (page.status !== "ok") throw new Error(page.reason);
599
+ expect(page.entries.map((entry) => entry.path.path)).toEqual([
600
+ "note-0.md",
601
+ "note-1.md",
602
+ ]);
603
+ expect(page.cursor).toBe("2");
604
+ expect(page.entries[0]?.generation.writer).toEqual(BOT_WRITER);
605
+
606
+ const rest = await workspace.list({
607
+ root: packageRoot,
608
+ cursor: page.cursor,
609
+ limit: 10,
610
+ });
611
+ if (rest.status !== "ok") throw new Error(rest.reason);
612
+ expect(rest.entries).toHaveLength(3);
613
+ expect(rest.cursor).toBeUndefined();
614
+
615
+ await workspace.list({ root: packageRoot, limit: 10_000 });
616
+ expect(host.scripts.at(-1)).toContain(
617
+ `LIMIT=${WORKSPACE_MAX_LIST_ENTRIES}`,
618
+ );
619
+ });
620
+
621
+ test("deletes with a tombstone generation and then answers not-found", async () => {
622
+ const { workspace } = await openWorkspace();
623
+ const written = await workspace.write({
624
+ path: { root: packageRoot, path: "gone.md" },
625
+ bytes: new TextEncoder().encode("bye"),
626
+ writer: BOT_WRITER,
627
+ expectedGenerationId: null,
628
+ });
629
+ if (written.status !== "ok") throw new Error(written.reason);
630
+
631
+ const removed = await workspace.delete({
632
+ path: { root: packageRoot, path: "gone.md" },
633
+ writer: BOT_WRITER,
634
+ expectedGenerationId: written.generation.generationId,
635
+ });
636
+
637
+ expect(removed).toMatchObject({ status: "ok" });
638
+ if (removed.status !== "ok") throw new Error(removed.reason);
639
+ expect(removed.generation.size).toBe(0);
640
+ expect(removed.generation.writer).toEqual(BOT_WRITER);
641
+ expect(
642
+ await workspace.read({ root: packageRoot, path: "gone.md" }),
643
+ ).toMatchObject({ status: "not-found" });
644
+ expect(
645
+ await workspace.delete({
646
+ path: { root: packageRoot, path: "gone.md" },
647
+ writer: BOT_WRITER,
648
+ expectedGenerationId: written.generation.generationId,
649
+ }),
650
+ ).toMatchObject({ status: "not-found" });
651
+ });
652
+
653
+ // A file written by ordinary shell work went around this surface, so no
654
+ // sidecar records who wrote it. It is `unattributed` — not the User, not a
655
+ // Bot — so it is readable data and never loadable as a Skill.
656
+ test("attributes a file with no recorded writer as unattributed", async () => {
657
+ const { disk, workspace } = await openWorkspace();
658
+ disk.files.set(
659
+ `/home/box/agent-data/agents/${computerBotKey(BOT)}/skills/by-shell.md`,
660
+ { bytes: new TextEncoder().encode("hand-written") },
661
+ );
662
+
663
+ const stat = await workspace.stat({
664
+ root: skillsRoot,
665
+ path: "by-shell.md",
666
+ });
667
+
668
+ if (stat.status !== "ok") throw new Error(stat.reason);
669
+ expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
670
+
671
+ const listed = await workspace.list({ root: skillsRoot });
672
+ if (listed.status !== "ok") throw new Error(listed.reason);
673
+ expect(
674
+ listed.entries.find((entry) => entry.path.path === "by-shell.md")
675
+ ?.generation.writer,
676
+ ).toEqual({ kind: "unattributed" });
677
+ });
678
+
679
+ // A sidecar is an ordinary file beside the bytes it describes, so a shell can
680
+ // overwrite the bytes and leave the sidecar standing — or plant a sidecar of
681
+ // its own. The recorded content address is what such a write cannot forge
682
+ // without producing the bytes, so a sidecar that does not describe the file
683
+ // is stale or invented and the file is `unattributed`: the previous writer's
684
+ // authority does not survive a write that went around this surface.
685
+ test("answers unattributed when the sidecar does not describe the bytes", async () => {
686
+ const { disk, workspace } = await openWorkspace();
687
+ const path = `/home/box/agent-data/agents/${computerBotKey(BOT)}/skills/deploy/SKILL.md`;
688
+ const written = await workspace.write({
689
+ path: { root: skillsRoot, path: "deploy/SKILL.md" },
690
+ bytes: new TextEncoder().encode("---\nname: deploy\n---\n"),
691
+ writer: BOT_WRITER,
692
+ expectedGenerationId: null,
693
+ });
694
+ if (written.status !== "ok") throw new Error(written.reason);
695
+ const before = await workspace.stat({
696
+ root: skillsRoot,
697
+ path: "deploy/SKILL.md",
698
+ });
699
+ if (before.status !== "ok") throw new Error(before.reason);
700
+ expect(before.entry.generation.writer).toEqual(BOT_WRITER);
701
+
702
+ // A shell overwrites the file. The sidecar the surface wrote stays put.
703
+ const kept = disk.files.get(path);
704
+ disk.files.set(path, {
705
+ bytes: new TextEncoder().encode("---\nname: deploy\n---\nrm -rf /\n"),
706
+ ...(kept?.meta ? { meta: kept.meta } : {}),
707
+ });
708
+
709
+ const stat = await workspace.stat({
710
+ root: skillsRoot,
711
+ path: "deploy/SKILL.md",
712
+ });
713
+
714
+ if (stat.status !== "ok") throw new Error(stat.reason);
715
+ expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
716
+ expect(
717
+ isLoadableSkillSourceV1(
718
+ {
719
+ path: { root: skillsRoot, path: "deploy/SKILL.md" },
720
+ writer: stat.entry.generation.writer,
721
+ generation: stat.entry.generation,
722
+ },
723
+ { botId: BOT, userId: USER },
724
+ ),
725
+ ).toBe(false);
726
+ // The listing answers the same way, and so does a read of the bytes.
727
+ const listed = await workspace.list({ root: skillsRoot });
728
+ if (listed.status !== "ok") throw new Error(listed.reason);
729
+ expect(
730
+ listed.entries.find((entry) => entry.path.path === "deploy/SKILL.md")
731
+ ?.generation.writer,
732
+ ).toEqual({ kind: "unattributed" });
733
+ const read = await workspace.read({
734
+ root: skillsRoot,
735
+ path: "deploy/SKILL.md",
736
+ });
737
+ if (read.status !== "ok") throw new Error(read.reason);
738
+ expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
739
+ });
740
+
741
+ // A sidecar that does not decode at this seam is no sidecar at all.
742
+ test("answers unattributed when the sidecar does not decode", async () => {
743
+ const { disk, workspace } = await openWorkspace();
744
+ disk.files.set(
745
+ `/home/box/agent-data/agents/${computerBotKey(BOT)}/skills/planted.md`,
746
+ {
747
+ bytes: new TextEncoder().encode("body"),
748
+ meta: Buffer.from(
749
+ `forged\n${JSON.stringify({ writer: { kind: "user", userId: USER } })}`,
750
+ ).toString("base64"),
751
+ },
752
+ );
753
+
754
+ const stat = await workspace.stat({ root: skillsRoot, path: "planted.md" });
755
+
756
+ if (stat.status !== "ok") throw new Error(stat.reason);
757
+ expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
758
+ });
759
+
760
+ // "every write to a durable root records its writer": `unattributed` is an
761
+ // answer about a file nobody recorded, never a writer a caller may present.
762
+ test("refuses a write or a delete that names an unattributed writer", async () => {
763
+ const { workspace } = await openWorkspace();
764
+
765
+ expect(
766
+ await workspace.write({
767
+ path: { root: skillsRoot, path: "SKILL.md" },
768
+ bytes: new TextEncoder().encode("body"),
769
+ writer: { kind: "unattributed" },
770
+ expectedGenerationId: null,
771
+ }),
772
+ ).toMatchObject({ status: "refused" });
773
+ expect(
774
+ await workspace.delete({
775
+ path: { root: packageRoot, path: "a.md" },
776
+ writer: { kind: "unattributed" },
777
+ expectedGenerationId: "whatever",
778
+ }),
779
+ ).toMatchObject({ status: "refused" });
780
+ });
781
+
782
+ // Constitution — Computer and Workspace: connections drop on every pause, so
783
+ // "unavailable" is an ordinary answer, not an exception.
784
+ test("answers unavailable rather than throwing when the Sprite is paused", async () => {
785
+ const { disk, workspace } = await openWorkspace();
786
+ disk.offline = true;
787
+
788
+ expect(
789
+ await workspace.read({ root: packageRoot, path: "a.md" }),
790
+ ).toMatchObject({ status: "unavailable" });
791
+ expect(await workspace.list({ root: packageRoot })).toMatchObject({
792
+ status: "unavailable",
793
+ });
794
+ expect(
795
+ await workspace.write({
796
+ path: { root: packageRoot, path: "a.md" },
797
+ bytes: new Uint8Array(1),
798
+ writer: BOT_WRITER,
799
+ expectedGenerationId: null,
800
+ }),
801
+ ).toMatchObject({ status: "unavailable" });
802
+ });
803
+ });
804
+
805
+ describe("the Computer's sidecar is a hint; the Durable Object is the authority", () => {
806
+ const SKILLS_MOUNT = `/home/box/agent-data/agents/${computerBotKey(BOT)}/skills`;
807
+
808
+ /** What a shell on the Computer can write: bytes, and a sidecar for them. */
809
+ function plant(
810
+ disk: FakeWorkspaceDisk,
811
+ relative: string,
812
+ text: string,
813
+ generation: {
814
+ generationId: string;
815
+ writer: WorkspaceWriterV1;
816
+ writtenAt?: string;
817
+ },
818
+ ): void {
819
+ const bytes = new TextEncoder().encode(text);
820
+ const meta = {
821
+ schemaVersion: 1,
822
+ generationId: generation.generationId,
823
+ // The forger has the bytes, so it has their hash too.
824
+ contentHash: sha256(bytes),
825
+ size: bytes.byteLength,
826
+ writer: generation.writer,
827
+ writtenAt: generation.writtenAt ?? new Date(0).toISOString(),
828
+ };
829
+ disk.files.set(`${SKILLS_MOUNT}/${relative}`, {
830
+ bytes,
831
+ meta: Buffer.from(
832
+ `${meta.generationId}\n${JSON.stringify(meta)}`,
833
+ ).toString("base64"),
834
+ });
835
+ }
836
+
837
+ test("a forged sidecar whose hash matches the bytes is still unattributed", async () => {
838
+ const { disk, workspace } = await openWorkspace();
839
+ // A shell writes the file *and* a perfectly-formed sidecar claiming the
840
+ // Bot itself wrote it. Nothing about the bytes is wrong; only the ledger
841
+ // can tell, and it never recorded this generation.
842
+ plant(disk, "forged/SKILL.md", "# Forged", {
843
+ generationId: "000001700000000000-000001",
844
+ writer: BOT_WRITER,
845
+ });
846
+
847
+ const stat = await workspace.stat({
848
+ root: skillsRoot,
849
+ path: "forged/SKILL.md",
850
+ });
851
+ if (stat.status !== "ok") throw new Error(stat.reason);
852
+ expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
853
+ expect(
854
+ isLoadableSkillSourceV1(
855
+ {
856
+ path: stat.entry.path,
857
+ writer: stat.entry.generation.writer,
858
+ generation: stat.entry.generation,
859
+ },
860
+ { userId: USER, botId: BOT },
861
+ ),
862
+ ).toBe(false);
863
+ const listed = await workspace.list({ root: skillsRoot });
864
+ if (listed.status !== "ok") throw new Error(listed.reason);
865
+ expect(listed.entries[0]?.generation.writer).toEqual({
866
+ kind: "unattributed",
867
+ });
868
+ });
869
+
870
+ test("a write through the surface is attributed, because the ledger holds it", async () => {
871
+ const { disk, workspace, generations } = await openWorkspace();
872
+ const written = await workspace.write({
873
+ path: { root: skillsRoot, path: "authored/SKILL.md" },
874
+ bytes: new TextEncoder().encode("# Authored"),
875
+ writer: BOT_WRITER,
876
+ expectedGenerationId: null,
877
+ });
878
+ if (written.status !== "ok") throw new Error(written.reason);
879
+
880
+ const recorded = await generations?.current(
881
+ skillsRoot,
882
+ "authored/SKILL.md",
883
+ );
884
+ expect(recorded?.generation.generationId).toBe(
885
+ written.generation.generationId,
886
+ );
887
+ const read = await workspace.read({
888
+ root: skillsRoot,
889
+ path: "authored/SKILL.md",
890
+ });
891
+ if (read.status !== "ok") throw new Error(read.reason);
892
+ expect(read.file.generation.writer).toEqual(BOT_WRITER);
893
+
894
+ // And a shell overwriting those bytes takes the attribution with it: the
895
+ // record the ledger holds no longer describes what is on disk, and a
896
+ // sidecar re-forged over the new bytes names a generation the ledger
897
+ // never minted.
898
+ plant(disk, "authored/SKILL.md", "# Overwritten", {
899
+ generationId: written.generation.generationId,
900
+ writer: BOT_WRITER,
901
+ });
902
+ const after = await workspace.read({
903
+ root: skillsRoot,
904
+ path: "authored/SKILL.md",
905
+ });
906
+ if (after.status !== "ok") throw new Error(after.reason);
907
+ expect(after.file.generation.writer).toEqual({ kind: "unattributed" });
908
+ });
909
+
910
+ test("with no ledger injected, every file is unattributed", async () => {
911
+ const disk = new FakeWorkspaceDisk();
912
+ const { workspace } = openUserWorkspace(BOT, disk, "none");
913
+ const written = await workspace.write({
914
+ path: { root: skillsRoot, path: "local/SKILL.md" },
915
+ bytes: new TextEncoder().encode("# Local"),
916
+ writer: USER_WRITER,
917
+ expectedGenerationId: null,
918
+ });
919
+ if (written.status !== "ok") throw new Error(written.reason);
920
+
921
+ const read = await workspace.read({
922
+ root: skillsRoot,
923
+ path: "local/SKILL.md",
924
+ });
925
+ if (read.status !== "ok") throw new Error(read.reason);
926
+ expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
927
+ });
928
+ });