@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,247 @@
1
+ // `computer_doctor` (parity row 27) and the GUI policy at the exec seam (row
2
+ // 33).
3
+ //
4
+ // What is asserted here is what the Package does with the Computer's answer:
5
+ // the report is filed through the Workspace so the Bot is recorded as its
6
+ // writer, the self-check runs once for the Computer this instance opened, and
7
+ // a `chromium …` command is refused with the sentence that names the tool to
8
+ // use instead.
9
+ import { describe, expect, test } from "bun:test";
10
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
11
+ import { ToolRegistry } from "@frockbot/plugin-tools";
12
+ import {
13
+ ComputerRegistry,
14
+ computerBotPathKeyV1,
15
+ type ComputerDoctorReportV1,
16
+ type ComputerHandle,
17
+ type ComputerProvider,
18
+ } from "@frockbot/computer-core";
19
+ import { createPluginHarness } from "@frockbot/plugin-testkit";
20
+ import { SessionStore } from "@frockbot/kernel-contracts";
21
+ import { createComputerAgentPlugin } from "./agent.js";
22
+ import { FakeWorkspace } from "./workspace-fixture.js";
23
+
24
+ const REPORT: ComputerDoctorReportV1 = {
25
+ schemaVersion: 2,
26
+ generation: 3,
27
+ capturedAt: "2026-09-01T00:00:00.000Z",
28
+ checks: [
29
+ { name: "disk-root", status: "pass", detail: "12% full, 90 GiB free" },
30
+ { name: "dns", status: "fail", detail: "api.fly.io does not resolve" },
31
+ ],
32
+ // Parity row 34b: what the browser announced itself as, filed with the rest
33
+ // of the report so the measurement is readable while the Computer sleeps.
34
+ browserIdentity: {
35
+ userAgent: "Mozilla/5.0 … Chrome/141.0.0.0 Safari/537.36",
36
+ webdriver: false,
37
+ brands: ["Chromium/141"],
38
+ },
39
+ summary: "2 checks, 1 passed, 1 failed",
40
+ };
41
+
42
+ interface Fixture {
43
+ provider: ComputerProvider;
44
+ runs: number;
45
+ execs: string[];
46
+ }
47
+
48
+ function fixture(workspace: FakeWorkspace): Fixture {
49
+ const state: Fixture = {
50
+ runs: 0,
51
+ execs: [],
52
+ provider: {
53
+ id: "fixture",
54
+ open: (identity, tenant, assignment): Promise<ComputerHandle> =>
55
+ Promise.resolve({
56
+ assignment,
57
+ identity,
58
+ tenant,
59
+ workspace,
60
+ doctor: {
61
+ run: () => {
62
+ state.runs += 1;
63
+ return Promise.resolve(REPORT);
64
+ },
65
+ },
66
+ exec: {
67
+ execute: (request) => {
68
+ state.execs.push(request.args?.at(-1) ?? "");
69
+ return Promise.resolve({
70
+ exitCode: 0,
71
+ stdout: new TextEncoder().encode("ran"),
72
+ stderr: new Uint8Array(),
73
+ outputTruncated: false,
74
+ });
75
+ },
76
+ },
77
+ close: () => Promise.resolve(),
78
+ }),
79
+ },
80
+ };
81
+ return state;
82
+ }
83
+
84
+ async function mount(provider: ComputerProvider) {
85
+ const harness = await createPluginHarness([
86
+ ComputerRegistry,
87
+ ToolRegistry,
88
+ SystemPromptRegistry,
89
+ SessionStore,
90
+ ]);
91
+ harness.root.computers.register(provider);
92
+ await harness.mount(
93
+ createComputerAgentPlugin({
94
+ userId: "user-1",
95
+ defaultProviderId: "fixture",
96
+ writer: { sessionId: "session-1", turnId: "run-9", runId: "run-9" },
97
+ }),
98
+ );
99
+ return harness;
100
+ }
101
+
102
+ function context(effectId = "tool:1:1:0") {
103
+ return {
104
+ botId: "bot-1",
105
+ agentId: "run-9",
106
+ compositionGenerationId: "bootstrap",
107
+ turnType: "chat" as const,
108
+ sessionId: "session-1",
109
+ effectId,
110
+ signal: new AbortController().signal,
111
+ };
112
+ }
113
+
114
+ async function call(
115
+ harness: Awaited<ReturnType<typeof createPluginHarness>>,
116
+ name: string,
117
+ input: unknown,
118
+ effectId?: string,
119
+ ) {
120
+ const execution = context(effectId);
121
+ const prepared = await harness.root.tools.prepare(
122
+ { id: crypto.randomUUID(), name, input },
123
+ execution,
124
+ );
125
+ if (prepared.kind !== "ready") return prepared.result;
126
+ return harness.root.tools.executePrepared(prepared, execution);
127
+ }
128
+
129
+ describe("computer_doctor", () => {
130
+ test("answers the report and files it with the Bot as its writer", async () => {
131
+ const workspace = new FakeWorkspace();
132
+ const state = fixture(workspace);
133
+ const harness = await mount(state.provider);
134
+
135
+ const result = await call(harness, "computer_doctor", {});
136
+
137
+ expect(result.isError).toBe(false);
138
+ const answer = JSON.parse(result.content) as Record<string, unknown>;
139
+ expect(answer).toMatchObject({
140
+ schemaVersion: 2,
141
+ generation: 3,
142
+ summary: "2 checks, 1 passed, 1 failed",
143
+ rootId: "doctor",
144
+ });
145
+ const botKey = computerBotPathKeyV1("bot-1");
146
+ expect(answer.path).toBe(`${botKey}/latest.json`);
147
+ expect(answer.checks).toHaveLength(2);
148
+
149
+ // Through the Workspace, never left on the Computer: a report that
150
+ // reached object storage by a shell write would arrive `unattributed`,
151
+ // which is data and never provenance.
152
+ const written = workspace.writes.find((write) =>
153
+ write.path.path.endsWith("latest.json"),
154
+ );
155
+ expect(written?.path.root).toMatchObject({
156
+ kind: "package-declared",
157
+ packageId: "computer",
158
+ rootId: "doctor",
159
+ });
160
+ expect(written?.writer).toEqual({
161
+ kind: "bot",
162
+ botId: "bot-1",
163
+ sessionId: "session-1",
164
+ turnId: "run-9",
165
+ runId: "run-9",
166
+ });
167
+ const filed = JSON.parse(
168
+ new TextDecoder().decode(written!.bytes),
169
+ ) as ComputerDoctorReportV1;
170
+ expect(filed).toEqual(REPORT);
171
+ });
172
+
173
+ test("is offered on every turn type, so a Routine can diagnose too", async () => {
174
+ // A Routine that finds a Computer misbehaving has to be able to say what
175
+ // is wrong with it, and a read-only call is admissible wherever a Turn is.
176
+ const state = fixture(new FakeWorkspace());
177
+ const harness = await mount(state.provider);
178
+
179
+ for (const turnType of ["chat", "automation", "subagent"] as const) {
180
+ const names = harness.root.tools
181
+ .schemas({ turnType })
182
+ .map((schema) => schema.name);
183
+ expect(names, turnType).toContain("computer_doctor");
184
+ }
185
+ });
186
+
187
+ test("runs the self-check once for the Computer this instance opened", async () => {
188
+ // "box-doctor runs at startup and on demand". Startup is the first time
189
+ // this Bot reaches its Computer after this Package loaded — the first Turn
190
+ // after a cold provisioning. Repeating it costs a read-only exec and no
191
+ // effect, so the guard is against waste and never against damage.
192
+ const state = fixture(new FakeWorkspace());
193
+ const harness = await mount(state.provider);
194
+
195
+ await call(harness, "computer_exec", { command: "ls" }, "tool:1:1:0");
196
+ await call(harness, "computer_exec", { command: "pwd" }, "tool:1:1:1");
197
+
198
+ expect(state.runs).toBe(1);
199
+ });
200
+ });
201
+
202
+ describe("the GUI is never driven from the shell", () => {
203
+ test("refuses a command that reaches for the browser or the X tools", async () => {
204
+ const state = fixture(new FakeWorkspace());
205
+ const harness = await mount(state.provider);
206
+
207
+ for (const command of [
208
+ "chromium --headless https://example.com",
209
+ "cd /tmp && scrot shot.png",
210
+ "xdotool key Return",
211
+ "sudo x11vnc -display :1",
212
+ ]) {
213
+ const result = await call(harness, "computer_exec", { command });
214
+ expect(result.isError, command).toBe(true);
215
+ expect(result.content).toContain("never driven from the shell");
216
+ expect(result.content).toContain("computer_browser");
217
+ expect(result.content).toContain("computer_screenshot");
218
+ expect(result.content).toContain("frockbot-chrome");
219
+ }
220
+ // Refused at the seam: none of them reached the Computer at all.
221
+ expect(state.execs).toEqual([]);
222
+ });
223
+
224
+ test("lets a command that merely mentions one through", async () => {
225
+ const state = fixture(new FakeWorkspace());
226
+ const harness = await mount(state.provider);
227
+
228
+ const result = await call(harness, "computer_exec", {
229
+ command: "grep -c chromium /home/box/.frockbot/bots/x/chromium.log",
230
+ });
231
+
232
+ expect(result.isError).toBe(false);
233
+ expect(state.execs).toHaveLength(1);
234
+ });
235
+
236
+ test("says where the shared scratch is, and that it is not durable", async () => {
237
+ const state = fixture(new FakeWorkspace());
238
+ const harness = await mount(state.provider);
239
+ const description = harness.root.tools
240
+ .schemas({ turnType: "chat" })
241
+ .find((schema) => schema.name === "computer_exec")?.description;
242
+
243
+ expect(description).toContain("/workspace");
244
+ expect(description).toContain("not durable");
245
+ expect(description).toContain("never driven from the shell");
246
+ });
247
+ });
package/src/env.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+
4
+ const component: DefineComponent<
5
+ Record<string, never>,
6
+ Record<string, never>,
7
+ unknown
8
+ >;
9
+ export default component;
10
+ }
11
+
12
+ declare module "*.css";
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./agent.js";
2
+ export * from "./process-records.js";
3
+ export * from "./process-store.js";
4
+ export { default as computerClientPlugin } from "./client/application.js";
5
+ export * from "./shared.js";
6
+ export { default as computerManifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,178 @@
1
+ // The durable process record, its codec, and the one rule that decides what a
2
+ // process is after the Computer moved under it.
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ COMPUTER_PROCESS_LIMIT_PER_BOT,
6
+ computerProcessKeyV1,
7
+ computerProcessStatusV1,
8
+ ComputerProcessDecodeError,
9
+ decodeComputerProcessRecordV1,
10
+ type ComputerProcessRecordV1,
11
+ } from "./process-records.js";
12
+ import {
13
+ ComputerProcessLimitError,
14
+ ComputerProcessStore,
15
+ type ComputerProcessStorageV1,
16
+ } from "./process-store.js";
17
+
18
+ const record: ComputerProcessRecordV1 = {
19
+ schemaVersion: 1,
20
+ processId: "p-tool-1-1-0",
21
+ botId: "bot-1",
22
+ sessionId: "session-1",
23
+ turnId: "run-9",
24
+ command: "npm run build",
25
+ cwd: "/workspaces/bot-1",
26
+ startedAt: "2026-08-31T00:00:00.000Z",
27
+ status: "running",
28
+ generation: 3,
29
+ effectId: "tool:1:1:0",
30
+ pid: 4321,
31
+ logPath: "/home/box/.frockbot/bots/bot-1/processes/p-tool-1-1-0/log",
32
+ };
33
+
34
+ /** Storage enough for the store: a map with a prefix listing. */
35
+ function storage(): ComputerProcessStorageV1 & { map: Map<string, unknown> } {
36
+ const map = new Map<string, unknown>();
37
+ return {
38
+ map,
39
+ get: <T>(key: string) => Promise.resolve(map.get(key) as T | undefined),
40
+ put: (key, value) => {
41
+ map.set(key, value);
42
+ return Promise.resolve();
43
+ },
44
+ delete: (key) => Promise.resolve(map.delete(key)),
45
+ list: <T>(options: { prefix: string; limit?: number }) => {
46
+ const held = new Map<string, T>();
47
+ for (const [key, value] of map) {
48
+ if (!key.startsWith(options.prefix)) continue;
49
+ if (options.limit !== undefined && held.size >= options.limit) break;
50
+ held.set(key, value as T);
51
+ }
52
+ return Promise.resolve(held);
53
+ },
54
+ };
55
+ }
56
+
57
+ describe("the Computer process record codec", () => {
58
+ test("round-trips an exact record and keys it by its id", () => {
59
+ expect(decodeComputerProcessRecordV1(record)).toEqual(record);
60
+ expect(computerProcessKeyV1(record.processId)).toBe(
61
+ "computer-process:p-tool-1-1-0",
62
+ );
63
+ });
64
+
65
+ test("refuses a record it does not exactly recognise", () => {
66
+ // No migrations: a record the codec refuses is a visible failure rather
67
+ // than something reshaped into a guess.
68
+ expect(() =>
69
+ decodeComputerProcessRecordV1({ ...record, schemaVersion: 2 }),
70
+ ).toThrow(ComputerProcessDecodeError);
71
+ expect(() =>
72
+ decodeComputerProcessRecordV1({ ...record, extra: true }),
73
+ ).toThrow(/unknown field "extra"/);
74
+ expect(() =>
75
+ decodeComputerProcessRecordV1({ ...record, status: "paused" }),
76
+ ).toThrow(/status is invalid/);
77
+ expect(() =>
78
+ decodeComputerProcessRecordV1({ ...record, generation: -1 }),
79
+ ).toThrow(/generation/);
80
+ const { pid: _pid, ...withoutPid } = record;
81
+ expect(decodeComputerProcessRecordV1(withoutPid)).toEqual(withoutPid);
82
+ });
83
+ });
84
+
85
+ describe("the process store", () => {
86
+ test("writes intent, reads it back, and bounds how many a Bot may hold", async () => {
87
+ const held = storage();
88
+ const store = new ComputerProcessStore(held);
89
+
90
+ await store.record(record);
91
+ expect(await store.read(record.processId)).toEqual(record);
92
+ expect(await store.read("p-absent")).toBeUndefined();
93
+
94
+ for (let index = 1; index < COMPUTER_PROCESS_LIMIT_PER_BOT; index += 1) {
95
+ await store.record({ ...record, processId: `p-${index}` });
96
+ }
97
+ await expect(
98
+ store.record({ ...record, processId: "p-one-too-many" }),
99
+ ).rejects.toBeInstanceOf(ComputerProcessLimitError);
100
+ // Updating one that already exists is not a new record and is admitted.
101
+ await store.update({ ...record, status: "exited", exitCode: 0 });
102
+ expect((await store.read(record.processId))?.status).toBe("exited");
103
+ });
104
+
105
+ test("drops a stored value the codec refuses rather than failing the listing", async () => {
106
+ const held = storage();
107
+ const store = new ComputerProcessStore(held);
108
+ await store.record(record);
109
+ held.map.set("computer-process:p-broken", { schemaVersion: 9 });
110
+
111
+ expect((await store.list()).map((entry) => entry.processId)).toEqual([
112
+ record.processId,
113
+ ]);
114
+ });
115
+ });
116
+
117
+ describe("the status a process holds", () => {
118
+ test("is running only when the same Computer still holds a live pid", () => {
119
+ expect(
120
+ computerProcessStatusV1({
121
+ recorded: record,
122
+ currentGeneration: 3,
123
+ observed: { alive: true },
124
+ }),
125
+ ).toEqual({ status: "running" });
126
+ });
127
+
128
+ test("is exited with its code once the Computer recorded one", () => {
129
+ expect(
130
+ computerProcessStatusV1({
131
+ recorded: record,
132
+ currentGeneration: 3,
133
+ observed: { alive: false, exitCode: 2 },
134
+ }),
135
+ ).toEqual({ status: "exited", exitCode: 2 });
136
+ });
137
+
138
+ test("is unknown, never running, once the generation moved", () => {
139
+ // "other processes are assumed dead after a cold pause": a rebuilt
140
+ // Computer is a different Computer, and a live pid on it belongs to
141
+ // whatever is running there now.
142
+ expect(
143
+ computerProcessStatusV1({
144
+ recorded: record,
145
+ currentGeneration: 4,
146
+ observed: { alive: true },
147
+ }),
148
+ ).toEqual({ status: "unknown" });
149
+ // An exit file written before the rebuild is still evidence.
150
+ expect(
151
+ computerProcessStatusV1({
152
+ recorded: record,
153
+ currentGeneration: 4,
154
+ observed: { alive: false, exitCode: 0 },
155
+ }),
156
+ ).toEqual({ status: "exited", exitCode: 0 });
157
+ });
158
+
159
+ test("is unknown when the pid is gone with no recorded exit", () => {
160
+ expect(
161
+ computerProcessStatusV1({
162
+ recorded: record,
163
+ currentGeneration: 3,
164
+ observed: { alive: false },
165
+ }),
166
+ ).toEqual({ status: "unknown" });
167
+ });
168
+
169
+ test("keeps a settled outcome settled", () => {
170
+ expect(
171
+ computerProcessStatusV1({
172
+ recorded: { ...record, status: "exited", exitCode: 7 },
173
+ currentGeneration: 99,
174
+ observed: { alive: true },
175
+ }),
176
+ ).toEqual({ status: "exited", exitCode: 7 });
177
+ });
178
+ });