@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,411 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ ComputerControlLease,
4
+ ComputerHandle,
5
+ } from "@frockbot/computer-core";
6
+ import { ComputerError } from "@frockbot/computer-core";
7
+ import {
8
+ COMPUTER_CONTROL_RECORD_KEY,
9
+ COMPUTER_INTENT_PREFIX,
10
+ COMPUTER_PROVIDER_RECORD_KEY,
11
+ COMPUTER_VIEWER_RECORD_KEY,
12
+ createComputerBotBackendContribution,
13
+ type ComputerBotStorage,
14
+ type ComputerBotTransaction,
15
+ } from "./bot.js";
16
+ import type { ComputerCommandV1 } from "./protocol.js";
17
+
18
+ class MemoryStorage implements ComputerBotStorage {
19
+ readonly values = new Map<string, unknown>();
20
+
21
+ get<T>(key: string): Promise<T | undefined> {
22
+ return Promise.resolve(
23
+ structuredClone(this.values.get(key)) as T | undefined,
24
+ );
25
+ }
26
+
27
+ put<T>(key: string, value: T): Promise<void>;
28
+ put(entries: Record<string, unknown>): Promise<void>;
29
+ put<T>(
30
+ keyOrEntries: string | Record<string, unknown>,
31
+ value?: T,
32
+ ): Promise<void> {
33
+ if (typeof keyOrEntries === "string") {
34
+ this.values.set(keyOrEntries, structuredClone(value));
35
+ } else {
36
+ for (const [key, entry] of Object.entries(keyOrEntries)) {
37
+ this.values.set(key, structuredClone(entry));
38
+ }
39
+ }
40
+ return Promise.resolve();
41
+ }
42
+
43
+ delete(key: string): Promise<boolean> {
44
+ return Promise.resolve(this.values.delete(key));
45
+ }
46
+
47
+ transaction<T>(
48
+ callback: (storage: ComputerBotTransaction) => Promise<T>,
49
+ ): Promise<T> {
50
+ return callback(this);
51
+ }
52
+ }
53
+
54
+ function command(
55
+ type: ComputerCommandV1["type"],
56
+ commandId: string,
57
+ ): ComputerCommandV1 {
58
+ return { version: 1, commandId, botId: "scout", type };
59
+ }
60
+
61
+ function fakeHandle(options: {
62
+ presence?(): Promise<{
63
+ id: string;
64
+ url: string;
65
+ expiresAt: string;
66
+ message?: string;
67
+ }>;
68
+ renewViewer?(
69
+ sessionId: string,
70
+ ): Promise<{ id: string; url: string; expiresAt: string }>;
71
+ acquire?(ownerId: string, scope?: string): Promise<ComputerControlLease>;
72
+ renew?(
73
+ lease: ComputerControlLease,
74
+ scope?: string,
75
+ ): Promise<ComputerControlLease>;
76
+ release?(lease: ComputerControlLease, scope?: string): Promise<void>;
77
+ }): ComputerHandle {
78
+ return {
79
+ assignment: { providerId: "fake", generation: 1 },
80
+ identity: { userId: "user-1" },
81
+ tenant: { botId: "scout" },
82
+ ...(options.presence ? { presence: { connect: options.presence } } : {}),
83
+ ...(options.renewViewer
84
+ ? {
85
+ viewer: {
86
+ open: () => Promise.reject(new Error("not used")),
87
+ renew: options.renewViewer,
88
+ revoke: () => Promise.resolve(),
89
+ },
90
+ }
91
+ : {}),
92
+ control: {
93
+ acquire: (request) =>
94
+ options.acquire!(request?.ownerId ?? "missing", request?.scope),
95
+ renew: (lease, request) => options.renew!(lease, request?.scope),
96
+ release: (lease, request) => options.release!(lease, request?.scope),
97
+ },
98
+ close: () => Promise.resolve(),
99
+ };
100
+ }
101
+
102
+ describe("Computer Bot Durable Object Contribution", () => {
103
+ test("commits intent before it asks the provider and replays one receipt", async () => {
104
+ const storage = new MemoryStorage();
105
+ let calls = 0;
106
+ const contribution = createComputerBotBackendContribution({
107
+ storage,
108
+ configured: true,
109
+ providerLabel: "Fake Computer",
110
+ openComputer: () =>
111
+ Promise.resolve(
112
+ fakeHandle({
113
+ presence: () => {
114
+ calls += 1;
115
+ expect(
116
+ storage.values.has(`${COMPUTER_INTENT_PREFIX}connect-1`),
117
+ ).toBe(true);
118
+ return Promise.resolve({
119
+ id: "viewer-1",
120
+ url: "https://viewer.invalid/secret",
121
+ expiresAt: "2026-09-02T00:01:30.000Z",
122
+ });
123
+ },
124
+ }),
125
+ ),
126
+ now: () => new Date("2026-09-02T00:00:00.000Z"),
127
+ });
128
+ const first = await contribution.execute(
129
+ "user-1",
130
+ "scout",
131
+ command("connect", "connect-1"),
132
+ );
133
+ const replay = await contribution.execute(
134
+ "user-1",
135
+ "scout",
136
+ command("connect", "connect-1"),
137
+ );
138
+ expect(replay).toEqual(first);
139
+ expect(calls).toBe(1);
140
+ expect(JSON.stringify([...storage.values.values()])).not.toContain(
141
+ "viewer.invalid",
142
+ );
143
+ });
144
+
145
+ test("records and replays one viewer renewal without storing its bearer URL", async () => {
146
+ const storage = new MemoryStorage();
147
+ let renewals = 0;
148
+ const contribution = createComputerBotBackendContribution({
149
+ storage,
150
+ configured: true,
151
+ providerLabel: "Fake Computer",
152
+ openComputer: () =>
153
+ Promise.resolve(
154
+ fakeHandle({
155
+ presence: () =>
156
+ Promise.resolve({
157
+ id: "viewer-1",
158
+ url: "https://viewer.invalid/secret",
159
+ expiresAt: "2026-09-02T00:01:30.000Z",
160
+ }),
161
+ renewViewer: (sessionId) => {
162
+ renewals += 1;
163
+ expect(sessionId).toBe("viewer-1");
164
+ expect(
165
+ storage.values.has(`${COMPUTER_INTENT_PREFIX}viewer-renew-1`),
166
+ ).toBe(true);
167
+ return Promise.resolve({
168
+ id: sessionId,
169
+ url: "https://viewer.invalid/secret",
170
+ expiresAt: "2026-09-02T00:02:00.000Z",
171
+ });
172
+ },
173
+ }),
174
+ ),
175
+ now: () => new Date("2026-09-02T00:00:00.000Z"),
176
+ });
177
+ await contribution.execute(
178
+ "user-1",
179
+ "scout",
180
+ command("connect", "connect-viewer"),
181
+ );
182
+ const first = await contribution.execute(
183
+ "user-1",
184
+ "scout",
185
+ command("refreshViewer", "viewer-renew-1"),
186
+ );
187
+ const replay = await contribution.execute(
188
+ "user-1",
189
+ "scout",
190
+ command("refreshViewer", "viewer-renew-1"),
191
+ );
192
+
193
+ expect(first.status).toBe("applied");
194
+ expect(replay).toEqual(first);
195
+ expect(renewals).toBe(1);
196
+ expect(JSON.stringify([...storage.values.values()])).not.toContain(
197
+ "viewer.invalid",
198
+ );
199
+ });
200
+
201
+ test("projects update-kind provider progress as updating with its label", async () => {
202
+ const storage = new MemoryStorage();
203
+ const contribution = createComputerBotBackendContribution({
204
+ storage,
205
+ configured: true,
206
+ providerLabel: "Fake Computer",
207
+ openComputer: () =>
208
+ Promise.resolve(
209
+ fakeHandle({
210
+ presence: () =>
211
+ Promise.resolve({
212
+ id: "viewer-1",
213
+ url: "https://viewer.invalid/secret",
214
+ expiresAt: "2026-09-02T00:01:30.000Z",
215
+ message: "Updating the Computer: Updating the Computer runtime",
216
+ }),
217
+ }),
218
+ ),
219
+ now: () => new Date("2026-09-02T00:00:00.000Z"),
220
+ });
221
+
222
+ await contribution.execute(
223
+ "user-1",
224
+ "scout",
225
+ command("connect", "connect-update"),
226
+ );
227
+
228
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
229
+ phase: "updating",
230
+ message: "Updating the Computer runtime",
231
+ viewerSession: { id: "viewer-1" },
232
+ });
233
+ });
234
+
235
+ test("projects an updating provider error during connect", async () => {
236
+ const storage = new MemoryStorage();
237
+ const contribution = createComputerBotBackendContribution({
238
+ storage,
239
+ configured: true,
240
+ providerLabel: "Fake Computer",
241
+ openComputer: () =>
242
+ Promise.resolve(
243
+ fakeHandle({
244
+ presence: () =>
245
+ Promise.reject(
246
+ new ComputerError(
247
+ "updating",
248
+ "Updating the Computer runtime",
249
+ true,
250
+ ),
251
+ ),
252
+ }),
253
+ ),
254
+ now: () => new Date("2026-09-02T00:00:00.000Z"),
255
+ });
256
+
257
+ const receipt = await contribution.execute(
258
+ "user-1",
259
+ "scout",
260
+ command("connect", "connect-updating"),
261
+ );
262
+
263
+ expect(receipt.status).toBe("rejected");
264
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
265
+ phase: "updating",
266
+ message: "Updating the Computer runtime",
267
+ });
268
+ });
269
+
270
+ test("reconstructs a live lease after eviction and renews then releases it", async () => {
271
+ const storage = new MemoryStorage();
272
+ let now = new Date("2026-09-02T00:00:00.000Z");
273
+ let heldOwner = "";
274
+ const owners: string[] = [];
275
+ const scopes: string[] = [];
276
+ const host = {
277
+ storage,
278
+ configured: true,
279
+ providerLabel: "Fake Computer",
280
+ now: () => now,
281
+ newId: () => "owner-1",
282
+ openComputer: () =>
283
+ Promise.resolve(
284
+ fakeHandle({
285
+ acquire: (ownerId, scope) => {
286
+ heldOwner = ownerId;
287
+ owners.push(ownerId);
288
+ scopes.push(scope ?? "missing");
289
+ return Promise.resolve({
290
+ id: ownerId,
291
+ expiresAt: "2026-09-02T00:01:30.000Z",
292
+ });
293
+ },
294
+ renew: (lease, scope) => {
295
+ scopes.push(scope ?? "missing");
296
+ expect(lease.id).toBe(heldOwner);
297
+ return Promise.resolve({
298
+ id: lease.id,
299
+ expiresAt: "2026-09-02T00:02:00.000Z",
300
+ });
301
+ },
302
+ release: (lease, scope) => {
303
+ scopes.push(scope ?? "missing");
304
+ expect(lease.id).toBe(heldOwner);
305
+ return Promise.resolve();
306
+ },
307
+ }),
308
+ ),
309
+ };
310
+ const resident = createComputerBotBackendContribution(host);
311
+ await resident.execute("user-1", "scout", command("takeControl", "take-1"));
312
+ const reconstructed = createComputerBotBackendContribution(host);
313
+ now = new Date("2026-09-02T00:00:30.000Z");
314
+ const projected = await reconstructed.read("user-1", "scout");
315
+ expect(projected.controlLease?.ownerId).toBe("human:owner-1");
316
+ await reconstructed.execute(
317
+ "user-1",
318
+ "scout",
319
+ command("refreshControl", "refresh-1"),
320
+ );
321
+ await reconstructed.execute(
322
+ "user-1",
323
+ "scout",
324
+ command("releaseControl", "release-1"),
325
+ );
326
+ expect(owners).toEqual(["human:owner-1"]);
327
+ expect(scopes).toEqual(["desktop-gui", "desktop-gui", "desktop-gui"]);
328
+ expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(false);
329
+ });
330
+
331
+ test("reclaims a stale lease under a new durable owner", async () => {
332
+ const storage = new MemoryStorage();
333
+ let now = new Date("2026-09-02T00:00:00.000Z");
334
+ let id = 0;
335
+ const acquired: string[] = [];
336
+ const host = {
337
+ storage,
338
+ configured: true,
339
+ providerLabel: "Fake Computer",
340
+ now: () => now,
341
+ newId: () => `owner-${++id}`,
342
+ openComputer: () =>
343
+ Promise.resolve(
344
+ fakeHandle({
345
+ acquire: (ownerId) => {
346
+ acquired.push(ownerId);
347
+ return Promise.resolve({
348
+ id: ownerId,
349
+ expiresAt: new Date(now.getTime() + 90_000).toISOString(),
350
+ });
351
+ },
352
+ }),
353
+ ),
354
+ };
355
+ await createComputerBotBackendContribution(host).execute(
356
+ "user-1",
357
+ "scout",
358
+ command("takeControl", "take-1"),
359
+ );
360
+ now = new Date("2026-09-02T00:02:00.000Z");
361
+ await createComputerBotBackendContribution(host).execute(
362
+ "user-1",
363
+ "scout",
364
+ command("takeControl", "take-2"),
365
+ );
366
+ expect(acquired).toEqual(["human:owner-1", "human:owner-2"]);
367
+ });
368
+
369
+ test("projects reconstructed durable state without opening the Computer", async () => {
370
+ const storage = new MemoryStorage();
371
+ await storage.put({
372
+ [COMPUTER_VIEWER_RECORD_KEY]: {
373
+ version: 1,
374
+ id: "viewer-1",
375
+ expiresAt: "2026-09-02T00:01:30.000Z",
376
+ },
377
+ [COMPUTER_CONTROL_RECORD_KEY]: {
378
+ version: 1,
379
+ ownerId: "owner-1",
380
+ acquiredAt: "2026-09-02T00:00:00.000Z",
381
+ expiresAt: "2026-09-02T00:01:30.000Z",
382
+ },
383
+ [COMPUTER_PROVIDER_RECORD_KEY]: {
384
+ version: 1,
385
+ phase: "ready",
386
+ message: "Computer ready",
387
+ recordedAt: "2026-09-02T00:00:00.000Z",
388
+ },
389
+ });
390
+ let providerCalls = 0;
391
+ const reconstructed = createComputerBotBackendContribution({
392
+ storage,
393
+ configured: true,
394
+ providerLabel: "Fake Computer",
395
+ openComputer: () => {
396
+ providerCalls += 1;
397
+ throw new Error("a read must not wake the Computer");
398
+ },
399
+ now: () => new Date("2026-09-02T00:00:30.000Z"),
400
+ });
401
+
402
+ const projected = await reconstructed.read("user-1", "scout");
403
+
404
+ expect(projected).toMatchObject({
405
+ phase: "human-control",
406
+ controlLease: { ownerId: "owner-1" },
407
+ });
408
+ expect(projected.viewerSession).toBeUndefined();
409
+ expect(providerCalls).toBe(0);
410
+ });
411
+ });