@frockbot/plugin-fly-sprite 0.0.0 → 0.1.1

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,268 @@
1
+ /**
2
+ * Test support: a double for the shared Computer host (ADR 0004).
3
+ *
4
+ * It is a module under `src` rather than a fixture inside one test file
5
+ * because three suites need the same one — `computer.test.ts`,
6
+ * `workspace.test.ts`, and `sync.test.ts` all drive a `FlySpriteComputer`, and
7
+ * a double per suite would let three of them drift from one contract. It is
8
+ * deliberately absent from this Package's `exports`, so nothing outside can
9
+ * reach it, and it is not a `*.test.ts` file, so `bun test` never runs it as
10
+ * one.
11
+ *
12
+ * What it stands in for is the host, not the Computer: it holds the
13
+ * human-control leases the Sprite's `flock` would hold, answers `open` and
14
+ * `viewer` with the shape the container answers, and hands every script to a
15
+ * runner the suite supplies. What it does not do is HTTP — the wire is
16
+ * `host-client.test.ts`'s subject and the workerd suite's, and repeating it
17
+ * here would test the transport three more times and the provider none.
18
+ */
19
+ import type {
20
+ ComputerHostControlResultV1,
21
+ ComputerHostFileReadResultV1,
22
+ ComputerHostOpenResultV1,
23
+ ComputerHostProvisioningV1,
24
+ ComputerHostViewerResultV1,
25
+ } from "@frockbot/computer-host-protocol";
26
+ import { DESKTOP_GUI_LEASE_KEY } from "@frockbot/computer-host-runtime";
27
+ import {
28
+ computerBotKey,
29
+ type ComputerHostFactoryV1,
30
+ type ComputerHostSurfaceV1,
31
+ } from "./computer.ts";
32
+ import type {
33
+ ComputerHostCallOptions,
34
+ ComputerHostExecCommandV1,
35
+ ComputerHostExecOutcomeV1,
36
+ } from "./host-client.ts";
37
+
38
+ /** What a suite's runner says one script did. */
39
+ export interface FakeComputerRunV1 {
40
+ exitCode?: number;
41
+ stdout?: string;
42
+ stderr?: string;
43
+ outputTruncated?: boolean;
44
+ }
45
+
46
+ export type FakeComputerRunnerV1 = (
47
+ script: string,
48
+ ) => FakeComputerRunV1 | Promise<FakeComputerRunV1>;
49
+
50
+ /** One script the host was asked to run, in order. */
51
+ export interface FakeComputerCommandV1 {
52
+ botId: string;
53
+ script: string;
54
+ timeoutMs?: number;
55
+ maxOutputBytes?: number;
56
+ }
57
+
58
+ interface FakeLease {
59
+ owner: string;
60
+ fresh: boolean;
61
+ }
62
+
63
+ const GUARD = /control\.sh assert-agent '([^']+)' '([^']+)' '([^']+)'/;
64
+ /** The exit code the Computer's control script uses for a refused assertion. */
65
+ const HUMAN_CONTROL_EXIT = 73;
66
+ const HUMAN_CONTROL_MESSAGE = "The user is controlling this agent's computer";
67
+
68
+ const encoder = new TextEncoder();
69
+
70
+ /**
71
+ * A Computer host whose Computer is whatever the suite's runner says.
72
+ *
73
+ * One instance is one User's Computer: `factory` hands out a per-tenant
74
+ * surface, and the leases are shared across them exactly as one Sprite's
75
+ * `flock` is shared across a User's Bots.
76
+ */
77
+ export class FakeComputerHost {
78
+ readonly commands: FakeComputerCommandV1[] = [];
79
+ readonly leases = new Map<string, FakeLease>();
80
+ readonly viewerSessions: Array<{ botId: string; action: string }> = [];
81
+ spriteName = "frockbot-test";
82
+ viewerUrl =
83
+ "https://frockbot-test-123.sprites.app/vnc.html#autoconnect=1&password=secret-pass";
84
+ display: string | undefined = ":100";
85
+ generation = 1;
86
+ provisioning?: ComputerHostProvisioningV1;
87
+ /** Set to refuse the next `open`, the way an exhausted slot pool does. */
88
+ openFailure?: Error;
89
+ /** The bytes `file/read` answers with, by absolute path on the Computer. */
90
+ readonly files = new Map<string, Uint8Array>();
91
+ /** Every `file/read` the host was asked for, in order. */
92
+ readonly reads: Array<{ botId: string; path: string }> = [];
93
+
94
+ constructor(private runner: FakeComputerRunnerV1 = () => ({})) {}
95
+
96
+ /** Replaces the runner, so a suite can change behaviour mid-test. */
97
+ runs(runner: FakeComputerRunnerV1): void {
98
+ this.runner = runner;
99
+ }
100
+
101
+ /** The scripts this host ran, joined — what a suite usually asserts on. */
102
+ get scripts(): string[] {
103
+ return this.commands.map((command) => command.script);
104
+ }
105
+
106
+ readonly factory: ComputerHostFactoryV1 = (_identity, tenant) =>
107
+ this.surface(tenant.botId);
108
+
109
+ surface(botId: string): ComputerHostSurfaceV1 {
110
+ const host = this;
111
+ const botKey = computerBotKey(botId);
112
+ return {
113
+ open(
114
+ options?: ComputerHostCallOptions,
115
+ ): Promise<ComputerHostOpenResultV1> {
116
+ options?.signal?.throwIfAborted();
117
+ if (host.openFailure) return Promise.reject(host.openFailure);
118
+ return Promise.resolve({
119
+ version: 1,
120
+ effectId: options?.effectId ?? "effect-open",
121
+ spriteName: host.spriteName,
122
+ directory: `/home/box/agent-data/agents/${botKey}`,
123
+ ...(host.display ? { display: host.display } : {}),
124
+ generation: host.generation,
125
+ ...(host.provisioning ? { provisioning: host.provisioning } : {}),
126
+ });
127
+ },
128
+
129
+ async exec(
130
+ command: ComputerHostExecCommandV1,
131
+ options?: ComputerHostCallOptions,
132
+ ): Promise<ComputerHostExecOutcomeV1> {
133
+ options?.signal?.throwIfAborted();
134
+ host.commands.push({
135
+ botId,
136
+ script: command.script,
137
+ ...(command.timeoutMs === undefined
138
+ ? {}
139
+ : { timeoutMs: command.timeoutMs }),
140
+ ...(command.maxOutputBytes === undefined
141
+ ? {}
142
+ : { maxOutputBytes: command.maxOutputBytes }),
143
+ });
144
+ const refused = host.assert(command.script);
145
+ if (refused) return refused;
146
+ const run = await host.runner(command.script);
147
+ return {
148
+ effectId: options?.effectId ?? "effect-exec",
149
+ exitCode: run.exitCode ?? 0,
150
+ stdout: encoder.encode(run.stdout ?? ""),
151
+ stderr: encoder.encode(run.stderr ?? ""),
152
+ outputTruncated: run.outputTruncated ?? false,
153
+ };
154
+ },
155
+
156
+ fileRead(
157
+ path: string,
158
+ options?: ComputerHostCallOptions,
159
+ ): Promise<ComputerHostFileReadResultV1> {
160
+ options?.signal?.throwIfAborted();
161
+ host.reads.push({ botId, path });
162
+ const bytes = host.files.get(path);
163
+ if (!bytes) {
164
+ return Promise.reject(new Error(`no such file: ${path}`));
165
+ }
166
+ return Promise.resolve({
167
+ version: 1,
168
+ effectId: options?.effectId ?? "effect-file-read",
169
+ entry: {
170
+ path,
171
+ kind: "file",
172
+ size: bytes.byteLength,
173
+ mode: 0o600,
174
+ },
175
+ bytesBase64: Buffer.from(bytes).toString("base64"),
176
+ });
177
+ },
178
+
179
+ control(
180
+ action: "acquire" | "renew" | "release",
181
+ ownerId: string,
182
+ maxAgeSeconds: number,
183
+ options?: ComputerHostCallOptions & {
184
+ scope?: "bot" | "desktop-gui";
185
+ },
186
+ ): Promise<ComputerHostControlResultV1> {
187
+ options?.signal?.throwIfAborted();
188
+ const leaseKey =
189
+ options?.scope === "desktop-gui" ? DESKTOP_GUI_LEASE_KEY : botKey;
190
+ const lease = host.leases.get(leaseKey);
191
+ if (action === "acquire") {
192
+ if (lease?.fresh && lease.owner !== ownerId) {
193
+ return Promise.reject(new Error("human control is active"));
194
+ }
195
+ host.leases.set(leaseKey, { owner: ownerId, fresh: true });
196
+ } else if (action === "renew") {
197
+ if (lease?.owner !== ownerId) {
198
+ return Promise.reject(new Error("lease owner changed"));
199
+ }
200
+ lease.fresh = true;
201
+ } else if (lease?.owner === ownerId) {
202
+ host.leases.delete(leaseKey);
203
+ }
204
+ return Promise.resolve({
205
+ version: 1,
206
+ effectId: options?.effectId ?? "effect-control",
207
+ action,
208
+ ownerId,
209
+ ...(action === "release"
210
+ ? {}
211
+ : {
212
+ expiresAt: new Date(
213
+ Date.now() + maxAgeSeconds * 1_000,
214
+ ).toISOString(),
215
+ }),
216
+ });
217
+ },
218
+
219
+ viewer(
220
+ action: "open" | "renew" | "revoke",
221
+ options?: ComputerHostCallOptions & { sessionId?: string },
222
+ ): Promise<ComputerHostViewerResultV1> {
223
+ options?.signal?.throwIfAborted();
224
+ host.viewerSessions.push({ botId, action });
225
+ return Promise.resolve({
226
+ version: 1,
227
+ effectId: options?.effectId ?? "effect-viewer",
228
+ ...(action === "revoke"
229
+ ? {}
230
+ : {
231
+ session: {
232
+ id: "secret-token",
233
+ url: host.viewerUrl,
234
+ expiresAt: new Date(Date.now() + 900_000).toISOString(),
235
+ },
236
+ }),
237
+ });
238
+ },
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Applies the human-control guard the script carries.
244
+ *
245
+ * The guard is a line of bash on a real Computer, so a double that ignored
246
+ * it would let a suite prove the provider respects a lease it never
247
+ * consulted. Exit 73 is what the Computer's own control script answers.
248
+ */
249
+ private assert(script: string): ComputerHostExecOutcomeV1 | undefined {
250
+ const match = GUARD.exec(script);
251
+ if (!match) return undefined;
252
+ const [, botKey = "", desktopKey = "", owner = ""] = match;
253
+ for (const key of [botKey, desktopKey]) {
254
+ const lease = this.leases.get(key);
255
+ if (lease?.fresh && lease.owner !== owner) {
256
+ return {
257
+ effectId: "effect-refused",
258
+ exitCode: HUMAN_CONTROL_EXIT,
259
+ stdout: encoder.encode(""),
260
+ stderr: encoder.encode(`${HUMAN_CONTROL_MESSAGE}: ${lease.owner}`),
261
+ outputTruncated: false,
262
+ };
263
+ }
264
+ if (lease && !lease.fresh) this.leases.delete(key);
265
+ }
266
+ return undefined;
267
+ }
268
+ }
package/src/host.ts ADDED
@@ -0,0 +1,331 @@
1
+ import type { Entry } from "@cordisjs/plugin-webui";
2
+ import { ComputerError } from "@frockbot/computer-core";
3
+ import type { ComputerState } from "@frockbot/plugin-computer/shared";
4
+ import { computerUpdateLabelV1 } from "@frockbot/plugin-computer/protocol";
5
+ import {
6
+ initialComputerMachineState,
7
+ transitionComputerState,
8
+ type ComputerMachineEvent,
9
+ type ComputerMachineState,
10
+ } from "@frockbot/plugin-computer/client-state-machine";
11
+ import type { Context, Plugin } from "cordis";
12
+ import {
13
+ type ComputerBotIdentity,
14
+ type FlySpriteAgentComputer,
15
+ FlySpriteComputer,
16
+ } from "./computer.ts";
17
+ import { flySpriteNameForComputer } from "./provider.ts";
18
+
19
+ export function configuredFlyBotId(
20
+ environment: { FROCKBOT_BOT_ID?: string } = process.env as {
21
+ FROCKBOT_BOT_ID?: string;
22
+ },
23
+ ): string {
24
+ return environment.FROCKBOT_BOT_ID?.trim() || "barebones";
25
+ }
26
+
27
+ class FlySpriteHostController {
28
+ private readonly computer: FlySpriteAgentComputer;
29
+ private readonly configured: boolean;
30
+ private readonly entry: Entry<ComputerState>;
31
+ private readonly data: ComputerState;
32
+ private machine: ComputerMachineState;
33
+ private controlHeartbeat?: ReturnType<typeof setInterval>;
34
+ private viewerHeartbeat?: ReturnType<typeof setInterval>;
35
+ private viewerSessionId?: string;
36
+ private controlRequest?: Promise<void>;
37
+ private takingControl = false;
38
+
39
+ constructor(
40
+ ctx: Context,
41
+ computer: FlySpriteComputer,
42
+ identity: ComputerBotIdentity,
43
+ ) {
44
+ this.computer = computer.bot(identity);
45
+ this.configured = computer.configured;
46
+ this.machine = transitionComputerState(initialComputerMachineState(), {
47
+ type: "configured",
48
+ botId: this.computer.botId,
49
+ providerLabel: "Fly Sprites",
50
+ configured: computer.configured,
51
+ message: computer.configured
52
+ ? "Persistent Fly Sprite computer"
53
+ : "Set SPRITES_TOKEN to attach a computer",
54
+ });
55
+ const data: ComputerState = {
56
+ ...this.machine,
57
+ connect: () => this.connect(),
58
+ openViewer: () => this.openViewer(),
59
+ closeViewer: () => this.closeViewer(),
60
+ takeControl: () => this.takeOver(),
61
+ releaseControl: () => this.release(),
62
+ runDoctor: () => this.runDoctor(),
63
+ retry: () => this.connect(),
64
+ };
65
+ this.data = data;
66
+ this.entry = ctx.webui.addEntry(
67
+ {
68
+ modulePath: "@frockbot/plugin-computer",
69
+ baseUrl: import.meta.resolve("@frockbot/plugin-computer/package.json"),
70
+ source: "./src/client/index.ts",
71
+ manifest: "./dist/manifest.json",
72
+ },
73
+ data,
74
+ );
75
+ }
76
+
77
+ async dispose(): Promise<void> {
78
+ this.stopControlHeartbeat();
79
+ this.stopViewerHeartbeat();
80
+ if (this.takingControl) {
81
+ try {
82
+ await this.computer.releaseControl();
83
+ } catch {
84
+ // Best-effort cleanup during application shutdown.
85
+ }
86
+ }
87
+ this.entry.dispose();
88
+ }
89
+
90
+ private async connect(): Promise<void> {
91
+ if (!this.configured) return;
92
+ this.apply({ type: "connect-requested" });
93
+ try {
94
+ const connection = await this.computer.connect();
95
+ this.viewerSessionId = connection.viewerSessionId;
96
+ this.apply({ type: "connected", viewerUrl: connection.viewerUrl });
97
+ const updateLabel = computerUpdateLabelV1(connection.message);
98
+ if (updateLabel) {
99
+ this.apply({ type: "update-reported", message: updateLabel });
100
+ }
101
+ } catch (error) {
102
+ this.fail(error);
103
+ }
104
+ }
105
+
106
+ private async openViewer(): Promise<void> {
107
+ if (this.machine.expanded) return;
108
+ const wake = this.machine.phase === "idle";
109
+ this.apply({ type: "viewer-expanded" });
110
+ if (wake) await this.connect();
111
+ }
112
+
113
+ private async closeViewer(): Promise<void> {
114
+ if (!this.machine.expanded) return;
115
+ if (this.controlRequest) {
116
+ try {
117
+ await this.controlRequest;
118
+ } catch {
119
+ // The acquisition failure is already the visible machine state.
120
+ }
121
+ }
122
+ if (this.takingControl) await this.release();
123
+ this.apply({ type: "viewer-collapsed" });
124
+ }
125
+
126
+ private takeOver(): Promise<void> {
127
+ if (this.controlRequest) return this.controlRequest;
128
+ const pending = this.acquireControl();
129
+ this.controlRequest = pending.finally(() => {
130
+ this.controlRequest = undefined;
131
+ });
132
+ return this.controlRequest;
133
+ }
134
+
135
+ private async acquireControl(): Promise<void> {
136
+ if (!this.configured || this.takingControl) return;
137
+ if (!this.current().viewerUrl) await this.connect();
138
+ if (!this.current().viewerUrl) return;
139
+ this.apply({ type: "take-control-requested" });
140
+ try {
141
+ await this.computer.takeControl();
142
+ this.takingControl = true;
143
+ this.startControlHeartbeat();
144
+ this.apply({ type: "control-acquired" });
145
+ } catch (error) {
146
+ this.fail(error);
147
+ }
148
+ }
149
+
150
+ private async release(): Promise<void> {
151
+ if (!this.takingControl) return;
152
+ try {
153
+ await this.computer.releaseControl();
154
+ this.stopControlHeartbeat();
155
+ this.takingControl = false;
156
+ this.apply({ type: "control-released" });
157
+ } catch (error) {
158
+ this.fail(error);
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Runs the Computer's self-check and publishes the report.
165
+ *
166
+ * A failed run is reported as a failed run rather than as a Computer in an
167
+ * error phase: the self-check not answering is a fact about the self-check,
168
+ * and the desktop beside it may be perfectly fine.
169
+ */
170
+ private async runDoctor(): Promise<void> {
171
+ if (!this.configured) return;
172
+ try {
173
+ const report = await this.computer.doctor(new AbortController().signal);
174
+ this.apply({
175
+ type: "doctor-updated",
176
+ doctor: {
177
+ version: 1,
178
+ capturedAt: report.capturedAt,
179
+ summary: report.summary,
180
+ checks: report.checks.map((check) => ({ version: 1, ...check })),
181
+ },
182
+ });
183
+ } catch (error) {
184
+ this.apply({
185
+ type: "doctor-updated",
186
+ doctor: {
187
+ version: 1,
188
+ capturedAt: new Date().toISOString(),
189
+ summary: "the self-check could not be run",
190
+ checks: [
191
+ {
192
+ version: 1,
193
+ name: "self-check",
194
+ status: "fail",
195
+ detail: error instanceof Error ? error.message : String(error),
196
+ },
197
+ ],
198
+ },
199
+ });
200
+ }
201
+ }
202
+
203
+ private current(): ComputerState {
204
+ return this.data;
205
+ }
206
+
207
+ private startControlHeartbeat(): void {
208
+ this.stopControlHeartbeat();
209
+ this.controlHeartbeat = setInterval(
210
+ () => void this.refreshControl(),
211
+ 30_000,
212
+ );
213
+ }
214
+
215
+ private stopControlHeartbeat(): void {
216
+ if (this.controlHeartbeat) clearInterval(this.controlHeartbeat);
217
+ this.controlHeartbeat = undefined;
218
+ }
219
+
220
+ private syncViewerHeartbeat(): void {
221
+ if (!this.machine.expanded || !this.viewerSessionId) {
222
+ this.stopViewerHeartbeat();
223
+ return;
224
+ }
225
+ if (!this.viewerHeartbeat) {
226
+ this.viewerHeartbeat = setInterval(
227
+ () => void this.refreshViewer(),
228
+ 30_000,
229
+ );
230
+ }
231
+ }
232
+
233
+ private stopViewerHeartbeat(): void {
234
+ if (this.viewerHeartbeat) clearInterval(this.viewerHeartbeat);
235
+ this.viewerHeartbeat = undefined;
236
+ }
237
+
238
+ private async refreshViewer(): Promise<void> {
239
+ const sessionId = this.viewerSessionId;
240
+ if (!sessionId) return;
241
+ try {
242
+ await this.computer.refreshViewer(sessionId);
243
+ const viewerUrl = this.current().viewerUrl;
244
+ if (this.machine.phase === "updating" && viewerUrl) {
245
+ this.apply({ type: "connected", viewerUrl });
246
+ }
247
+ } catch (error) {
248
+ this.viewerSessionId = undefined;
249
+ const detail = error instanceof Error ? error.message : String(error);
250
+ this.apply({
251
+ type: "viewer-disconnected",
252
+ message: `Viewer disconnected: ${detail}`,
253
+ });
254
+ }
255
+ }
256
+
257
+ private async refreshControl(): Promise<void> {
258
+ try {
259
+ await this.computer.refreshControl();
260
+ } catch (error) {
261
+ this.stopControlHeartbeat();
262
+ this.takingControl = false;
263
+ const detail = error instanceof Error ? error.message : String(error);
264
+ this.apply({
265
+ type: "failed",
266
+ message: `Human control lease was lost: ${detail}`,
267
+ takingControl: false,
268
+ });
269
+ }
270
+ }
271
+
272
+ private apply(event: ComputerMachineEvent): void {
273
+ this.machine = transitionComputerState(this.machine, event);
274
+ Object.assign(this.data, this.machine);
275
+ this.entry.mutate((data) => Object.assign(data, this.machine));
276
+ this.syncViewerHeartbeat();
277
+ }
278
+
279
+ private fail(error: unknown): void {
280
+ if (error instanceof ComputerError && error.code === "updating") {
281
+ this.apply({
282
+ type: "update-reported",
283
+ message: computerUpdateLabelV1(error.message) ?? error.message,
284
+ });
285
+ return;
286
+ }
287
+ this.apply({
288
+ type: "failed",
289
+ message: error instanceof Error ? error.message : String(error),
290
+ takingControl: this.takingControl,
291
+ });
292
+ }
293
+ }
294
+
295
+ export function createFlySpriteHostPlugin(
296
+ computer: FlySpriteComputer,
297
+ identity: ComputerBotIdentity = {
298
+ id: configuredFlyBotId(),
299
+ name: process.env.FROCKBOT_AGENT_NAME?.trim() || "Barebones",
300
+ },
301
+ ): Plugin.Function {
302
+ const plugin: Plugin.Function = (ctx) => {
303
+ const controller = new FlySpriteHostController(ctx, computer, identity);
304
+ return () => controller.dispose();
305
+ };
306
+ plugin.inject = ["webui"];
307
+ return plugin;
308
+ }
309
+
310
+ const defaultUserId = process.env.FROCKBOT_USER_ID?.trim() || "local-user";
311
+ const defaultBotId = configuredFlyBotId();
312
+
313
+ const selectedProvider =
314
+ process.env.FROCKBOT_COMPUTER_PROVIDER?.trim() || "fly-sprite";
315
+
316
+ export const flySpriteHostPlugin: Plugin.Function =
317
+ selectedProvider === "fly-sprite"
318
+ ? createFlySpriteHostPlugin(
319
+ new FlySpriteComputer({
320
+ respectHumanControl: true,
321
+ // One Sprite per User (ADR 0012): the Bot is a tenant on it.
322
+ spriteName: flySpriteNameForComputer({ userId: defaultUserId }),
323
+ }),
324
+ {
325
+ id: defaultBotId,
326
+ name: process.env.FROCKBOT_AGENT_NAME?.trim() || "Barebones",
327
+ },
328
+ )
329
+ : () => undefined;
330
+
331
+ export default flySpriteHostPlugin;
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./computer.js";
2
+ export * from "./host.js";
3
+ export * from "./host-client.js";
4
+ export { default as flySpriteManifest } from "./manifest.js";
5
+ export * from "./provider.js";
6
+ export * from "./sync.js";
7
+ export * from "./workspace.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;