@frockbot/plugin-fly-sprite 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.
@@ -0,0 +1,145 @@
1
+ /// <reference types="bun" />
2
+
3
+ // `screenshotForAgent`: the two host operations a capture is made of.
4
+ //
5
+ // The subject is the pair — a guarded `exec` that runs `scrot` under the
6
+ // tenant's own display, and a `file/read` that brings the PNG back — because
7
+ // the pair is what makes the capture attributable. Leaving the file on the
8
+ // Computer would let the sync mirror it back `unattributed`, so the bytes must
9
+ // come off the Sprite before the Workspace writes them.
10
+ import { describe, expect, test } from "bun:test";
11
+ import { ComputerError } from "@frockbot/computer-core";
12
+ import { BOTS_ROOT } from "@frockbot/computer-host-runtime";
13
+ import {
14
+ computerBotKey,
15
+ FlySpriteComputer,
16
+ SCREENSHOT_MAX_BYTES,
17
+ } from "./computer.ts";
18
+ import { FakeComputerHost } from "./host-double.ts";
19
+ import { FlySpriteComputerProvider } from "./provider.ts";
20
+
21
+ const KEY = computerBotKey("health");
22
+ const PATH = `${BOTS_ROOT}/${KEY}/screenshot.png`;
23
+
24
+ function png(): Uint8Array {
25
+ const bytes = new Uint8Array(64);
26
+ bytes.set([137, 80, 78, 71, 13, 10, 26, 10], 0);
27
+ new DataView(bytes.buffer).setUint32(16, 1280);
28
+ new DataView(bytes.buffer).setUint32(20, 720);
29
+ return bytes;
30
+ }
31
+
32
+ function hostWith(size: number): FakeComputerHost {
33
+ const host = new FakeComputerHost((script) =>
34
+ script.includes("scrot") ? { stdout: `${size}\n` } : {},
35
+ );
36
+ host.files.set(PATH, png());
37
+ return host;
38
+ }
39
+
40
+ function computerOn(host: FakeComputerHost): FlySpriteComputer {
41
+ return new FlySpriteComputer({
42
+ identity: { userId: "owner" },
43
+ host: host.factory,
44
+ spriteName: "frockbot-test",
45
+ });
46
+ }
47
+
48
+ function signal(): AbortSignal {
49
+ return new AbortController().signal;
50
+ }
51
+
52
+ describe("screenshotForAgent", () => {
53
+ test("runs scrot under the tenant's display behind the control guard, then reads the PNG back", async () => {
54
+ const host = hostWith(64);
55
+ const computer = computerOn(host);
56
+ const bot = computer.bot("health");
57
+ await bot.ensure(signal());
58
+
59
+ const captured = await bot.screenshot(signal());
60
+
61
+ const script = host.scripts.find((candidate) =>
62
+ candidate.includes("scrot"),
63
+ );
64
+ expect(script).toBeDefined();
65
+ // The human-control guard first, then the tenant's own display: a capture
66
+ // during a takeover is the human's screen, so it is refused, not taken.
67
+ expect(script!.indexOf("control.sh assert-agent")).toBeLessThan(
68
+ script!.indexOf("scrot"),
69
+ );
70
+ expect(script).toContain("export DISPLAY=':100'");
71
+ expect(script).toContain(`scrot --overwrite '${PATH}'`);
72
+ // Read back rather than left on disk. That is the whole reason the
73
+ // Workspace can record the Bot as the writer of these bytes.
74
+ expect(host.reads).toEqual([{ botId: "health", path: PATH }]);
75
+ expect(captured.display).toBe(":100");
76
+ expect(captured.bytes.byteLength).toBe(64);
77
+ expect(Date.parse(captured.capturedAt)).toBeGreaterThan(0);
78
+ });
79
+
80
+ test("refuses a capture while a human holds the takeover lease", async () => {
81
+ const host = hostWith(64);
82
+ const computer = computerOn(host);
83
+ const bot = computer.bot("health");
84
+ await bot.ensure(signal());
85
+ host.leases.set(KEY, { owner: "a-human", fresh: true });
86
+
87
+ await expect(bot.screenshot(signal())).rejects.toThrow(
88
+ /controlling this agent's computer/,
89
+ );
90
+ });
91
+
92
+ test("refuses a capture on a Computer that allocated no display", async () => {
93
+ const host = hostWith(64);
94
+ host.display = undefined;
95
+ const computer = computerOn(host);
96
+ const bot = computer.bot("health");
97
+ await bot.ensure(signal());
98
+
99
+ await expect(bot.screenshot(signal())).rejects.toThrow(
100
+ /no desktop on this Computer to capture/,
101
+ );
102
+ expect(host.reads).toEqual([]);
103
+ });
104
+
105
+ test("refuses a capture past the size limit before it reads any bytes", async () => {
106
+ const host = hostWith(SCREENSHOT_MAX_BYTES + 1);
107
+ const computer = computerOn(host);
108
+ const bot = computer.bot("health");
109
+ await bot.ensure(signal());
110
+
111
+ const failure = await bot
112
+ .screenshot(signal())
113
+ .catch((error: unknown) => error);
114
+ expect(failure).toBeInstanceOf(ComputerError);
115
+ expect((failure as ComputerError).code).toBe("limit-exceeded");
116
+ expect(host.reads).toEqual([]);
117
+ });
118
+
119
+ test("answers an absent capture rather than an empty picture", async () => {
120
+ const host = new FakeComputerHost(() => ({ stdout: "" }));
121
+ const computer = computerOn(host);
122
+ const bot = computer.bot("health");
123
+ await bot.ensure(signal());
124
+
125
+ await expect(bot.screenshot(signal())).rejects.toThrow(
126
+ /produced no screenshot/,
127
+ );
128
+ });
129
+
130
+ test("reaches the provider-neutral Computer interface as a PNG capture", async () => {
131
+ const host = hostWith(64);
132
+ const provider = new FlySpriteComputerProvider(computerOn(host));
133
+ const handle = await provider.open(
134
+ { userId: "owner" },
135
+ { botId: "health" },
136
+ { providerId: "fly-sprite", generation: 1 },
137
+ );
138
+
139
+ const captured = await handle.screenshot!.capture({ signal: signal() });
140
+
141
+ expect(captured.mediaType).toBe("image/png");
142
+ expect(captured.display).toBe(":100");
143
+ expect(captured.bytes.byteLength).toBe(64);
144
+ });
145
+ });