@frockbot/plugin-fly-sprite 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.
@@ -0,0 +1,591 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ ComputerError,
4
+ computerIdentityKeyV1,
5
+ computerSyncSummaryV1,
6
+ computerTenantBotIdV1,
7
+ type ComputerAssignment,
8
+ type ComputerBrowserAction,
9
+ type ComputerBrowserState,
10
+ type ComputerControlLease,
11
+ type ComputerExecRequest,
12
+ type ComputerHandle,
13
+ type ComputerIdentityV1,
14
+ type ComputerOperationOptions,
15
+ type ComputerProvider,
16
+ type ComputerSyncHostV1,
17
+ type ComputerSyncReasonV1,
18
+ type ComputerSyncSummaryV1,
19
+ type ComputerSyncV1,
20
+ type ComputerTenantV1,
21
+ type WorkspaceLayoutV1,
22
+ } from "@frockbot/computer-core";
23
+ import type { Plugin } from "cordis";
24
+ import {
25
+ computerBotKey,
26
+ type BrowserAction,
27
+ type ComputerHostFactoryV1,
28
+ FlySpriteComputer,
29
+ type FlySpriteAgentComputer,
30
+ flySpriteNameForBot,
31
+ } from "./computer.js";
32
+ import { FlyComputerWorkspace } from "./workspace.js";
33
+ import { createFlySpriteSyncV1, type WorkspaceSyncReportV1 } from "./sync.js";
34
+
35
+ const encoder = new TextEncoder();
36
+
37
+ /**
38
+ * The durable roots this provider guarantees, laid out to match GrokBot's box
39
+ * (`docs/research/grokbot-computer.md`): `HOME=/home/box`, durable application
40
+ * data under `agent-data`, per-Bot state under `agents/<key>`, and User-shared
41
+ * memory beside it.
42
+ *
43
+ * Memory roots are `read-only` from the Computer's point of view. "The Memory
44
+ * Package is the single writer of Memory roots ... the Workspace presents
45
+ * Memory roots read-only through the durable-root sync" (ADR 0013). That sync
46
+ * is `./sync.ts`: it materializes Memory roots at these mount paths from
47
+ * object storage and never pushes a change back out of them. The User-global
48
+ * instruction root is read-only for the same reason and by the same mechanism
49
+ * (ADR 0016): the Skills Package is its single writer, so a shared root needs
50
+ * no conflict machinery and no Turn has to wake a Computer to read a Skill.
51
+ *
52
+ * `package-declared` roots are User-scoped, because Package availability is
53
+ * User-level and `WorkspaceRootV1` names a Package root by User and Package.
54
+ */
55
+ export const FLY_WORKSPACE_LAYOUT: WorkspaceLayoutV1 = {
56
+ schemaVersion: 1,
57
+ home: "/home/box",
58
+ roots: [
59
+ {
60
+ kind: "bot-instructions",
61
+ scope: "bot",
62
+ mountPath: "/home/box/agent-data/agents/{bot}/skills",
63
+ access: "read-write",
64
+ },
65
+ {
66
+ // GrokBot's `agent-data/workflows/<slug>/SKILL.md`: the User's own
67
+ // Skills, global across all of their assistants. Read-only on the
68
+ // Computer — the Skills Package writes it through object storage and
69
+ // the sync only materializes it (ADR 0016, extending ADR 0013's Memory
70
+ // exception).
71
+ kind: "user-instructions",
72
+ scope: "user",
73
+ mountPath: "/home/box/agent-data/workflows",
74
+ access: "read-only",
75
+ },
76
+ {
77
+ kind: "bot-memory",
78
+ scope: "bot",
79
+ mountPath: "/home/box/agent-data/agents/{bot}/memory",
80
+ access: "read-only",
81
+ },
82
+ {
83
+ kind: "user-memory",
84
+ scope: "user",
85
+ mountPath: "/home/box/agent-data/user-memory",
86
+ access: "read-only",
87
+ },
88
+ {
89
+ kind: "package-declared",
90
+ scope: "user",
91
+ mountPath: "/home/box/agent-data/user-packages/{package}/{root}",
92
+ access: "read-write",
93
+ },
94
+ ],
95
+ };
96
+
97
+ function browserAction(action: ComputerBrowserAction): BrowserAction {
98
+ switch (action.type) {
99
+ case "snapshot":
100
+ return { action: "snapshot" };
101
+ case "navigate":
102
+ return { action: "navigate", url: action.url };
103
+ case "click":
104
+ return {
105
+ action: "click",
106
+ role: action.role,
107
+ name: action.name,
108
+ exact: action.exact,
109
+ };
110
+ case "fill":
111
+ return {
112
+ action: "fill",
113
+ label: action.label,
114
+ text: action.text,
115
+ exact: action.exact,
116
+ };
117
+ case "press":
118
+ return { action: "press", key: action.key };
119
+ case "wait":
120
+ return { action: "wait", milliseconds: action.milliseconds };
121
+ }
122
+ }
123
+
124
+ function browserState(output: string): ComputerBrowserState {
125
+ try {
126
+ const parsed: unknown = JSON.parse(output);
127
+ if (
128
+ typeof parsed === "object" &&
129
+ parsed !== null &&
130
+ "snapshot" in parsed &&
131
+ typeof parsed.snapshot === "string"
132
+ ) {
133
+ const value = parsed as {
134
+ url?: unknown;
135
+ title?: unknown;
136
+ snapshot: string;
137
+ };
138
+ return {
139
+ url: typeof value.url === "string" ? value.url : undefined,
140
+ title: typeof value.title === "string" ? value.title : undefined,
141
+ accessibilitySnapshot: value.snapshot,
142
+ };
143
+ }
144
+ } catch {
145
+ // Preserve provider output as a diagnostic snapshot.
146
+ }
147
+ return { accessibilitySnapshot: output };
148
+ }
149
+
150
+ function shellQuote(value: string): string {
151
+ return `'${value.replaceAll("'", `'\\''`)}'`;
152
+ }
153
+
154
+ function commandFor(
155
+ executable: string,
156
+ args: readonly string[] | undefined,
157
+ ): string {
158
+ if (
159
+ (executable === "/bin/bash" || executable === "bash") &&
160
+ args?.[0] === "-lc" &&
161
+ typeof args[1] === "string"
162
+ ) {
163
+ return args[1];
164
+ }
165
+ return [executable, ...(args ?? [])].map(shellQuote).join(" ");
166
+ }
167
+
168
+ /**
169
+ * The durable-root sync of ADR 0013, behind the provider-neutral
170
+ * `ComputerSyncV1`.
171
+ *
172
+ * Everything Fly-specific stops here: the reconciliation itself is
173
+ * `./sync.ts`, the object-storage side and the Durable Object records come
174
+ * from the host, and what leaves this class is counts and a status. It exists
175
+ * only while a Computer is open for a Bot, so it can never be the reason a
176
+ * hibernated Computer wakes.
177
+ *
178
+ * `reconcile` never throws. A paused Sprite, a dropped connection, a store
179
+ * that refuses: each is a declared outcome its caller records on the Turn and
180
+ * carries on — "a dropped connection is an outcome, not a failure."
181
+ */
182
+ class FlySpriteComputerSync implements ComputerSyncV1 {
183
+ private readonly sync: ReturnType<typeof createFlySpriteSyncV1>;
184
+
185
+ constructor(
186
+ computer: FlySpriteAgentComputer,
187
+ identity: ComputerIdentityV1,
188
+ tenant: ComputerTenantV1,
189
+ host: ComputerSyncHostV1,
190
+ ) {
191
+ this.sync = createFlySpriteSyncV1({
192
+ computer,
193
+ layout: FLY_WORKSPACE_LAYOUT,
194
+ userId: identity.userId,
195
+ botDirectoryKey: computerBotKey,
196
+ botIds: [tenant.botId],
197
+ store: host.store,
198
+ ...(host.effects ? { effects: host.effects } : {}),
199
+ ...(host.generations ? { generations: host.generations } : {}),
200
+ });
201
+ }
202
+
203
+ async reconcile(
204
+ _reason: ComputerSyncReasonV1,
205
+ options?: ComputerOperationOptions,
206
+ ): Promise<ComputerSyncSummaryV1> {
207
+ if (options?.signal?.aborted) {
208
+ return computerSyncSummaryV1("skipped", "the Turn was cancelled");
209
+ }
210
+ let report: WorkspaceSyncReportV1;
211
+ try {
212
+ report = await this.sync.sync();
213
+ } catch (error) {
214
+ return computerSyncSummaryV1(
215
+ "unavailable",
216
+ error instanceof Error ? error.message : String(error),
217
+ );
218
+ }
219
+ return summarize(report);
220
+ }
221
+
222
+ async signal(
223
+ options?: ComputerOperationOptions,
224
+ ): Promise<string | undefined> {
225
+ if (options?.signal?.aborted) return undefined;
226
+ try {
227
+ const outcome = await this.sync.signal();
228
+ return outcome.status === "ok" ? (outcome.text ?? "") : undefined;
229
+ } catch {
230
+ return undefined;
231
+ }
232
+ }
233
+ }
234
+
235
+ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
236
+ const total = (
237
+ pick: (root: WorkspaceSyncReportV1["roots"][number]) => number,
238
+ ) => report.roots.reduce((sum, root) => sum + pick(root), 0);
239
+ const failed = report.failures[0];
240
+ const summary: ComputerSyncSummaryV1 = {
241
+ // Every root failing is `unavailable` — the usual shape of a paused
242
+ // Sprite. A partial failure is still an `ok` run that says what it missed.
243
+ status:
244
+ report.failures.length > 0 &&
245
+ report.roots.every((root) => root.failures.length > 0)
246
+ ? "unavailable"
247
+ : "ok",
248
+ detail: failed ? `${failed.status}: ${failed.reason}`.slice(0, 512) : "",
249
+ pulled: total((root) => root.pulled.length),
250
+ pushed: total((root) => root.pushed.length),
251
+ restored: total((root) => root.restored.length),
252
+ removed: total(
253
+ (root) => root.removedOnComputer.length + root.removedInStore.length,
254
+ ),
255
+ adopted: total((root) => root.adopted.length),
256
+ conflicts: report.conflicts.length,
257
+ failures: report.failures.length,
258
+ };
259
+ return summary;
260
+ }
261
+
262
+ /**
263
+ * One bash document for one exec request.
264
+ *
265
+ * `cwd` and `env` become `cd` and `export` lines rather than transport
266
+ * options, and `stdin` is fed to the command from a heredoc, because the whole
267
+ * request travels as a script on the command's own stdin. Nothing reaches an
268
+ * argv, which is what the 431 recorded in ADR 0004 cost to learn.
269
+ */
270
+ function composed(request: ComputerExecRequest): string {
271
+ const command = commandFor(request.executable, request.args);
272
+ const lines: string[] = [];
273
+ for (const [key, value] of Object.entries(request.env ?? {})) {
274
+ lines.push(`export ${key}=${shellQuote(value)}`);
275
+ }
276
+ if (request.cwd) lines.push(`cd ${shellQuote(request.cwd)}`);
277
+ if (request.stdin === undefined) {
278
+ lines.push(command);
279
+ } else {
280
+ // A quoted heredoc: the bytes reach the command unexpanded, and a
281
+ // delimiter derived from them cannot appear inside them.
282
+ const marker = `FROCKBOT_STDIN_${createHash("sha256")
283
+ .update(request.stdin)
284
+ .digest("hex")
285
+ .slice(0, 16)
286
+ .toUpperCase()}`;
287
+ lines.push(`${command} <<'${marker}'`);
288
+ lines.push(new TextDecoder().decode(request.stdin));
289
+ lines.push(marker);
290
+ }
291
+ return lines.join("\n");
292
+ }
293
+
294
+ function lease(result: {
295
+ ownerId: string;
296
+ expiresAt?: string;
297
+ }): ComputerControlLease {
298
+ return {
299
+ id: result.ownerId,
300
+ // A lease with no expiry would be a lease nothing can reclaim. The host
301
+ // always dates an acquire and a renew; this is the refusal if it ever
302
+ // does not.
303
+ expiresAt: result.expiresAt ?? new Date(0).toISOString(),
304
+ };
305
+ }
306
+
307
+ function handle(
308
+ identity: ComputerIdentityV1,
309
+ tenant: ComputerTenantV1,
310
+ computer: FlySpriteAgentComputer,
311
+ assignment: ComputerAssignment,
312
+ syncHost?: ComputerSyncHostV1,
313
+ ): ComputerHandle {
314
+ return {
315
+ assignment,
316
+ identity,
317
+ tenant,
318
+ ...(syncHost
319
+ ? {
320
+ sync: new FlySpriteComputerSync(computer, identity, tenant, syncHost),
321
+ }
322
+ : {}),
323
+ workspace: new FlyComputerWorkspace(FLY_WORKSPACE_LAYOUT, {
324
+ computer,
325
+ userId: identity.userId,
326
+ botId: tenant.botId,
327
+ botDirectoryKey: computerBotKey,
328
+ // The Durable Object's generation ledger, when the host supplied one.
329
+ // Without it the Computer's Workspace can attribute nothing, because a
330
+ // sidecar on the Computer is a hint and never an authority.
331
+ ...(syncHost?.generations ? { generations: syncHost.generations } : {}),
332
+ }),
333
+ exec: {
334
+ execute: async (request, options) => {
335
+ // `cwd`, `env`, and `stdin` used to be refused because the Sprites SDK
336
+ // put every one of them into a request URL. The host compiles them
337
+ // into the script it delivers on the command's stdin instead, so they
338
+ // are ordinary parts of a request now.
339
+ const result = await computer.exec(
340
+ composed(request),
341
+ options?.signal ?? new AbortController().signal,
342
+ {
343
+ timeoutMs: request.timeoutMs,
344
+ maxOutputBytes: request.maxOutputBytes,
345
+ },
346
+ );
347
+ return {
348
+ exitCode: result.exitCode,
349
+ stdout: encoder.encode(result.stdout),
350
+ stderr: encoder.encode(result.stderr),
351
+ outputTruncated: result.outputTruncated,
352
+ };
353
+ },
354
+ },
355
+ screenshot: {
356
+ capture: async (options) => {
357
+ const captured = await computer.screenshot(
358
+ options?.signal ?? new AbortController().signal,
359
+ );
360
+ return {
361
+ bytes: captured.bytes,
362
+ mediaType: "image/png",
363
+ display: captured.display,
364
+ capturedAt: captured.capturedAt,
365
+ };
366
+ },
367
+ },
368
+ // The Computer's self-check. Read-only, and not lease-guarded: a Computer
369
+ // under human control is exactly a Computer somebody may need to ask what
370
+ // is wrong with.
371
+ doctor: {
372
+ run: (options) =>
373
+ computer.doctor(options?.signal ?? new AbortController().signal),
374
+ },
375
+ presence: {
376
+ connect: async (options) => {
377
+ const connected = await computer.connect(options);
378
+ return {
379
+ id: connected.viewerSessionId,
380
+ url: connected.viewerUrl,
381
+ ...(connected.viewerExpiresAt
382
+ ? { expiresAt: connected.viewerExpiresAt }
383
+ : {}),
384
+ ...(connected.message ? { message: connected.message } : {}),
385
+ };
386
+ },
387
+ },
388
+ processes: {
389
+ launch: async (request, options) => {
390
+ const launched = await computer.launchProcess(
391
+ request.processId,
392
+ request.command,
393
+ options?.signal ?? new AbortController().signal,
394
+ );
395
+ return {
396
+ pid: launched.pid,
397
+ logPath: launched.logPath,
398
+ cwd: launched.cwd,
399
+ // The generation the launch happened under, which is what a later
400
+ // check compares against to decide whether this is the same
401
+ // Computer at all.
402
+ generation: computer.generation ?? 0,
403
+ };
404
+ },
405
+ inspect: (processId, options) =>
406
+ computer.inspectProcess(
407
+ processId,
408
+ options?.signal ?? new AbortController().signal,
409
+ options?.tailBytes,
410
+ ),
411
+ stop: (processId, options) =>
412
+ computer.stopProcess(
413
+ processId,
414
+ options?.signal ?? new AbortController().signal,
415
+ ),
416
+ // Asked of the host every time, never read from the cached open: a
417
+ // process's whole reconciliation question is whether the Computer
418
+ // answering now is the one it was launched on.
419
+ generation: (options) => computer.currentGeneration(options?.signal),
420
+ },
421
+ browser: {
422
+ perform: async (action, options) =>
423
+ browserState(
424
+ await computer.browser(
425
+ browserAction(action),
426
+ options?.signal ?? new AbortController().signal,
427
+ ),
428
+ ),
429
+ },
430
+ // A viewer and a human-control lease are reachable from the Durable
431
+ // Object now. They were not before: both need the Sprite's URL and its
432
+ // `flock`, and neither was reachable from workerd (ADR 0004).
433
+ viewer: {
434
+ open: async (options) => {
435
+ const result = await computer.viewer(options);
436
+ if (!result.session) {
437
+ throw new ComputerError(
438
+ "provider-unavailable",
439
+ "The Computer host returned no viewer session",
440
+ true,
441
+ );
442
+ }
443
+ return {
444
+ id: result.session.id,
445
+ url: result.session.url,
446
+ ...(result.session.expiresAt
447
+ ? { expiresAt: result.session.expiresAt }
448
+ : {}),
449
+ };
450
+ },
451
+ renew: async (sessionId, options) => {
452
+ const result = await computer.refreshViewer(sessionId, options);
453
+ if (!result.session) {
454
+ throw new ComputerError(
455
+ "provider-unavailable",
456
+ "The Computer host did not renew the viewer session",
457
+ true,
458
+ );
459
+ }
460
+ return {
461
+ id: result.session.id,
462
+ url: result.session.url,
463
+ ...(result.session.expiresAt
464
+ ? { expiresAt: result.session.expiresAt }
465
+ : {}),
466
+ };
467
+ },
468
+ revoke: async (sessionId, options) => {
469
+ await computer.revokeViewer(sessionId, options);
470
+ },
471
+ },
472
+ control: {
473
+ // The scope and the owner travel with the call, so one Computer surface
474
+ // serves legacy per-tenant leases and the User-wide `desktop-gui` lease
475
+ // on which human sessions and `computerUse` subagents contend.
476
+ acquire: async (request, options) =>
477
+ lease(await computer.takeControl(options, request)),
478
+ renew: async (_current, request, options) =>
479
+ lease(await computer.refreshControl(options, request)),
480
+ release: (_current, request, options) =>
481
+ computer.releaseControl(options, request),
482
+ },
483
+ close: () => Promise.resolve(),
484
+ };
485
+ }
486
+
487
+ /**
488
+ * One Sprite per User (ADR 0012). The Sprite name is derived from the User and
489
+ * from nothing else, so every Bot the User owns lands on the same Computer,
490
+ * sharing its browser profile, installed tooling, and Workspace.
491
+ */
492
+ export function flySpriteNameForComputer(identity: ComputerIdentityV1): string {
493
+ return flySpriteNameForBot(JSON.stringify(["user", identity.userId.trim()]));
494
+ }
495
+
496
+ /** Provider adapter that keeps Fly-specific lifecycle behind Computer core. */
497
+ export class FlySpriteComputerProvider implements ComputerProvider {
498
+ readonly id = "fly-sprite";
499
+ readonly workspaceLayout = FLY_WORKSPACE_LAYOUT;
500
+ private readonly computers = new Map<string, FlySpriteComputer>();
501
+
502
+ constructor(
503
+ private readonly fixedComputer?: FlySpriteComputer,
504
+ /**
505
+ * The shared Computer host (ADR 0004). Absent, and every Computer this
506
+ * provider opens is unconfigured: the provider Package holds no Sprites
507
+ * SDK and no token, so without a host there is no compute to reach.
508
+ */
509
+ private readonly host?: ComputerHostFactoryV1,
510
+ /**
511
+ * The object-storage side of the durable roots, and the Durable Object
512
+ * records a push depends on. Supplied by the host for one admitted Turn;
513
+ * absent outside one, and the handle then carries no `sync` at all rather
514
+ * than a sync with nowhere to record its intent.
515
+ */
516
+ private readonly syncHost?: ComputerSyncHostV1,
517
+ /** The active `computerUse` task owner, on that task's child Turn. */
518
+ private readonly agentControlOwnerId?: string,
519
+ ) {}
520
+
521
+ /**
522
+ * The one Sprite backing a User's Computer. One Computer per User (ADR
523
+ * 0012): every Bot the User owns is a tenant on the instance this returns.
524
+ */
525
+ computerFor(identity: ComputerIdentityV1): FlySpriteComputer {
526
+ if (this.fixedComputer) return this.fixedComputer;
527
+ const key = computerIdentityKeyV1(identity);
528
+ let computer = this.computers.get(key);
529
+ if (!computer) {
530
+ computer = new FlySpriteComputer({
531
+ identity: { userId: identity.userId },
532
+ ...(this.host ? { host: this.host } : {}),
533
+ respectHumanControl: true,
534
+ spriteName: flySpriteNameForComputer(identity),
535
+ ...(this.agentControlOwnerId
536
+ ? { agentControlOwnerId: this.agentControlOwnerId }
537
+ : {}),
538
+ });
539
+ this.computers.set(key, computer);
540
+ }
541
+ return computer;
542
+ }
543
+
544
+ open(
545
+ identity: ComputerIdentityV1,
546
+ tenant: ComputerTenantV1,
547
+ assignment: ComputerAssignment,
548
+ _options?: ComputerOperationOptions,
549
+ ): Promise<ComputerHandle> {
550
+ computerIdentityKeyV1(identity);
551
+ const botId = computerTenantBotIdV1(tenant);
552
+ const attached = this.computerFor(identity).bot(botId);
553
+ return Promise.resolve(
554
+ handle(
555
+ { userId: identity.userId },
556
+ {
557
+ botId,
558
+ directory: attached.directory,
559
+ ...(attached.display ? { display: attached.display } : {}),
560
+ },
561
+ attached,
562
+ assignment,
563
+ this.syncHost,
564
+ ),
565
+ );
566
+ }
567
+ }
568
+
569
+ export function createFlySpriteProviderPlugin(
570
+ computer?: FlySpriteComputer,
571
+ options?: {
572
+ host?: ComputerHostFactoryV1;
573
+ sync?: ComputerSyncHostV1;
574
+ agentControlOwnerId?: string;
575
+ },
576
+ ): Plugin.Function {
577
+ const plugin: Plugin.Function = (ctx) =>
578
+ ctx.computers.register(
579
+ new FlySpriteComputerProvider(
580
+ computer,
581
+ options?.host,
582
+ options?.sync,
583
+ options?.agentControlOwnerId,
584
+ ),
585
+ );
586
+ plugin.inject = ["computers"];
587
+ return plugin;
588
+ }
589
+
590
+ export const flySpriteProviderPlugin = createFlySpriteProviderPlugin();
591
+ export default flySpriteProviderPlugin;