@frockbot/plugin-computer 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.
Files changed (42) hide show
  1. package/frockbot.json +25 -0
  2. package/package.json +54 -6
  3. package/src/agent.test.ts +271 -0
  4. package/src/agent.ts +1419 -0
  5. package/src/backend.test.ts +149 -0
  6. package/src/backend.ts +163 -0
  7. package/src/bot.test.ts +411 -0
  8. package/src/bot.ts +831 -0
  9. package/src/client/ComputerCard.test.ts +96 -0
  10. package/src/client/ComputerCard.vue +60 -0
  11. package/src/client/ComputerStrip.test.ts +54 -0
  12. package/src/client/ComputerStrip.vue +55 -0
  13. package/src/client/ComputerViewerOverlay.vue +252 -0
  14. package/src/client/application.test.ts +373 -0
  15. package/src/client/application.ts +340 -0
  16. package/src/client/cordis-client-shim.d.ts +16 -0
  17. package/src/client/dialog-focus.ts +13 -0
  18. package/src/client/index.ts +28 -0
  19. package/src/client/state-machine.test.ts +200 -0
  20. package/src/client/state-machine.ts +172 -0
  21. package/src/client/styles.css +594 -0
  22. package/src/client/viewer.ts +58 -0
  23. package/src/control-record.ts +57 -0
  24. package/src/doctor.test.ts +247 -0
  25. package/src/env.d.ts +12 -0
  26. package/src/index.ts +6 -0
  27. package/src/manifest.ts +3 -0
  28. package/src/process-records.test.ts +178 -0
  29. package/src/process-records.ts +278 -0
  30. package/src/process-store.ts +96 -0
  31. package/src/processes.test.ts +388 -0
  32. package/src/protocol.ts +405 -0
  33. package/src/roots.ts +6 -0
  34. package/src/screenshot.test.ts +253 -0
  35. package/src/shared-provider.test.ts +56 -0
  36. package/src/shared-provider.ts +121 -0
  37. package/src/shared.ts +54 -0
  38. package/src/sync.test.ts +255 -0
  39. package/src/workspace-fixture.ts +126 -0
  40. package/tsconfig.json +19 -0
  41. package/vite.config.ts +24 -0
  42. package/README.md +0 -3
