@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,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ComputerBotNotFoundError,
4
+ createComputerBackendContribution,
5
+ } from "./backend.js";
6
+ import type {
7
+ ComputerCommandReceiptV1,
8
+ ComputerProjectionV1,
9
+ } from "./protocol.js";
10
+
11
+ const projection: ComputerProjectionV1 = {
12
+ version: 1,
13
+ botId: "scout",
14
+ providerLabel: "Fake Computer",
15
+ phase: "idle",
16
+ message: "Persistent Computer available",
17
+ screenshots: [],
18
+ };
19
+
20
+ function request(body: unknown): Request {
21
+ return new Request("https://app.test/api/bots/scout/computer/commands", {
22
+ method: "POST",
23
+ headers: { "content-type": "application/json" },
24
+ body: JSON.stringify(body),
25
+ });
26
+ }
27
+
28
+ describe("Computer gateway Contribution", () => {
29
+ test("rejects malformed exact DTOs before the host or storage is touched", async () => {
30
+ let calls = 0;
31
+ const contribution = createComputerBackendContribution({
32
+ readComputer: () => Promise.resolve(projection),
33
+ executeComputerCommand: () => {
34
+ calls += 1;
35
+ throw new Error("must not execute");
36
+ },
37
+ });
38
+ const response = await contribution.route(
39
+ request({
40
+ version: 1,
41
+ commandId: "command-1",
42
+ botId: "scout",
43
+ type: "connect",
44
+ unexpected: true,
45
+ }),
46
+ new URL("https://app.test/api/bots/scout/computer/commands"),
47
+ { userId: "user-1", client: "browser" },
48
+ );
49
+ expect(response?.status).toBe(400);
50
+ expect(calls).toBe(0);
51
+ });
52
+
53
+ test("answers 404 for a foreign Bot without writing its storage", async () => {
54
+ let writes = 0;
55
+ const contribution = createComputerBackendContribution({
56
+ readComputer: () => {
57
+ throw new ComputerBotNotFoundError("scout");
58
+ },
59
+ executeComputerCommand: () => {
60
+ writes += 1;
61
+ throw new Error("must not execute");
62
+ },
63
+ });
64
+ const response = await contribution.route(
65
+ new Request("https://app.test/api/bots/scout/computer"),
66
+ new URL("https://app.test/api/bots/scout/computer"),
67
+ { userId: "another-user", client: "browser" },
68
+ );
69
+ expect(response?.status).toBe(404);
70
+ expect(writes).toBe(0);
71
+ });
72
+
73
+ test("returns the authority's first receipt for a duplicate command replay", async () => {
74
+ const first: ComputerCommandReceiptV1 = {
75
+ version: 1,
76
+ commandId: "command-1",
77
+ type: "connect",
78
+ status: "applied",
79
+ completedAt: "2026-09-02T00:00:00.000Z",
80
+ };
81
+ const receipts = new Map<string, ComputerCommandReceiptV1>();
82
+ let effects = 0;
83
+ const contribution = createComputerBackendContribution({
84
+ readComputer: () => Promise.resolve(projection),
85
+ executeComputerCommand: (_userId, _botId, command) => {
86
+ const stored = receipts.get(command.commandId);
87
+ if (stored) return Promise.resolve(stored);
88
+ effects += 1;
89
+ receipts.set(command.commandId, first);
90
+ return Promise.resolve(first);
91
+ },
92
+ });
93
+ const command = {
94
+ version: 1,
95
+ commandId: "command-1",
96
+ botId: "scout",
97
+ type: "connect",
98
+ };
99
+ const execute = () =>
100
+ contribution.route(
101
+ request(command),
102
+ new URL("https://app.test/api/bots/scout/computer/commands"),
103
+ { userId: "user-1", client: "browser" },
104
+ );
105
+ const one = await execute();
106
+ const two = await execute();
107
+ expect(await one?.json()).toEqual(await two?.json());
108
+ expect(effects).toBe(1);
109
+ });
110
+
111
+ test("decodes refreshViewer and returns the authority's replayed receipt", async () => {
112
+ let effects = 0;
113
+ const receipts = new Map<string, ComputerCommandReceiptV1>();
114
+ const contribution = createComputerBackendContribution({
115
+ readComputer: () => Promise.resolve(projection),
116
+ executeComputerCommand: (_userId, _botId, decoded) => {
117
+ const replay = receipts.get(decoded.commandId);
118
+ if (replay) return Promise.resolve(replay);
119
+ effects += 1;
120
+ const receipt: ComputerCommandReceiptV1 = {
121
+ version: 1,
122
+ commandId: decoded.commandId,
123
+ type: decoded.type,
124
+ status: "applied",
125
+ completedAt: "2026-09-02T00:00:00.000Z",
126
+ };
127
+ receipts.set(decoded.commandId, receipt);
128
+ return Promise.resolve(receipt);
129
+ },
130
+ });
131
+ const execute = () =>
132
+ contribution.route(
133
+ request({
134
+ version: 1,
135
+ commandId: "viewer-renew-1",
136
+ botId: "scout",
137
+ type: "refreshViewer",
138
+ }),
139
+ new URL("https://app.test/api/bots/scout/computer/commands"),
140
+ { userId: "user-1", client: "browser" },
141
+ );
142
+
143
+ const first = await execute();
144
+ const duplicate = await execute();
145
+
146
+ expect(await first?.json()).toEqual(await duplicate?.json());
147
+ expect(effects).toBe(1);
148
+ });
149
+ });
package/src/backend.ts ADDED
@@ -0,0 +1,163 @@
1
+ // The authenticated Computer presence routes. The gateway owns no Computer
2
+ // state: it decodes one exact DTO, proves User-to-Bot membership through its
3
+ // host, and forwards to the Bot Durable Object that owns the records.
4
+ import type { Plugin } from "cordis";
5
+ import {
6
+ ComputerProtocolDecodeError,
7
+ decodeComputerCommandReceiptV1,
8
+ decodeComputerCommandV1,
9
+ decodeComputerProjectionV1,
10
+ type ComputerCommandReceiptV1,
11
+ type ComputerCommandV1,
12
+ type ComputerProjectionV1,
13
+ } from "./protocol.js";
14
+
15
+ export interface ComputerGatewayHost {
16
+ readComputer(userId: string, botId: string): Promise<ComputerProjectionV1>;
17
+ executeComputerCommand(
18
+ userId: string,
19
+ botId: string,
20
+ command: ComputerCommandV1,
21
+ ): Promise<ComputerCommandReceiptV1>;
22
+ }
23
+
24
+ export interface ComputerBackendRouteContribution {
25
+ packageId: string;
26
+ route(
27
+ request: Request,
28
+ url: URL,
29
+ context: { userId?: string; client: "browser" | "desktop" },
30
+ ): Promise<Response | undefined>;
31
+ }
32
+
33
+ export class ComputerBotNotFoundError extends Error {
34
+ override readonly name = "ComputerBotNotFoundError";
35
+ }
36
+
37
+ function missingBot(error: unknown): boolean {
38
+ return (
39
+ typeof error === "object" &&
40
+ error !== null &&
41
+ "name" in error &&
42
+ (error.name === "ComputerBotNotFoundError" ||
43
+ error.name === "BotNotFoundError")
44
+ );
45
+ }
46
+
47
+ function errorResponse(error: unknown): Response {
48
+ if (missingBot(error)) {
49
+ return Response.json(
50
+ { error: "Computer not found", code: "bot-not-found", definitive: true },
51
+ { status: 404 },
52
+ );
53
+ }
54
+ if (
55
+ error instanceof ComputerProtocolDecodeError ||
56
+ error instanceof SyntaxError ||
57
+ (typeof error === "object" &&
58
+ error !== null &&
59
+ "name" in error &&
60
+ error.name === "ComputerProtocolDecodeError")
61
+ ) {
62
+ return Response.json(
63
+ {
64
+ error:
65
+ error instanceof Error
66
+ ? error.message
67
+ : "Computer request is invalid",
68
+ code: "invalid-request",
69
+ definitive: true,
70
+ },
71
+ { status: 400 },
72
+ );
73
+ }
74
+ return Response.json(
75
+ {
76
+ error: error instanceof Error ? error.message : "Computer request failed",
77
+ },
78
+ { status: 500 },
79
+ );
80
+ }
81
+
82
+ function pathId(value: string): string {
83
+ let decoded: string;
84
+ try {
85
+ decoded = decodeURIComponent(value);
86
+ } catch {
87
+ throw new ComputerProtocolDecodeError("Computer botId is invalid");
88
+ }
89
+ if (!decoded.trim() || decoded.length > 200) {
90
+ throw new ComputerProtocolDecodeError("Computer botId is invalid");
91
+ }
92
+ return decoded;
93
+ }
94
+
95
+ export function createComputerBackendContribution(
96
+ host: ComputerGatewayHost,
97
+ ): ComputerBackendRouteContribution {
98
+ return {
99
+ packageId: "computer",
100
+ async route(request, url, context) {
101
+ if (!context.userId) return undefined;
102
+ const read = /^\/api\/bots\/([^/]+)\/computer$/.exec(url.pathname);
103
+ const command = /^\/api\/bots\/([^/]+)\/computer\/commands$/.exec(
104
+ url.pathname,
105
+ );
106
+ const match = read ?? command;
107
+ if (!match) return undefined;
108
+ if ([...url.searchParams.keys()].length > 0) {
109
+ return errorResponse(
110
+ new ComputerProtocolDecodeError(
111
+ "Computer routes take no query parameters",
112
+ ),
113
+ );
114
+ }
115
+ try {
116
+ const encodedBotId = match[1];
117
+ if (encodedBotId === undefined) {
118
+ throw new ComputerProtocolDecodeError("Computer botId is invalid");
119
+ }
120
+ const botId = pathId(encodedBotId);
121
+ if (read) {
122
+ if (request.method !== "GET") {
123
+ return Response.json(
124
+ { error: "method not allowed" },
125
+ { status: 405 },
126
+ );
127
+ }
128
+ return Response.json(
129
+ decodeComputerProjectionV1(
130
+ await host.readComputer(context.userId, botId),
131
+ ),
132
+ );
133
+ }
134
+ if (request.method !== "POST") {
135
+ return Response.json(
136
+ { error: "method not allowed" },
137
+ { status: 405 },
138
+ );
139
+ }
140
+ const decoded = decodeComputerCommandV1(await request.json());
141
+ if (decoded.botId !== botId) {
142
+ throw new ComputerProtocolDecodeError(
143
+ "Computer command does not match the request path",
144
+ );
145
+ }
146
+ return Response.json(
147
+ decodeComputerCommandReceiptV1(
148
+ await host.executeComputerCommand(context.userId, botId, decoded),
149
+ ),
150
+ );
151
+ } catch (error) {
152
+ return errorResponse(error);
153
+ }
154
+ },
155
+ };
156
+ }
157
+
158
+ export function createComputerBackendPlugin(
159
+ host: ComputerGatewayHost,
160
+ lifecycle: { mount(value: ComputerBackendRouteContribution): () => void },
161
+ ): Plugin {
162
+ return () => lifecycle.mount(createComputerBackendContribution(host));
163
+ }