@@ -0,0 +1,405 @@
1
+ // The Computer presence protocol crosses the hosted client, gateway and Bot
2
+ // Durable Object seams. It contains projections and commands only: notably,
3
+ // the viewer URL is present solely on a read projection and never on a command
4
+ // receipt, so an idempotency record cannot become durable secret storage.
5
+
6
+ export const COMPUTER_COMMAND_TYPES = [
7
+ "connect",
8
+ "takeControl",
9
+ "releaseControl",
10
+ "refreshControl",
11
+ "refreshViewer",
12
+ "runDoctor",
13
+ ] as const;
14
+
15
+ export type ComputerCommandTypeV1 = (typeof COMPUTER_COMMAND_TYPES)[number];
16
+
17
+ export interface ComputerCommandV1 {
18
+ version: 1;
19
+ commandId: string;
20
+ botId: string;
21
+ type: ComputerCommandTypeV1;
22
+ }
23
+
24
+ export interface ComputerViewerSessionViewV1 {
25
+ version: 1;
26
+ id: string;
27
+ /**
28
+ * A bearer secret for the VNC transport. It crosses this one projection,
29
+ * lives only in client memory, and must never enter storage or a log.
30
+ */
31
+ url: string;
32
+ expiresAt: string;
33
+ }
34
+
35
+ export interface ComputerControlLeaseViewV1 {
36
+ version: 1;
37
+ ownerId: string;
38
+ acquiredAt: string;
39
+ expiresAt: string;
40
+ }
41
+
42
+ export interface ComputerScreenshotViewV1 {
43
+ version: 1;
44
+ path: string;
45
+ capturedAt: string;
46
+ contentHash: string;
47
+ url: string;
48
+ }
49
+
50
+ export interface ComputerDoctorCheckViewV1 {
51
+ version: 1;
52
+ name: string;
53
+ status: "pass" | "fail";
54
+ detail: string;
55
+ }
56
+
57
+ export interface ComputerDoctorViewV1 {
58
+ version: 1;
59
+ capturedAt: string;
60
+ summary: string;
61
+ checks: ComputerDoctorCheckViewV1[];
62
+ }
63
+
64
+ export const COMPUTER_PHASES = [
65
+ "unconfigured",
66
+ "idle",
67
+ "provisioning",
68
+ "updating",
69
+ "ready",
70
+ "taking-control",
71
+ "human-control",
72
+ "disconnected",
73
+ "error",
74
+ ] as const;
75
+
76
+ export type ComputerPhase = (typeof COMPUTER_PHASES)[number];
77
+
78
+ export const COMPUTER_UPDATE_MESSAGE_PREFIX = "Updating the Computer: ";
79
+
80
+ /** Extracts the provider's update phase label without coupling to a provider. */
81
+ export function computerUpdateLabelV1(
82
+ message: string | undefined,
83
+ ): string | undefined {
84
+ if (!message?.startsWith(COMPUTER_UPDATE_MESSAGE_PREFIX)) return undefined;
85
+ const label = message.slice(COMPUTER_UPDATE_MESSAGE_PREFIX.length).trim();
86
+ return label || undefined;
87
+ }
88
+
89
+ export interface ComputerProjectionV1 {
90
+ version: 1;
91
+ botId: string;
92
+ providerLabel: string;
93
+ phase: ComputerPhase;
94
+ message: string;
95
+ viewerSession?: ComputerViewerSessionViewV1;
96
+ controlLease?: ComputerControlLeaseViewV1;
97
+ screenshots: ComputerScreenshotViewV1[];
98
+ doctor?: ComputerDoctorViewV1;
99
+ }
100
+
101
+ export type ComputerCommandReceiptV1 =
102
+ | {
103
+ version: 1;
104
+ commandId: string;
105
+ type: ComputerCommandTypeV1;
106
+ status: "applied";
107
+ completedAt: string;
108
+ }
109
+ | {
110
+ version: 1;
111
+ commandId: string;
112
+ type: ComputerCommandTypeV1;
113
+ status: "rejected";
114
+ completedAt: string;
115
+ failure: string;
116
+ };
117
+
118
+ export class ComputerProtocolDecodeError extends Error {
119
+ override readonly name = "ComputerProtocolDecodeError";
120
+ }
121
+
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ function record(value: unknown, label: string): Record<string, unknown> {
127
+ if (!isRecord(value)) {
128
+ throw new ComputerProtocolDecodeError(`${label} must be an object`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ function exactKeys(
134
+ value: Record<string, unknown>,
135
+ required: readonly string[],
136
+ optional: readonly string[],
137
+ label: string,
138
+ ): void {
139
+ const allowed = new Set([...required, ...optional]);
140
+ if (
141
+ !required.every((key) => Object.hasOwn(value, key)) ||
142
+ Object.keys(value).some((key) => !allowed.has(key))
143
+ ) {
144
+ throw new ComputerProtocolDecodeError(`${label} has unexpected fields`);
145
+ }
146
+ }
147
+
148
+ function text(value: unknown, label: string): string {
149
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
150
+ throw new ComputerProtocolDecodeError(`${label} is invalid`);
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function timestamp(value: unknown, label: string): string {
156
+ const decoded = text(value, label);
157
+ if (!Number.isFinite(Date.parse(decoded))) {
158
+ throw new ComputerProtocolDecodeError(`${label} is invalid`);
159
+ }
160
+ return decoded;
161
+ }
162
+
163
+ function commandType(value: unknown): ComputerCommandTypeV1 {
164
+ const decoded = COMPUTER_COMMAND_TYPES.find((known) => known === value);
165
+ if (!decoded) {
166
+ throw new ComputerProtocolDecodeError("Computer command type is unknown");
167
+ }
168
+ return decoded;
169
+ }
170
+
171
+ function phase(value: unknown): ComputerPhase {
172
+ const decoded = COMPUTER_PHASES.find((known) => known === value);
173
+ if (!decoded) {
174
+ throw new ComputerProtocolDecodeError("Computer phase is unknown");
175
+ }
176
+ return decoded;
177
+ }
178
+
179
+ export function decodeComputerCommandV1(value: unknown): ComputerCommandV1 {
180
+ const candidate = record(value, "Computer command");
181
+ exactKeys(
182
+ candidate,
183
+ ["version", "commandId", "botId", "type"],
184
+ [],
185
+ "Computer command",
186
+ );
187
+ if (candidate.version !== 1) {
188
+ throw new ComputerProtocolDecodeError(
189
+ "Computer command version is unsupported",
190
+ );
191
+ }
192
+ return {
193
+ version: 1,
194
+ commandId: text(candidate.commandId, "Computer commandId"),
195
+ botId: text(candidate.botId, "Computer botId"),
196
+ type: commandType(candidate.type),
197
+ };
198
+ }
199
+
200
+ function decodeViewerSessionV1(value: unknown): ComputerViewerSessionViewV1 {
201
+ const candidate = record(value, "Computer viewer session");
202
+ exactKeys(
203
+ candidate,
204
+ ["version", "id", "url", "expiresAt"],
205
+ [],
206
+ "Computer viewer session",
207
+ );
208
+ if (candidate.version !== 1) {
209
+ throw new ComputerProtocolDecodeError(
210
+ "Computer viewer session version is unsupported",
211
+ );
212
+ }
213
+ return {
214
+ version: 1,
215
+ id: text(candidate.id, "Computer viewer session id"),
216
+ url: text(candidate.url, "Computer viewer session URL"),
217
+ expiresAt: timestamp(
218
+ candidate.expiresAt,
219
+ "Computer viewer session expiresAt",
220
+ ),
221
+ };
222
+ }
223
+
224
+ function decodeControlLeaseV1(value: unknown): ComputerControlLeaseViewV1 {
225
+ const candidate = record(value, "Computer control lease");
226
+ exactKeys(
227
+ candidate,
228
+ ["version", "ownerId", "acquiredAt", "expiresAt"],
229
+ [],
230
+ "Computer control lease",
231
+ );
232
+ if (candidate.version !== 1) {
233
+ throw new ComputerProtocolDecodeError(
234
+ "Computer control lease version is unsupported",
235
+ );
236
+ }
237
+ return {
238
+ version: 1,
239
+ ownerId: text(candidate.ownerId, "Computer control lease ownerId"),
240
+ acquiredAt: timestamp(
241
+ candidate.acquiredAt,
242
+ "Computer control lease acquiredAt",
243
+ ),
244
+ expiresAt: timestamp(
245
+ candidate.expiresAt,
246
+ "Computer control lease expiresAt",
247
+ ),
248
+ };
249
+ }
250
+
251
+ function decodeScreenshotV1(value: unknown): ComputerScreenshotViewV1 {
252
+ const candidate = record(value, "Computer screenshot");
253
+ exactKeys(
254
+ candidate,
255
+ ["version", "path", "capturedAt", "contentHash", "url"],
256
+ [],
257
+ "Computer screenshot",
258
+ );
259
+ if (candidate.version !== 1) {
260
+ throw new ComputerProtocolDecodeError(
261
+ "Computer screenshot version is unsupported",
262
+ );
263
+ }
264
+ return {
265
+ version: 1,
266
+ path: text(candidate.path, "Computer screenshot path"),
267
+ capturedAt: timestamp(
268
+ candidate.capturedAt,
269
+ "Computer screenshot capturedAt",
270
+ ),
271
+ contentHash: text(candidate.contentHash, "Computer screenshot contentHash"),
272
+ url: text(candidate.url, "Computer screenshot URL"),
273
+ };
274
+ }
275
+
276
+ function decodeDoctorCheckV1(value: unknown): ComputerDoctorCheckViewV1 {
277
+ const candidate = record(value, "Computer doctor check");
278
+ exactKeys(
279
+ candidate,
280
+ ["version", "name", "status", "detail"],
281
+ [],
282
+ "Computer doctor check",
283
+ );
284
+ if (candidate.version !== 1) {
285
+ throw new ComputerProtocolDecodeError(
286
+ "Computer doctor check version is unsupported",
287
+ );
288
+ }
289
+ if (candidate.status !== "pass" && candidate.status !== "fail") {
290
+ throw new ComputerProtocolDecodeError(
291
+ "Computer doctor check status is invalid",
292
+ );
293
+ }
294
+ return {
295
+ version: 1,
296
+ name: text(candidate.name, "Computer doctor check name"),
297
+ status: candidate.status,
298
+ detail: text(candidate.detail, "Computer doctor check detail"),
299
+ };
300
+ }
301
+
302
+ function decodeDoctorV1(value: unknown): ComputerDoctorViewV1 {
303
+ const candidate = record(value, "Computer doctor report");
304
+ exactKeys(
305
+ candidate,
306
+ ["version", "capturedAt", "summary", "checks"],
307
+ [],
308
+ "Computer doctor report",
309
+ );
310
+ if (candidate.version !== 1 || !Array.isArray(candidate.checks)) {
311
+ throw new ComputerProtocolDecodeError("Computer doctor report is invalid");
312
+ }
313
+ return {
314
+ version: 1,
315
+ capturedAt: timestamp(
316
+ candidate.capturedAt,
317
+ "Computer doctor report capturedAt",
318
+ ),
319
+ summary: text(candidate.summary, "Computer doctor report summary"),
320
+ checks: candidate.checks.map(decodeDoctorCheckV1),
321
+ };
322
+ }
323
+
324
+ export function decodeComputerProjectionV1(
325
+ value: unknown,
326
+ ): ComputerProjectionV1 {
327
+ const candidate = record(value, "Computer projection");
328
+ exactKeys(
329
+ candidate,
330
+ ["version", "botId", "providerLabel", "phase", "message", "screenshots"],
331
+ ["viewerSession", "controlLease", "doctor"],
332
+ "Computer projection",
333
+ );
334
+ if (candidate.version !== 1 || !Array.isArray(candidate.screenshots)) {
335
+ throw new ComputerProtocolDecodeError("Computer projection is invalid");
336
+ }
337
+ return {
338
+ version: 1,
339
+ botId: text(candidate.botId, "Computer projection botId"),
340
+ providerLabel: text(
341
+ candidate.providerLabel,
342
+ "Computer projection providerLabel",
343
+ ),
344
+ phase: phase(candidate.phase),
345
+ message: text(candidate.message, "Computer projection message"),
346
+ ...(candidate.viewerSession === undefined
347
+ ? {}
348
+ : { viewerSession: decodeViewerSessionV1(candidate.viewerSession) }),
349
+ ...(candidate.controlLease === undefined
350
+ ? {}
351
+ : { controlLease: decodeControlLeaseV1(candidate.controlLease) }),
352
+ screenshots: candidate.screenshots.map(decodeScreenshotV1),
353
+ ...(candidate.doctor === undefined
354
+ ? {}
355
+ : { doctor: decodeDoctorV1(candidate.doctor) }),
356
+ };
357
+ }
358
+
359
+ export function decodeComputerCommandReceiptV1(
360
+ value: unknown,
361
+ ): ComputerCommandReceiptV1 {
362
+ const candidate = record(value, "Computer command receipt");
363
+ const rejected = candidate.status === "rejected";
364
+ exactKeys(
365
+ candidate,
366
+ ["version", "commandId", "type", "status", "completedAt"],
367
+ rejected ? ["failure"] : [],
368
+ "Computer command receipt",
369
+ );
370
+ if (candidate.version !== 1) {
371
+ throw new ComputerProtocolDecodeError(
372
+ "Computer command receipt version is unsupported",
373
+ );
374
+ }
375
+ const common = {
376
+ version: 1 as const,
377
+ commandId: text(candidate.commandId, "Computer command receipt commandId"),
378
+ type: commandType(candidate.type),
379
+ completedAt: timestamp(
380
+ candidate.completedAt,
381
+ "Computer command receipt completedAt",
382
+ ),
383
+ };
384
+ if (candidate.status === "applied") return { ...common, status: "applied" };
385
+ if (candidate.status === "rejected") {
386
+ return {
387
+ ...common,
388
+ status: "rejected",
389
+ failure: text(candidate.failure, "Computer command receipt failure"),
390
+ };
391
+ }
392
+ throw new ComputerProtocolDecodeError(
393
+ "Computer command receipt status is invalid",
394
+ );
395
+ }
396
+
397
+ export function computerCommandFingerprintV1(
398
+ command: ComputerCommandV1,
399
+ ): string {
400
+ return JSON.stringify({
401
+ version: 1,
402
+ botId: command.botId,
403
+ type: command.type,
404
+ });
405
+ }
package/src/roots.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** The Package-declared durable root screenshots are written to. */
2
+ export const COMPUTER_SCREENSHOTS_ROOT_ID = "screenshots";
3
+ /** Screenshots kept per Bot. Older captures are pruned on the next capture. */
4
+ export const COMPUTER_SCREENSHOT_RETENTION = 20;
5
+ /** The Package-declared durable root a self-check report is filed in. */
6
+ export const COMPUTER_DOCTOR_ROOT_ID = "doctor";
@@ -0,0 +1,253 @@
1
+ // `computer_screenshot`: parity row 25.
2
+ //
3
+ // The subject is what the tool *files*, not what `scrot` produced. Three rules
4
+ // are asserted here because they are the ones a future change could quietly
5
+ // break: the bytes go through `ComputerWorkspace.write` so the Bot is recorded
6
+ // as their writer, the root is bounded, and the model gets a reference it can
7
+ // resolve rather than a picture of a path.
8
+ import { describe, expect, test } from "bun:test";
9
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
10
+ import { ToolRegistry } from "@frockbot/plugin-tools";
11
+ import {
12
+ ComputerRegistry,
13
+ computerBotPathKeyV1,
14
+ type ComputerHandle,
15
+ type ComputerProvider,
16
+ } from "@frockbot/computer-core";
17
+ import { createPluginHarness } from "@frockbot/plugin-testkit";
18
+ import { SessionStore } from "@frockbot/kernel-contracts";
19
+ import { createComputerAgentPlugin, pngDimensionsV1 } from "./agent.js";
20
+ import { FakeWorkspace } from "./workspace-fixture.js";
21
+
22
+ /** A 4x3 PNG: a real signature and a real IHDR, and nothing after it. */
23
+ function png(width = 4, height = 3): Uint8Array {
24
+ const bytes = new Uint8Array(32);
25
+ bytes.set([137, 80, 78, 71, 13, 10, 26, 10], 0);
26
+ const view = new DataView(bytes.buffer);
27
+ view.setUint32(16, width);
28
+ view.setUint32(20, height);
29
+ return bytes;
30
+ }
31
+
32
+ function providerWith(
33
+ workspace: FakeWorkspace,
34
+ capture: () => Promise<{
35
+ bytes: Uint8Array;
36
+ mediaType: "image/png";
37
+ display: string;
38
+ capturedAt: string;
39
+ }>,
40
+ ): ComputerProvider {
41
+ return {
42
+ id: "fixture",
43
+ open: (identity, tenant, assignment): Promise<ComputerHandle> =>
44
+ Promise.resolve({
45
+ assignment,
46
+ identity,
47
+ tenant,
48
+ workspace,
49
+ screenshot: { capture: () => capture() },
50
+ close: () => Promise.resolve(),
51
+ }),
52
+ };
53
+ }
54
+
55
+ async function mount(provider: ComputerProvider, writer = true) {
56
+ const harness = await createPluginHarness([
57
+ ComputerRegistry,
58
+ ToolRegistry,
59
+ SystemPromptRegistry,
60
+ SessionStore,
61
+ ]);
62
+ harness.root.computers.register(provider);
63
+ await harness.mount(
64
+ createComputerAgentPlugin({
65
+ userId: "user-1",
66
+ defaultProviderId: "fixture",
67
+ ...(writer
68
+ ? {
69
+ writer: {
70
+ sessionId: "session-1",
71
+ turnId: "run-9",
72
+ runId: "run-9",
73
+ },
74
+ }
75
+ : {}),
76
+ }),
77
+ );
78
+ return harness;
79
+ }
80
+
81
+ async function capture(
82
+ harness: Awaited<ReturnType<typeof createPluginHarness>>,
83
+ ) {
84
+ const context = {
85
+ botId: "bot-1",
86
+ agentId: "run-9",
87
+ compositionGenerationId: "bootstrap",
88
+ turnType: "chat" as const,
89
+ sessionId: "session-1",
90
+ effectId: "tool:1:1:0",
91
+ signal: new AbortController().signal,
92
+ };
93
+ const prepared = await harness.root.tools.prepare(
94
+ { id: crypto.randomUUID(), name: "computer_screenshot", input: {} },
95
+ context,
96
+ );
97
+ if (prepared.kind !== "ready") throw new Error(prepared.result.content);
98
+ return harness.root.tools.executePrepared(prepared, context);
99
+ }
100
+
101
+ describe("computer_screenshot", () => {
102
+ test("files the capture through the Workspace with the Bot as its writer", async () => {
103
+ const workspace = new FakeWorkspace();
104
+ const harness = await mount(
105
+ providerWith(workspace, () =>
106
+ Promise.resolve({
107
+ bytes: png(1280, 720),
108
+ mediaType: "image/png" as const,
109
+ display: ":100",
110
+ capturedAt: "2026-08-31T00:00:00.000Z",
111
+ }),
112
+ ),
113
+ );
114
+
115
+ const result = await capture(harness);
116
+
117
+ expect(result.isError).toBe(false);
118
+ const answer = JSON.parse(result.content) as Record<string, unknown>;
119
+ const botKey = computerBotPathKeyV1("bot-1");
120
+ expect(answer).toMatchObject({
121
+ path: `${botKey}/run-9-1.png`,
122
+ rootId: "screenshots",
123
+ width: 1280,
124
+ height: 720,
125
+ display: ":100",
126
+ capturedAt: "2026-08-31T00:00:00.000Z",
127
+ });
128
+ // The writer is the point: a file a shell left on the Computer would sync
129
+ // back `unattributed`, so the tool reads the bytes and writes them here.
130
+ const written = workspace.files.get(`${botKey}/run-9-1.png`);
131
+ expect(written?.generation.writer).toEqual({
132
+ kind: "bot",
133
+ botId: "bot-1",
134
+ sessionId: "session-1",
135
+ turnId: "run-9",
136
+ runId: "run-9",
137
+ });
138
+ expect(result.attachments).toEqual([
139
+ {
140
+ kind: "image",
141
+ mediaType: "image/png",
142
+ workspacePath: {
143
+ root: {
144
+ kind: "package-declared",
145
+ userId: "user-1",
146
+ packageId: "computer",
147
+ rootId: "screenshots",
148
+ },
149
+ path: `${botKey}/run-9-1.png`,
150
+ },
151
+ contentHash: written!.generation.contentHash,
152
+ bytes: written!.generation.size,
153
+ },
154
+ ]);
155
+ await harness.dispose();
156
+ });
157
+
158
+ test("prunes to the newest twenty captures for one Bot", async () => {
159
+ const workspace = new FakeWorkspace();
160
+ const harness = await mount(
161
+ providerWith(workspace, () =>
162
+ Promise.resolve({
163
+ bytes: png(),
164
+ mediaType: "image/png" as const,
165
+ display: ":100",
166
+ capturedAt: "2026-08-31T00:00:00.000Z",
167
+ }),
168
+ ),
169
+ );
170
+
171
+ for (let index = 0; index < 23; index += 1) await capture(harness);
172
+
173
+ expect(workspace.files.size).toBe(20);
174
+ expect(workspace.deleted).toEqual([
175
+ `${computerBotPathKeyV1("bot-1")}/run-9-1.png`,
176
+ `${computerBotPathKeyV1("bot-1")}/run-9-2.png`,
177
+ `${computerBotPathKeyV1("bot-1")}/run-9-3.png`,
178
+ ]);
179
+ await harness.dispose();
180
+ });
181
+
182
+ test("reports a Computer with no screenshot capability as a failure", async () => {
183
+ const workspace = new FakeWorkspace();
184
+ const harness = await mount({
185
+ id: "fixture",
186
+ open: (identity, tenant, assignment) =>
187
+ Promise.resolve({
188
+ assignment,
189
+ identity,
190
+ tenant,
191
+ workspace,
192
+ close: () => Promise.resolve(),
193
+ }),
194
+ });
195
+
196
+ const result = await capture(harness);
197
+
198
+ expect(result).toMatchObject({ isError: true });
199
+ expect(result.content).toContain("does not support screenshots");
200
+ await harness.dispose();
201
+ });
202
+
203
+ test("carries a refused capture back as the tool's failure", async () => {
204
+ const workspace = new FakeWorkspace();
205
+ const harness = await mount(
206
+ providerWith(workspace, () =>
207
+ Promise.reject(
208
+ new Error("The user is controlling this agent's computer"),
209
+ ),
210
+ ),
211
+ );
212
+
213
+ const result = await capture(harness);
214
+
215
+ expect(result).toMatchObject({ isError: true });
216
+ expect(result.content).toContain("controlling this agent's computer");
217
+ expect(workspace.files.size).toBe(0);
218
+ await harness.dispose();
219
+ });
220
+
221
+ test("is not offered outside a Turn that can name its writer", async () => {
222
+ const workspace = new FakeWorkspace();
223
+ const harness = await mount(
224
+ providerWith(workspace, () =>
225
+ Promise.resolve({
226
+ bytes: png(),
227
+ mediaType: "image/png" as const,
228
+ display: ":100",
229
+ capturedAt: "2026-08-31T00:00:00.000Z",
230
+ }),
231
+ ),
232
+ false,
233
+ );
234
+
235
+ expect(
236
+ harness.root.tools
237
+ .schemas({ turnType: "chat" })
238
+ .map((schema) => schema.name),
239
+ ).not.toContain("computer_screenshot");
240
+ await harness.dispose();
241
+ });
242
+ });
243
+
244
+ describe("pngDimensionsV1", () => {
245
+ test("reads the IHDR of a PNG and refuses anything else", () => {
246
+ expect(pngDimensionsV1(png(1280, 720))).toEqual({
247
+ width: 1280,
248
+ height: 720,
249
+ });
250
+ expect(pngDimensionsV1(new Uint8Array(8))).toBeUndefined();
251
+ expect(pngDimensionsV1(png(0, 0))).toBeUndefined();
252
+ });
253
+ });