@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.
- package/frockbot.json +16 -0
- package/package.json +42 -6
- package/src/agent.ts +6 -0
- package/src/computer.test.ts +538 -0
- package/src/computer.ts +1391 -0
- package/src/doctor.test.ts +154 -0
- package/src/host-client.test.ts +618 -0
- package/src/host-client.ts +794 -0
- package/src/host-double.ts +268 -0
- package/src/host.ts +331 -0
- package/src/index.ts +7 -0
- package/src/manifest.ts +3 -0
- package/src/processes.test.ts +176 -0
- package/src/provider.ts +591 -0
- package/src/screenshot.test.ts +145 -0
- package/src/sync.test.ts +1060 -0
- package/src/sync.ts +1275 -0
- package/src/workspace.test.ts +928 -0
- package/src/workspace.ts +782 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/computer.ts
ADDED
|
@@ -0,0 +1,1391 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
ComputerError,
|
|
4
|
+
decodeComputerDoctorReportV1,
|
|
5
|
+
type ComputerControlRequestV1,
|
|
6
|
+
type ComputerDoctorReportV1,
|
|
7
|
+
type ComputerOperationOptions,
|
|
8
|
+
} from "@frockbot/computer-core";
|
|
9
|
+
import type {
|
|
10
|
+
ComputerHostControlResultV1,
|
|
11
|
+
ComputerHostFileReadResultV1,
|
|
12
|
+
ComputerHostOpenResultV1,
|
|
13
|
+
ComputerHostProvisioningV1,
|
|
14
|
+
ComputerHostViewerResultV1,
|
|
15
|
+
} from "@frockbot/computer-host-protocol";
|
|
16
|
+
import {
|
|
17
|
+
BIN_ROOT,
|
|
18
|
+
BOTS_ROOT,
|
|
19
|
+
BOUNDED_LOG_SCRIPT,
|
|
20
|
+
CONTROL_SCRIPT,
|
|
21
|
+
DATA_ROOT,
|
|
22
|
+
DESKTOP_GUI_LEASE_KEY,
|
|
23
|
+
DOCTOR_MARKER,
|
|
24
|
+
DOCTOR_SCRIPT,
|
|
25
|
+
HOME_ROOT,
|
|
26
|
+
LEASE_MAX_AGE_SECONDS,
|
|
27
|
+
NO_SLOTS_MARKER,
|
|
28
|
+
RUNTIME_ROOT,
|
|
29
|
+
SANCTIONED_SURFACE_ENV,
|
|
30
|
+
SCRATCH_ENV,
|
|
31
|
+
SCRATCH_ROOT,
|
|
32
|
+
shellQuote,
|
|
33
|
+
SHIMS_ROOT,
|
|
34
|
+
SLOT_IDLE_SECONDS,
|
|
35
|
+
WORKSPACE_SYNC_SERVICE,
|
|
36
|
+
WORKSPACES_ROOT,
|
|
37
|
+
} from "@frockbot/computer-host-runtime";
|
|
38
|
+
import type {
|
|
39
|
+
ComputerHostCallOptions,
|
|
40
|
+
ComputerHostExecCommandV1,
|
|
41
|
+
ComputerHostExecOutcomeV1,
|
|
42
|
+
} from "./host-client.js";
|
|
43
|
+
|
|
44
|
+
// The Computer's on-Sprite layout, its provisioning script, and its declared
|
|
45
|
+
// services live in `@frockbot/computer-host-runtime`, so the shared Computer
|
|
46
|
+
// host of ADR 0004 and this provider ship one runtime rather than two. Both
|
|
47
|
+
// names below are re-exported because they are part of this module's public
|
|
48
|
+
// surface: the sync Package names the watcher service, and the slot-reclaim
|
|
49
|
+
// threshold is policy a caller may need to reason about.
|
|
50
|
+
export { SLOT_IDLE_SECONDS, WORKSPACE_SYNC_SERVICE };
|
|
51
|
+
|
|
52
|
+
const MAX_OUTPUT = 30_000;
|
|
53
|
+
const MAX_STORAGE_OUTPUT = 500_000;
|
|
54
|
+
const EXEC_EXIT_MARKER = "__FROCKBOT_EXIT__";
|
|
55
|
+
/** Largest screenshot this provider will carry back off a Computer. */
|
|
56
|
+
export const SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
|
|
57
|
+
/** Log bytes a single read carries back from a background process. */
|
|
58
|
+
export const PROCESS_LOG_DEFAULT_TAIL_BYTES = 8_192;
|
|
59
|
+
export const PROCESS_LOG_MAX_TAIL_BYTES = 64_000;
|
|
60
|
+
/** How long a stopped process is given to handle TERM before KILL. */
|
|
61
|
+
export const PROCESS_STOP_GRACE_SECONDS = 5;
|
|
62
|
+
const PROCESS_MARKER = "__FROCKBOT_PROCESS__";
|
|
63
|
+
|
|
64
|
+
/** Deadlines this provider asks the host for, per phase. */
|
|
65
|
+
const TIMEOUTS = {
|
|
66
|
+
/** Provisioning apt-installs a desktop stack on a cold Computer. */
|
|
67
|
+
open: 10 * 60_000,
|
|
68
|
+
command: 120_000,
|
|
69
|
+
browser: 45_000,
|
|
70
|
+
screenshot: 30_000,
|
|
71
|
+
/** box-doctor probes a dozen things, none of them slow. */
|
|
72
|
+
doctor: 45_000,
|
|
73
|
+
control: 15_000,
|
|
74
|
+
viewer: 30_000,
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The shared Computer host as this provider uses it (ADR 0004).
|
|
79
|
+
*
|
|
80
|
+
* `ComputerHostClient` satisfies it, and so does a test double. It is declared
|
|
81
|
+
* here rather than imported as a class so this module depends on the *shape*
|
|
82
|
+
* of the host and not on the transport: the client owns the service binding,
|
|
83
|
+
* the framing, and the retry classification, and this module owns what a Bot
|
|
84
|
+
* tenant means on a Computer.
|
|
85
|
+
*/
|
|
86
|
+
export interface ComputerHostSurfaceV1 {
|
|
87
|
+
open(options?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1>;
|
|
88
|
+
exec(
|
|
89
|
+
command: ComputerHostExecCommandV1,
|
|
90
|
+
options?: ComputerHostCallOptions,
|
|
91
|
+
): Promise<ComputerHostExecOutcomeV1>;
|
|
92
|
+
fileRead(
|
|
93
|
+
path: string,
|
|
94
|
+
options?: ComputerHostCallOptions,
|
|
95
|
+
): Promise<ComputerHostFileReadResultV1>;
|
|
96
|
+
control(
|
|
97
|
+
action: "acquire" | "renew" | "release",
|
|
98
|
+
ownerId: string,
|
|
99
|
+
maxAgeSeconds: number,
|
|
100
|
+
options?: ComputerHostCallOptions & { scope?: "bot" | "desktop-gui" },
|
|
101
|
+
): Promise<ComputerHostControlResultV1>;
|
|
102
|
+
viewer(
|
|
103
|
+
action: "open" | "renew" | "revoke",
|
|
104
|
+
options?: ComputerHostCallOptions & { sessionId?: string },
|
|
105
|
+
): Promise<ComputerHostViewerResultV1>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Makes the host surface for one Bot on one User's Computer.
|
|
110
|
+
*
|
|
111
|
+
* The identity and the tenant are both arguments because they mean different
|
|
112
|
+
* things (ADR 0012): the User names the Computer, the Bot names the tenant on
|
|
113
|
+
* it, and the host has to be told both on every call.
|
|
114
|
+
*/
|
|
115
|
+
export type ComputerHostFactoryV1 = (
|
|
116
|
+
identity: { userId: string },
|
|
117
|
+
tenant: { botId: string },
|
|
118
|
+
) => ComputerHostSurfaceV1;
|
|
119
|
+
|
|
120
|
+
export interface ComputerBotIdentity {
|
|
121
|
+
id: string;
|
|
122
|
+
name?: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface AgentLayout {
|
|
127
|
+
identity: ComputerBotIdentity;
|
|
128
|
+
key: string;
|
|
129
|
+
runtimeDir: string;
|
|
130
|
+
workspaceDir: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One capture of a tenant's desktop, as it left the Computer. */
|
|
134
|
+
export interface SpriteScreenshotV1 {
|
|
135
|
+
bytes: Uint8Array;
|
|
136
|
+
/** The X display the capture was taken from, e.g. `:100`. */
|
|
137
|
+
display: string;
|
|
138
|
+
/** Where the capture sat on the Computer before it was read back. */
|
|
139
|
+
path: string;
|
|
140
|
+
capturedAt: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What a background launch left on the Computer. */
|
|
144
|
+
export interface SpriteProcessLaunchV1 {
|
|
145
|
+
pid: number;
|
|
146
|
+
logPath: string;
|
|
147
|
+
cwd: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the Computer says about one background process right now. */
|
|
151
|
+
export interface SpriteProcessStateV1 {
|
|
152
|
+
alive: boolean;
|
|
153
|
+
exitCode?: number;
|
|
154
|
+
logTail: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface SpriteAgentExecResult {
|
|
158
|
+
exitCode: number | null;
|
|
159
|
+
stdout: string;
|
|
160
|
+
stderr: string;
|
|
161
|
+
outputTruncated: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FlySpriteComputerOptions {
|
|
165
|
+
/** Whose Computer this is. One Computer per User (ADR 0012). */
|
|
166
|
+
identity?: { userId: string };
|
|
167
|
+
/**
|
|
168
|
+
* The shared Computer host. Absent, and this Computer is unconfigured: the
|
|
169
|
+
* provider Package can no longer reach a Sprite from the Durable Object on
|
|
170
|
+
* its own, so a Computer with no host is a Computer with no compute.
|
|
171
|
+
*/
|
|
172
|
+
host?: ComputerHostFactoryV1;
|
|
173
|
+
/** The Sprite name this Computer expects, before the host answers with one. */
|
|
174
|
+
spriteName?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The owner guarded commands name. A `computerUse` child receives the task
|
|
177
|
+
* owner that already holds `desktop-gui`, so its own commands pass the same
|
|
178
|
+
* fence that refuses every other Bot and human session.
|
|
179
|
+
*/
|
|
180
|
+
agentControlOwnerId?: string;
|
|
181
|
+
respectHumanControl?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface BrowserAction {
|
|
185
|
+
action: "snapshot" | "navigate" | "click" | "fill" | "press" | "wait";
|
|
186
|
+
url?: string;
|
|
187
|
+
role?: string;
|
|
188
|
+
name?: string;
|
|
189
|
+
label?: string;
|
|
190
|
+
text?: string;
|
|
191
|
+
key?: string;
|
|
192
|
+
exact?: boolean;
|
|
193
|
+
milliseconds?: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ComputerConnection {
|
|
197
|
+
botId: string;
|
|
198
|
+
botKey: string;
|
|
199
|
+
spriteName: string;
|
|
200
|
+
viewerUrl: string;
|
|
201
|
+
viewerSessionId: string;
|
|
202
|
+
viewerExpiresAt?: string;
|
|
203
|
+
/** The tenant's X display on the shared Computer, e.g. `:100`. */
|
|
204
|
+
display: string;
|
|
205
|
+
/** The tenant's durable directory, relative to the Workspace home. */
|
|
206
|
+
directory: string;
|
|
207
|
+
/** The progress from the host wake that opened this connection, if any. */
|
|
208
|
+
message?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function provisioningMessage(progress: ComputerHostProvisioningV1): string {
|
|
212
|
+
return progress.kind === "update"
|
|
213
|
+
? `Updating the Computer: ${progress.label}`
|
|
214
|
+
: `Preparing the Computer: ${progress.label}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function configuredName(): string {
|
|
218
|
+
const name = process.env.FROCKBOT_SPRITE_NAME?.trim() || "frockbot-barebones";
|
|
219
|
+
if (!/^[a-z][a-z0-9-]{2,62}$/.test(name)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"FROCKBOT_SPRITE_NAME must be 3-63 lowercase letters, numbers, or hyphens",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return name;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function flySpriteNameForBot(
|
|
228
|
+
botId: string,
|
|
229
|
+
baseName = configuredName(),
|
|
230
|
+
): string {
|
|
231
|
+
const normalizedBase = baseName.trim();
|
|
232
|
+
if (!/^[a-z][a-z0-9-]{2,62}$/.test(normalizedBase)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"Fly Sprite base name must be 3-63 lowercase letters, numbers, or hyphens",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const suffix = createHash("sha256").update(botId).digest("hex").slice(0, 12);
|
|
238
|
+
const prefix = normalizedBase.slice(0, 49).replace(/-+$/g, "");
|
|
239
|
+
return `${prefix}-${suffix}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizedIdentity(
|
|
243
|
+
input: string | ComputerBotIdentity,
|
|
244
|
+
): ComputerBotIdentity {
|
|
245
|
+
const identity = typeof input === "string" ? { id: input } : input;
|
|
246
|
+
const id = identity.id.trim();
|
|
247
|
+
if (!id || id.length > 200) {
|
|
248
|
+
throw new Error("Computer Bot id must contain 1-200 characters");
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
id,
|
|
252
|
+
name: identity.name?.trim() || undefined,
|
|
253
|
+
description: identity.description?.trim() || undefined,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function computerBotKey(botId: string): string {
|
|
258
|
+
const id = normalizedIdentity(botId).id;
|
|
259
|
+
const slug = id
|
|
260
|
+
.normalize("NFKD")
|
|
261
|
+
.toLowerCase()
|
|
262
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
263
|
+
.replace(/^-+|-+$/g, "")
|
|
264
|
+
.slice(0, 28);
|
|
265
|
+
const digest = createHash("sha256").update(id).digest("hex").slice(0, 12);
|
|
266
|
+
return `${slug || "bot"}-${digest}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function layoutFor(input: string | ComputerBotIdentity): AgentLayout {
|
|
270
|
+
const identity = normalizedIdentity(input);
|
|
271
|
+
const key = computerBotKey(identity.id);
|
|
272
|
+
return {
|
|
273
|
+
identity,
|
|
274
|
+
key,
|
|
275
|
+
runtimeDir: `${BOTS_ROOT}/${key}`,
|
|
276
|
+
workspaceDir: `${WORKSPACES_ROOT}/${key}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function errorText(error: unknown): string {
|
|
281
|
+
return error instanceof Error ? error.message : String(error);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const decoder = new TextDecoder();
|
|
285
|
+
|
|
286
|
+
function outputText(bytes: Uint8Array): string {
|
|
287
|
+
return decoder.decode(bytes);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function clipped(text: string, limit = MAX_OUTPUT): string {
|
|
291
|
+
if (text.length <= limit) return text;
|
|
292
|
+
return `${text.slice(0, limit)}\n… output truncated`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** True when the host refused because no desktop slot was free. */
|
|
296
|
+
function isSlotExhaustion(error: unknown): boolean {
|
|
297
|
+
const text = errorText(error);
|
|
298
|
+
return (
|
|
299
|
+
text.includes(NO_SLOTS_MARKER) ||
|
|
300
|
+
text.includes("no desktop slots available") ||
|
|
301
|
+
text.includes("no display until one is idle")
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export class FlySpriteAgentComputer {
|
|
306
|
+
readonly botId: string;
|
|
307
|
+
readonly botKey: string;
|
|
308
|
+
private readonly computer: FlySpriteComputer;
|
|
309
|
+
private readonly layout: AgentLayout;
|
|
310
|
+
|
|
311
|
+
constructor(computer: FlySpriteComputer, layout: AgentLayout) {
|
|
312
|
+
this.computer = computer;
|
|
313
|
+
this.layout = layout;
|
|
314
|
+
this.botId = layout.identity.id;
|
|
315
|
+
this.botKey = layout.key;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The tenant's allocated X display, once its desktop has been ensured. */
|
|
319
|
+
get display(): string | undefined {
|
|
320
|
+
return this.computer.displayForTenant(this.layout.key);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The tenant's durable directory, relative to the Workspace home. */
|
|
324
|
+
get directory(): string {
|
|
325
|
+
return `agent-data/agents/${this.layout.key}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ensure(signal?: AbortSignal): Promise<ComputerConnection> {
|
|
329
|
+
return this.computer.ensureAgent(this.layout, signal);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
connect(options?: ComputerOperationOptions): Promise<ComputerConnection> {
|
|
333
|
+
return this.computer.connectAgent(this.layout, options);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
run(command: string, signal: AbortSignal): Promise<string> {
|
|
337
|
+
return this.computer.runForAgent(this.layout, command, signal);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
exec(
|
|
341
|
+
command: string,
|
|
342
|
+
signal: AbortSignal,
|
|
343
|
+
limits: { timeoutMs?: number; maxOutputBytes?: number } = {},
|
|
344
|
+
): Promise<SpriteAgentExecResult> {
|
|
345
|
+
return this.computer.execForAgent(this.layout, command, signal, limits);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
runStorage(command: string, signal: AbortSignal): Promise<string> {
|
|
349
|
+
return this.computer.runStorageForAgent(this.layout, command, signal);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
browser(action: BrowserAction, signal: AbortSignal): Promise<string> {
|
|
353
|
+
return this.computer.browserForAgent(this.layout, action, signal);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Captures this tenant's own desktop. */
|
|
357
|
+
screenshot(signal: AbortSignal): Promise<SpriteScreenshotV1> {
|
|
358
|
+
return this.computer.screenshotForAgent(this.layout, signal);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Runs the Computer's self-check for this tenant. */
|
|
362
|
+
doctor(signal: AbortSignal): Promise<ComputerDoctorReportV1> {
|
|
363
|
+
return this.computer.doctorForAgent(this.layout, signal);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
launchProcess(
|
|
367
|
+
processId: string,
|
|
368
|
+
command: string,
|
|
369
|
+
signal: AbortSignal,
|
|
370
|
+
): Promise<SpriteProcessLaunchV1> {
|
|
371
|
+
return this.computer.launchProcessForAgent(
|
|
372
|
+
this.layout,
|
|
373
|
+
processId,
|
|
374
|
+
command,
|
|
375
|
+
signal,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
inspectProcess(
|
|
380
|
+
processId: string,
|
|
381
|
+
signal: AbortSignal,
|
|
382
|
+
tailBytes?: number,
|
|
383
|
+
): Promise<SpriteProcessStateV1> {
|
|
384
|
+
return this.computer.inspectProcessForAgent(
|
|
385
|
+
this.layout,
|
|
386
|
+
processId,
|
|
387
|
+
signal,
|
|
388
|
+
tailBytes,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
stopProcess(
|
|
393
|
+
processId: string,
|
|
394
|
+
signal: AbortSignal,
|
|
395
|
+
): Promise<SpriteProcessStateV1> {
|
|
396
|
+
return this.computer.stopProcessForAgent(this.layout, processId, signal);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The Computer's provisioning generation, once it has been opened. */
|
|
400
|
+
get generation(): number | undefined {
|
|
401
|
+
return this.computer.generationForTenant(this.layout.key);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The generation the Computer is on now, asked of the host. */
|
|
405
|
+
currentGeneration(signal?: AbortSignal): Promise<number> {
|
|
406
|
+
return this.computer.currentGenerationForAgent(this.layout, signal);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** This tenant's working directory on the Computer. */
|
|
410
|
+
get workingDirectory(): string {
|
|
411
|
+
return this.layout.workspaceDir;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Opens a viewer session on this tenant's desktop. */
|
|
415
|
+
viewer(
|
|
416
|
+
options?: ComputerOperationOptions,
|
|
417
|
+
): Promise<ComputerHostViewerResultV1> {
|
|
418
|
+
return this.computer.viewerForAgent(
|
|
419
|
+
this.layout,
|
|
420
|
+
"open",
|
|
421
|
+
options?.signal,
|
|
422
|
+
undefined,
|
|
423
|
+
options?.effectId,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
revokeViewer(
|
|
428
|
+
sessionId: string,
|
|
429
|
+
options?: ComputerOperationOptions,
|
|
430
|
+
): Promise<ComputerHostViewerResultV1> {
|
|
431
|
+
return this.computer.viewerForAgent(
|
|
432
|
+
this.layout,
|
|
433
|
+
"revoke",
|
|
434
|
+
options?.signal,
|
|
435
|
+
sessionId,
|
|
436
|
+
options?.effectId,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
refreshViewer(
|
|
441
|
+
sessionId: string,
|
|
442
|
+
options?: ComputerOperationOptions,
|
|
443
|
+
): Promise<ComputerHostViewerResultV1> {
|
|
444
|
+
return this.computer.viewerForAgent(
|
|
445
|
+
this.layout,
|
|
446
|
+
"renew",
|
|
447
|
+
options?.signal,
|
|
448
|
+
sessionId,
|
|
449
|
+
options?.effectId,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
takeControl(
|
|
454
|
+
options?: ComputerOperationOptions,
|
|
455
|
+
request?: ComputerControlRequestV1,
|
|
456
|
+
): Promise<ComputerHostControlResultV1> {
|
|
457
|
+
return this.computer.control(
|
|
458
|
+
this.layout,
|
|
459
|
+
"acquire",
|
|
460
|
+
options?.signal,
|
|
461
|
+
request,
|
|
462
|
+
options?.effectId,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
refreshControl(
|
|
467
|
+
options?: ComputerOperationOptions,
|
|
468
|
+
request?: ComputerControlRequestV1,
|
|
469
|
+
): Promise<ComputerHostControlResultV1> {
|
|
470
|
+
return this.computer.control(
|
|
471
|
+
this.layout,
|
|
472
|
+
"renew",
|
|
473
|
+
options?.signal,
|
|
474
|
+
request,
|
|
475
|
+
options?.effectId,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
releaseControl(
|
|
480
|
+
options?: ComputerOperationOptions,
|
|
481
|
+
request?: ComputerControlRequestV1,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
return this.computer.releaseForAgent(
|
|
484
|
+
this.layout,
|
|
485
|
+
options?.signal,
|
|
486
|
+
request,
|
|
487
|
+
options?.effectId,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The human session owner this Computer uses for local takeover. */
|
|
492
|
+
get controlOwnerId(): string {
|
|
493
|
+
return this.computer.humanControlOwnerId;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* One User's Computer, driven through the shared host.
|
|
499
|
+
*
|
|
500
|
+
* Everything Fly-specific that used to live here — the Sprites SDK, the
|
|
501
|
+
* provisioning script, the declared services, the viewer token files — is on
|
|
502
|
+
* the host now (ADR 0004). What remains is what a Bot *tenant* means on a
|
|
503
|
+
* Computer: its directory key, its human-control guard, and the shape of the
|
|
504
|
+
* commands it runs. That is why `FlySpriteAgentComputer`'s method surface is
|
|
505
|
+
* unchanged: `workspace.ts` and `sync.ts` generate bash against it and neither
|
|
506
|
+
* knows, or needs to know, that the bash now travels on a command's stdin.
|
|
507
|
+
*/
|
|
508
|
+
export class FlySpriteComputer {
|
|
509
|
+
readonly configured: boolean;
|
|
510
|
+
/** The caller identity guarded Bot commands name on the Computer. */
|
|
511
|
+
readonly ownerId: string;
|
|
512
|
+
/** The local viewer session, kept distinct from the guarded Bot caller. */
|
|
513
|
+
readonly humanControlOwnerId = `human:${randomUUID()}`;
|
|
514
|
+
private readonly identity: { userId: string };
|
|
515
|
+
private readonly host?: ComputerHostFactoryV1;
|
|
516
|
+
private readonly respectHumanControl: boolean;
|
|
517
|
+
private expectedSpriteName: string;
|
|
518
|
+
private readonly surfaces = new Map<string, ComputerHostSurfaceV1>();
|
|
519
|
+
private readonly agentPromises = new Map<
|
|
520
|
+
string,
|
|
521
|
+
Promise<ComputerConnection>
|
|
522
|
+
>();
|
|
523
|
+
private readonly storagePromises = new Map<string, Promise<unknown>>();
|
|
524
|
+
private readonly displays = new Map<string, string>();
|
|
525
|
+
private readonly generations = new Map<string, number>();
|
|
526
|
+
|
|
527
|
+
constructor(options: FlySpriteComputerOptions = {}) {
|
|
528
|
+
this.identity = options.identity ?? {
|
|
529
|
+
userId: process.env.FROCKBOT_USER_ID?.trim() || "local-user",
|
|
530
|
+
};
|
|
531
|
+
this.host = options.host;
|
|
532
|
+
this.expectedSpriteName = options.spriteName ?? configuredName();
|
|
533
|
+
this.ownerId = options.agentControlOwnerId ?? `agent:${randomUUID()}`;
|
|
534
|
+
this.configured = Boolean(options.host);
|
|
535
|
+
this.respectHumanControl = options.respectHumanControl ?? true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The Sprite backing this Computer. It is the host's answer once one has
|
|
540
|
+
* been opened, and the configured expectation before that: the host derives
|
|
541
|
+
* the name from the User, so the two agree, and only the host's is a fact.
|
|
542
|
+
*/
|
|
543
|
+
get spriteName(): string {
|
|
544
|
+
return this.expectedSpriteName;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
bot(identity: string | ComputerBotIdentity): FlySpriteAgentComputer {
|
|
548
|
+
return new FlySpriteAgentComputer(this, layoutFor(identity));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The X display this Computer allocated to one tenant, once its desktop has
|
|
553
|
+
* been ensured. Slots are allocated on demand, exactly as GrokBot allocates
|
|
554
|
+
* displays on demand rather than one per agent, so this is `undefined` until
|
|
555
|
+
* the tenant's desktop has started.
|
|
556
|
+
*/
|
|
557
|
+
displayForTenant(botKey: string): string | undefined {
|
|
558
|
+
return this.displays.get(botKey);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The Computer's provisioning generation, as the host last reported it. */
|
|
562
|
+
generationForTenant(botKey: string): number | undefined {
|
|
563
|
+
return this.generations.get(botKey);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async ensureAgent(
|
|
567
|
+
layout: AgentLayout,
|
|
568
|
+
signal?: AbortSignal,
|
|
569
|
+
): Promise<ComputerConnection> {
|
|
570
|
+
this.hostFor(layout);
|
|
571
|
+
let promise = this.agentPromises.get(layout.key);
|
|
572
|
+
if (!promise) {
|
|
573
|
+
promise = this.openAgent(layout, { signal }).catch((error: unknown) => {
|
|
574
|
+
this.agentPromises.delete(layout.key);
|
|
575
|
+
throw error;
|
|
576
|
+
});
|
|
577
|
+
this.agentPromises.set(layout.key, promise);
|
|
578
|
+
}
|
|
579
|
+
return promise;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
connectAgent(
|
|
583
|
+
layout: AgentLayout,
|
|
584
|
+
options?: ComputerOperationOptions,
|
|
585
|
+
): Promise<ComputerConnection> {
|
|
586
|
+
this.hostFor(layout);
|
|
587
|
+
return this.openAgent(layout, options);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async runForAgent(
|
|
591
|
+
layout: AgentLayout,
|
|
592
|
+
command: string,
|
|
593
|
+
signal: AbortSignal,
|
|
594
|
+
): Promise<string> {
|
|
595
|
+
const host = await this.readyHost(layout, signal);
|
|
596
|
+
const script = [
|
|
597
|
+
this.agentControlGuard(layout),
|
|
598
|
+
...this.tenantEnvironment(layout),
|
|
599
|
+
command,
|
|
600
|
+
].join("\n");
|
|
601
|
+
const outcome = await this.execute(
|
|
602
|
+
host,
|
|
603
|
+
script,
|
|
604
|
+
{ signal, timeoutMs: TIMEOUTS.command, maxOutputBytes: MAX_OUTPUT * 2 },
|
|
605
|
+
"Sprite command failed",
|
|
606
|
+
);
|
|
607
|
+
return clipped(
|
|
608
|
+
[outputText(outcome.stdout), outputText(outcome.stderr)]
|
|
609
|
+
.filter(Boolean)
|
|
610
|
+
.join("\n"),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async execForAgent(
|
|
615
|
+
layout: AgentLayout,
|
|
616
|
+
command: string,
|
|
617
|
+
signal: AbortSignal,
|
|
618
|
+
limits: { timeoutMs?: number; maxOutputBytes?: number } = {},
|
|
619
|
+
): Promise<SpriteAgentExecResult> {
|
|
620
|
+
const host = await this.readyHost(layout, signal);
|
|
621
|
+
const maxOutput = Math.max(
|
|
622
|
+
1,
|
|
623
|
+
Math.min(limits.maxOutputBytes ?? MAX_OUTPUT, MAX_OUTPUT),
|
|
624
|
+
);
|
|
625
|
+
// The marker survives the move to the host for one reason: the outer
|
|
626
|
+
// script's exit code belongs to the control guard, and the Bot's command
|
|
627
|
+
// has an exit code of its own. Collapsing the two would make a Computer
|
|
628
|
+
// under human control indistinguishable from a command that failed.
|
|
629
|
+
const script = [
|
|
630
|
+
this.agentControlGuard(layout),
|
|
631
|
+
...this.tenantEnvironment(layout),
|
|
632
|
+
`bash -c ${shellQuote(command)}`,
|
|
633
|
+
`printf '\\n%s%s\\n' ${shellQuote(EXEC_EXIT_MARKER)} "$?"`,
|
|
634
|
+
].join("\n");
|
|
635
|
+
const outcome = await this.execute(
|
|
636
|
+
host,
|
|
637
|
+
script,
|
|
638
|
+
{
|
|
639
|
+
signal,
|
|
640
|
+
timeoutMs: Math.max(
|
|
641
|
+
1,
|
|
642
|
+
Math.min(limits.timeoutMs ?? TIMEOUTS.command, TIMEOUTS.command),
|
|
643
|
+
),
|
|
644
|
+
maxOutputBytes: MAX_OUTPUT * 2,
|
|
645
|
+
},
|
|
646
|
+
"Sprite command failed",
|
|
647
|
+
);
|
|
648
|
+
const raw = outputText(outcome.stdout);
|
|
649
|
+
const match = new RegExp(`\\n?${EXEC_EXIT_MARKER}(\\d+)\\n?$`).exec(raw);
|
|
650
|
+
const stdout = match ? raw.slice(0, match.index) : raw;
|
|
651
|
+
const stderr = outputText(outcome.stderr);
|
|
652
|
+
return {
|
|
653
|
+
exitCode: match ? Number(match[1]) : null,
|
|
654
|
+
stdout: stdout.slice(0, maxOutput),
|
|
655
|
+
stderr: stderr.slice(0, maxOutput),
|
|
656
|
+
outputTruncated:
|
|
657
|
+
!match ||
|
|
658
|
+
outcome.outputTruncated ||
|
|
659
|
+
stdout.length > maxOutput ||
|
|
660
|
+
stderr.length > maxOutput,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The Workspace and the durable-root sync's own commands.
|
|
666
|
+
*
|
|
667
|
+
* They carry no human-control guard on purpose: reconciling durable state is
|
|
668
|
+
* not the Bot acting on the Computer, and a human holding the screen must
|
|
669
|
+
* not stop a Turn's files from reaching object storage.
|
|
670
|
+
*/
|
|
671
|
+
async runStorageForAgent(
|
|
672
|
+
layout: AgentLayout,
|
|
673
|
+
command: string,
|
|
674
|
+
signal: AbortSignal,
|
|
675
|
+
): Promise<string> {
|
|
676
|
+
const host = this.hostFor(layout);
|
|
677
|
+
signal.throwIfAborted();
|
|
678
|
+
await this.readyStorage(layout, host, signal);
|
|
679
|
+
const script = [...this.tenantEnvironment(layout), command].join("\n");
|
|
680
|
+
const outcome = await this.execute(
|
|
681
|
+
host,
|
|
682
|
+
script,
|
|
683
|
+
{
|
|
684
|
+
signal,
|
|
685
|
+
timeoutMs: TIMEOUTS.command,
|
|
686
|
+
maxOutputBytes: MAX_STORAGE_OUTPUT * 2,
|
|
687
|
+
},
|
|
688
|
+
"Sprite storage operation failed",
|
|
689
|
+
);
|
|
690
|
+
const stdout = outputText(outcome.stdout);
|
|
691
|
+
if (stdout.length > MAX_STORAGE_OUTPUT || outcome.outputTruncated) {
|
|
692
|
+
throw new ComputerError(
|
|
693
|
+
"limit-exceeded",
|
|
694
|
+
"Sprite storage output exceeded the maximum size",
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return stdout;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Captures the tenant's own desktop and carries the PNG back.
|
|
702
|
+
*
|
|
703
|
+
* Two host operations and no new one: a guarded `exec` runs `scrot` under
|
|
704
|
+
* the tenant's own `DISPLAY`, and `file/read` brings the bytes back. The
|
|
705
|
+
* guard is the same one every Bot command carries, so a screenshot taken
|
|
706
|
+
* while a human holds the takeover lease is refused rather than handing the
|
|
707
|
+
* Bot a picture of the human's session.
|
|
708
|
+
*
|
|
709
|
+
* The file is read back rather than left where it landed because a durable
|
|
710
|
+
* root reached by a shell write syncs back `unattributed`: the caller writes
|
|
711
|
+
* these bytes through the Workspace, which is what records the Bot as their
|
|
712
|
+
* writer.
|
|
713
|
+
*/
|
|
714
|
+
async screenshotForAgent(
|
|
715
|
+
layout: AgentLayout,
|
|
716
|
+
signal: AbortSignal,
|
|
717
|
+
): Promise<SpriteScreenshotV1> {
|
|
718
|
+
const host = await this.readyHost(layout, signal);
|
|
719
|
+
const display = this.displays.get(layout.key);
|
|
720
|
+
if (!display) {
|
|
721
|
+
throw new ComputerError(
|
|
722
|
+
"capability-unavailable",
|
|
723
|
+
`Bot "${layout.identity.id}" has no desktop on this Computer to capture`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const bot = `${BOTS_ROOT}/${layout.key}`;
|
|
727
|
+
const path = `${bot}/screenshot.png`;
|
|
728
|
+
const script = [
|
|
729
|
+
this.agentControlGuard(layout),
|
|
730
|
+
...this.tenantEnvironment(layout),
|
|
731
|
+
`export DISPLAY=${shellQuote(display)}`,
|
|
732
|
+
// `scrot` is one of the shimmed names, and this is the surface the shim
|
|
733
|
+
// exists to point at, so it is allowed through here and nowhere else.
|
|
734
|
+
`export ${SANCTIONED_SURFACE_ENV}=1`,
|
|
735
|
+
`rm -f ${shellQuote(path)}`,
|
|
736
|
+
`scrot --overwrite ${shellQuote(path)}`,
|
|
737
|
+
`stat -c %s ${shellQuote(path)}`,
|
|
738
|
+
].join("\n");
|
|
739
|
+
const outcome = await this.execute(
|
|
740
|
+
host,
|
|
741
|
+
script,
|
|
742
|
+
{
|
|
743
|
+
signal,
|
|
744
|
+
timeoutMs: TIMEOUTS.screenshot,
|
|
745
|
+
maxOutputBytes: MAX_OUTPUT,
|
|
746
|
+
},
|
|
747
|
+
"Sprite screenshot failed",
|
|
748
|
+
);
|
|
749
|
+
const size = Number(outputText(outcome.stdout).trim().split("\n").pop());
|
|
750
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
751
|
+
throw new ComputerError(
|
|
752
|
+
"provider-unavailable",
|
|
753
|
+
"The Computer produced no screenshot",
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
if (size > SCREENSHOT_MAX_BYTES) {
|
|
757
|
+
throw new ComputerError(
|
|
758
|
+
"limit-exceeded",
|
|
759
|
+
`The screenshot is ${size} bytes, past the ${SCREENSHOT_MAX_BYTES}-byte limit`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const read = await host.fileRead(path, {
|
|
763
|
+
signal,
|
|
764
|
+
timeoutMs: TIMEOUTS.screenshot,
|
|
765
|
+
});
|
|
766
|
+
const bytes = Uint8Array.from(Buffer.from(read.bytesBase64, "base64"));
|
|
767
|
+
if (bytes.byteLength === 0) {
|
|
768
|
+
throw new ComputerError(
|
|
769
|
+
"provider-unavailable",
|
|
770
|
+
"The Computer returned an empty screenshot",
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
bytes,
|
|
775
|
+
display,
|
|
776
|
+
path,
|
|
777
|
+
capturedAt: new Date().toISOString(),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Runs box-doctor and decodes its report.
|
|
783
|
+
*
|
|
784
|
+
* No human-control guard, and deliberately: a Computer a human has taken
|
|
785
|
+
* over is exactly a Computer somebody may need to ask what is wrong with,
|
|
786
|
+
* and every check reads. The tenant stamp still runs, because asking is
|
|
787
|
+
* using and a tenant being asked about must not lose its display slot
|
|
788
|
+
* mid-answer.
|
|
789
|
+
*
|
|
790
|
+
* The script prints its report on one marked line and its human-readable
|
|
791
|
+
* lines to `/tmp/box-doctor.log`, so the marker is what separates the report
|
|
792
|
+
* from anything else the Computer said.
|
|
793
|
+
*/
|
|
794
|
+
async doctorForAgent(
|
|
795
|
+
layout: AgentLayout,
|
|
796
|
+
signal: AbortSignal,
|
|
797
|
+
): Promise<ComputerDoctorReportV1> {
|
|
798
|
+
const host = await this.readyHost(layout, signal);
|
|
799
|
+
const generation = this.generations.get(layout.key) ?? 0;
|
|
800
|
+
const script = [
|
|
801
|
+
this.tenantStamp(layout),
|
|
802
|
+
...this.tenantEnvironment(layout),
|
|
803
|
+
`if [ ! -x ${DOCTOR_SCRIPT} ]; then echo "missing" >&2; exit 69; fi`,
|
|
804
|
+
`${DOCTOR_SCRIPT} ${shellQuote(layout.key)} ${String(generation)}`,
|
|
805
|
+
].join("\n");
|
|
806
|
+
let outcome: ComputerHostExecOutcomeV1;
|
|
807
|
+
try {
|
|
808
|
+
outcome = await this.execute(
|
|
809
|
+
host,
|
|
810
|
+
script,
|
|
811
|
+
{
|
|
812
|
+
signal,
|
|
813
|
+
timeoutMs: TIMEOUTS.doctor,
|
|
814
|
+
maxOutputBytes: MAX_OUTPUT * 2,
|
|
815
|
+
},
|
|
816
|
+
"Sprite self-check failed",
|
|
817
|
+
);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
// A Computer provisioned before the self-check existed has no script to
|
|
820
|
+
// run. That is a stated outcome — it installs on the next open — not a
|
|
821
|
+
// crash, and saying which it is costs one sentence.
|
|
822
|
+
if (errorText(error).includes("missing")) {
|
|
823
|
+
throw new ComputerError(
|
|
824
|
+
"capability-unavailable",
|
|
825
|
+
"This Computer has no self-check installed yet; it is installed the next time the Computer is opened",
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
const line = outputText(outcome.stdout)
|
|
831
|
+
.split("\n")
|
|
832
|
+
.find((candidate) => candidate.startsWith(DOCTOR_MARKER));
|
|
833
|
+
let parsed: unknown;
|
|
834
|
+
try {
|
|
835
|
+
parsed = line ? JSON.parse(line.slice(DOCTOR_MARKER.length)) : undefined;
|
|
836
|
+
} catch {
|
|
837
|
+
parsed = undefined;
|
|
838
|
+
}
|
|
839
|
+
const report = decodeComputerDoctorReportV1(parsed);
|
|
840
|
+
if (!report) {
|
|
841
|
+
throw new ComputerError(
|
|
842
|
+
"provider-failure",
|
|
843
|
+
"The Computer's self-check produced no readable report",
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
return report;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Starts a command that outlives its Turn.
|
|
851
|
+
*
|
|
852
|
+
* `setsid` makes the command a process group leader, which is what lets a
|
|
853
|
+
* later `stop` end the whole tree with one signal rather than orphaning the
|
|
854
|
+
* children of a shell pipeline. `nohup` detaches it from the exec's own
|
|
855
|
+
* terminal, so the process survives the connection closing — and connections
|
|
856
|
+
* to a Computer are expected to drop on every pause.
|
|
857
|
+
*
|
|
858
|
+
* The launch returns as soon as the pid file exists. Nothing here keeps the
|
|
859
|
+
* Computer awake: a process is a thing running on a Computer while it is
|
|
860
|
+
* awake, not a reason for it to stay that way.
|
|
861
|
+
*/
|
|
862
|
+
async launchProcessForAgent(
|
|
863
|
+
layout: AgentLayout,
|
|
864
|
+
processId: string,
|
|
865
|
+
command: string,
|
|
866
|
+
signal: AbortSignal,
|
|
867
|
+
): Promise<SpriteProcessLaunchV1> {
|
|
868
|
+
const host = await this.readyHost(layout, signal);
|
|
869
|
+
const directory = this.processDirectory(layout, processId);
|
|
870
|
+
const script = [
|
|
871
|
+
this.agentControlGuard(layout),
|
|
872
|
+
...this.tenantEnvironment(layout),
|
|
873
|
+
`DIR=${shellQuote(directory)}`,
|
|
874
|
+
'mkdir -p "$DIR"',
|
|
875
|
+
`printf %s ${shellQuote(command)} > "$DIR/command"`,
|
|
876
|
+
// One `bash -c` holding the pipeline, so `$!` is the group leader and
|
|
877
|
+
// the exit code recorded is the command's own, not the logger's.
|
|
878
|
+
`setsid nohup bash -c ${shellQuote(
|
|
879
|
+
[
|
|
880
|
+
`bash -c ${shellQuote(command)} 2>&1 | ${BOUNDED_LOG_SCRIPT} "${directory}/log"`,
|
|
881
|
+
`printf '%s\\n' "\${PIPESTATUS[0]}" > "${directory}/exit"`,
|
|
882
|
+
].join("\n"),
|
|
883
|
+
)} >/dev/null 2>&1 &`,
|
|
884
|
+
`printf '%s\\n' "$!" > "$DIR/pid"`,
|
|
885
|
+
`printf '%s%s\\n' ${shellQuote(PROCESS_MARKER)} "$(cat "$DIR/pid")"`,
|
|
886
|
+
].join("\n");
|
|
887
|
+
const outcome = await this.execute(
|
|
888
|
+
host,
|
|
889
|
+
script,
|
|
890
|
+
{ signal, timeoutMs: TIMEOUTS.control, maxOutputBytes: MAX_OUTPUT },
|
|
891
|
+
"Sprite background launch failed",
|
|
892
|
+
);
|
|
893
|
+
const pid = Number(
|
|
894
|
+
new RegExp(`${PROCESS_MARKER}(\\d+)`).exec(
|
|
895
|
+
outputText(outcome.stdout),
|
|
896
|
+
)?.[1],
|
|
897
|
+
);
|
|
898
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
899
|
+
throw new ComputerError(
|
|
900
|
+
"provider-unavailable",
|
|
901
|
+
"The Computer started no background process",
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
return { pid, logPath: `${directory}/log`, cwd: layout.workspaceDir };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Reads a process's outcome. It never restarts anything: recovery reads an
|
|
909
|
+
* outcome rather than repeating an effect.
|
|
910
|
+
*/
|
|
911
|
+
async inspectProcessForAgent(
|
|
912
|
+
layout: AgentLayout,
|
|
913
|
+
processId: string,
|
|
914
|
+
signal: AbortSignal,
|
|
915
|
+
tailBytes?: number,
|
|
916
|
+
): Promise<SpriteProcessStateV1> {
|
|
917
|
+
const host = await this.readyHost(layout, signal);
|
|
918
|
+
return this.readProcess(host, layout, processId, signal, tailBytes);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Ends the process group: TERM, a grace, then KILL.
|
|
923
|
+
*
|
|
924
|
+
* The negative pid is the point — a background command is usually a shell
|
|
925
|
+
* pipeline, and signalling only the leader leaves its children running with
|
|
926
|
+
* nothing recording them.
|
|
927
|
+
*/
|
|
928
|
+
async stopProcessForAgent(
|
|
929
|
+
layout: AgentLayout,
|
|
930
|
+
processId: string,
|
|
931
|
+
signal: AbortSignal,
|
|
932
|
+
): Promise<SpriteProcessStateV1> {
|
|
933
|
+
const host = await this.readyHost(layout, signal);
|
|
934
|
+
const directory = this.processDirectory(layout, processId);
|
|
935
|
+
const script = [
|
|
936
|
+
this.agentControlGuard(layout),
|
|
937
|
+
...this.tenantEnvironment(layout),
|
|
938
|
+
`DIR=${shellQuote(directory)}`,
|
|
939
|
+
'PID=$(cat "$DIR/pid" 2>/dev/null || echo 0)',
|
|
940
|
+
'if [ "$PID" -gt 0 ]; then',
|
|
941
|
+
' kill -TERM -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true',
|
|
942
|
+
` for _ in $(seq 1 ${PROCESS_STOP_GRACE_SECONDS * 10}); do`,
|
|
943
|
+
' kill -0 "$PID" 2>/dev/null || break',
|
|
944
|
+
" sleep 0.1",
|
|
945
|
+
" done",
|
|
946
|
+
' if kill -0 "$PID" 2>/dev/null; then',
|
|
947
|
+
' kill -KILL -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true',
|
|
948
|
+
" fi",
|
|
949
|
+
"fi",
|
|
950
|
+
// A stop that had to signal leaves no exit file of its own, so one is
|
|
951
|
+
// written here: an ended process must not read back as `unknown`.
|
|
952
|
+
'[ -f "$DIR/exit" ] || printf \'143\\n\' > "$DIR/exit"',
|
|
953
|
+
].join("\n");
|
|
954
|
+
await this.execute(
|
|
955
|
+
host,
|
|
956
|
+
script,
|
|
957
|
+
{
|
|
958
|
+
signal,
|
|
959
|
+
timeoutMs: (PROCESS_STOP_GRACE_SECONDS + 10) * 1_000,
|
|
960
|
+
maxOutputBytes: MAX_OUTPUT,
|
|
961
|
+
},
|
|
962
|
+
"Sprite background stop failed",
|
|
963
|
+
);
|
|
964
|
+
return this.readProcess(host, layout, processId, signal);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Asks the host what generation this Computer is on *now*.
|
|
969
|
+
*
|
|
970
|
+
* Deliberately not the cached answer from `ensureAgent`: the cached one is
|
|
971
|
+
* the generation this provider instance opened under, and the whole question
|
|
972
|
+
* a background process asks is whether the Computer answering today is the
|
|
973
|
+
* Computer it was launched on. A reprovision between the two is exactly the
|
|
974
|
+
* case that must not read back as `running`.
|
|
975
|
+
*/
|
|
976
|
+
async currentGenerationForAgent(
|
|
977
|
+
layout: AgentLayout,
|
|
978
|
+
signal?: AbortSignal,
|
|
979
|
+
): Promise<number> {
|
|
980
|
+
const host = await this.readyHost(layout, signal);
|
|
981
|
+
const opened = await host.open({ signal, timeoutMs: TIMEOUTS.open });
|
|
982
|
+
this.generations.set(layout.key, opened.generation);
|
|
983
|
+
if (opened.display) this.displays.set(layout.key, opened.display);
|
|
984
|
+
return opened.generation;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private processDirectory(layout: AgentLayout, processId: string): string {
|
|
988
|
+
return `${BOTS_ROOT}/${layout.key}/processes/${processId}`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* One read of a process's directory: liveness, exit code, and the bounded
|
|
993
|
+
* log the logger keeps in two halves.
|
|
994
|
+
*/
|
|
995
|
+
private async readProcess(
|
|
996
|
+
host: ComputerHostSurfaceV1,
|
|
997
|
+
layout: AgentLayout,
|
|
998
|
+
processId: string,
|
|
999
|
+
signal: AbortSignal,
|
|
1000
|
+
tailBytes = PROCESS_LOG_DEFAULT_TAIL_BYTES,
|
|
1001
|
+
): Promise<SpriteProcessStateV1> {
|
|
1002
|
+
const bounded = Math.max(
|
|
1003
|
+
1,
|
|
1004
|
+
Math.min(tailBytes, PROCESS_LOG_MAX_TAIL_BYTES),
|
|
1005
|
+
);
|
|
1006
|
+
const directory = this.processDirectory(layout, processId);
|
|
1007
|
+
// No human-control guard, and deliberately: reading a process's outcome is
|
|
1008
|
+
// not the Bot acting on the Computer, and a Routine collecting the result
|
|
1009
|
+
// of a long job must not be blocked because a human is holding the screen.
|
|
1010
|
+
// The stamp still runs, because a tenant with a running process is a live
|
|
1011
|
+
// tenant and its display slot must not be reclaimed under it.
|
|
1012
|
+
const script = [
|
|
1013
|
+
this.tenantStamp(layout),
|
|
1014
|
+
`DIR=${shellQuote(directory)}`,
|
|
1015
|
+
'PID=$(cat "$DIR/pid" 2>/dev/null || echo 0)',
|
|
1016
|
+
`printf '%salive=%s\\n' ${shellQuote(PROCESS_MARKER)} "$(kill -0 "$PID" 2>/dev/null && echo 1 || echo 0)"`,
|
|
1017
|
+
`if [ -f "$DIR/exit" ]; then printf '%sexit=%s\\n' ${shellQuote(PROCESS_MARKER)} "$(cat "$DIR/exit")"; fi`,
|
|
1018
|
+
`printf '%slog\\n' ${shellQuote(PROCESS_MARKER)}`,
|
|
1019
|
+
`if [ -s "$DIR/log.head" ]; then head -c ${bounded} "$DIR/log.head"; fi`,
|
|
1020
|
+
`if [ -s "$DIR/log.tail" ]; then printf '\\n… earlier output dropped …\\n'; tail -c ${bounded} "$DIR/log.tail"; fi`,
|
|
1021
|
+
].join("\n");
|
|
1022
|
+
const outcome = await this.execute(
|
|
1023
|
+
host,
|
|
1024
|
+
script,
|
|
1025
|
+
{
|
|
1026
|
+
signal,
|
|
1027
|
+
timeoutMs: TIMEOUTS.control,
|
|
1028
|
+
maxOutputBytes: PROCESS_LOG_MAX_TAIL_BYTES * 4,
|
|
1029
|
+
},
|
|
1030
|
+
"Sprite background read failed",
|
|
1031
|
+
);
|
|
1032
|
+
const raw = outputText(outcome.stdout);
|
|
1033
|
+
const alive = new RegExp(`${PROCESS_MARKER}alive=1`).test(raw);
|
|
1034
|
+
const exit = new RegExp(`${PROCESS_MARKER}exit=(-?\\d+)`).exec(raw);
|
|
1035
|
+
const logIndex = raw.indexOf(`${PROCESS_MARKER}log\n`);
|
|
1036
|
+
const logTail =
|
|
1037
|
+
logIndex < 0 ? "" : raw.slice(logIndex + `${PROCESS_MARKER}log\n`.length);
|
|
1038
|
+
return {
|
|
1039
|
+
alive,
|
|
1040
|
+
...(exit ? { exitCode: Number(exit[1]) } : {}),
|
|
1041
|
+
logTail: clipped(logTail, bounded * 2),
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async browserForAgent(
|
|
1046
|
+
layout: AgentLayout,
|
|
1047
|
+
action: BrowserAction,
|
|
1048
|
+
signal: AbortSignal,
|
|
1049
|
+
): Promise<string> {
|
|
1050
|
+
const host = await this.readyHost(layout, signal);
|
|
1051
|
+
const encoded = Buffer.from(JSON.stringify(action)).toString("base64url");
|
|
1052
|
+
const script = [
|
|
1053
|
+
this.agentControlGuard(layout),
|
|
1054
|
+
`PORT=$(cat ${layout.runtimeDir}/cdp-port)`,
|
|
1055
|
+
`node ${RUNTIME_ROOT}/browser.mjs "$PORT" ${shellQuote(encoded)}`,
|
|
1056
|
+
].join("\n");
|
|
1057
|
+
const outcome = await this.execute(
|
|
1058
|
+
host,
|
|
1059
|
+
script,
|
|
1060
|
+
{ signal, timeoutMs: TIMEOUTS.browser, maxOutputBytes: MAX_OUTPUT * 2 },
|
|
1061
|
+
"Sprite browser action failed",
|
|
1062
|
+
);
|
|
1063
|
+
return clipped(
|
|
1064
|
+
outputText(outcome.stdout).trim() || outputText(outcome.stderr).trim(),
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
control(
|
|
1069
|
+
layout: AgentLayout,
|
|
1070
|
+
action: "acquire" | "renew",
|
|
1071
|
+
signal?: AbortSignal,
|
|
1072
|
+
request?: ComputerControlRequestV1,
|
|
1073
|
+
effectId?: string,
|
|
1074
|
+
): Promise<ComputerHostControlResultV1> {
|
|
1075
|
+
const lease = request ?? {
|
|
1076
|
+
scope: "desktop-gui" as const,
|
|
1077
|
+
ownerId: this.humanControlOwnerId,
|
|
1078
|
+
};
|
|
1079
|
+
return this.hostFor(layout).control(
|
|
1080
|
+
action,
|
|
1081
|
+
lease.ownerId ?? this.humanControlOwnerId,
|
|
1082
|
+
LEASE_MAX_AGE_SECONDS,
|
|
1083
|
+
{
|
|
1084
|
+
signal,
|
|
1085
|
+
effectId,
|
|
1086
|
+
timeoutMs: TIMEOUTS.control,
|
|
1087
|
+
...(lease.scope ? { scope: lease.scope } : {}),
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async releaseForAgent(
|
|
1093
|
+
layout: AgentLayout,
|
|
1094
|
+
signal?: AbortSignal,
|
|
1095
|
+
request?: ComputerControlRequestV1,
|
|
1096
|
+
effectId?: string,
|
|
1097
|
+
): Promise<void> {
|
|
1098
|
+
if (!this.host) return;
|
|
1099
|
+
const lease = request ?? {
|
|
1100
|
+
scope: "desktop-gui" as const,
|
|
1101
|
+
ownerId: this.humanControlOwnerId,
|
|
1102
|
+
};
|
|
1103
|
+
try {
|
|
1104
|
+
await this.hostFor(layout).control(
|
|
1105
|
+
"release",
|
|
1106
|
+
lease.ownerId ?? this.humanControlOwnerId,
|
|
1107
|
+
LEASE_MAX_AGE_SECONDS,
|
|
1108
|
+
{
|
|
1109
|
+
signal,
|
|
1110
|
+
effectId,
|
|
1111
|
+
timeoutMs: TIMEOUTS.control,
|
|
1112
|
+
...(lease.scope ? { scope: lease.scope } : {}),
|
|
1113
|
+
},
|
|
1114
|
+
);
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
// A Computer that is not there holds no lease to release. Every other
|
|
1117
|
+
// refusal is real and the caller has to see it.
|
|
1118
|
+
if (error instanceof ComputerError && error.code === "conflict") return;
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
viewerForAgent(
|
|
1124
|
+
layout: AgentLayout,
|
|
1125
|
+
action: "open" | "renew" | "revoke",
|
|
1126
|
+
signal?: AbortSignal,
|
|
1127
|
+
sessionId?: string,
|
|
1128
|
+
effectId?: string,
|
|
1129
|
+
): Promise<ComputerHostViewerResultV1> {
|
|
1130
|
+
return this.hostFor(layout).viewer(action, {
|
|
1131
|
+
signal,
|
|
1132
|
+
effectId,
|
|
1133
|
+
timeoutMs: TIMEOUTS.viewer,
|
|
1134
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// --- internals -----------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Creates the tenant's durable directories once per Computer.
|
|
1142
|
+
*
|
|
1143
|
+
* The Workspace and the sync both assume their roots exist. They are made
|
|
1144
|
+
* here rather than by the host's `open` because storage is reachable while
|
|
1145
|
+
* the tenant has no desktop: a Turn that only reads files must not have to
|
|
1146
|
+
* provision a screen first.
|
|
1147
|
+
*/
|
|
1148
|
+
private readyStorage(
|
|
1149
|
+
layout: AgentLayout,
|
|
1150
|
+
host: ComputerHostSurfaceV1,
|
|
1151
|
+
signal?: AbortSignal,
|
|
1152
|
+
): Promise<unknown> {
|
|
1153
|
+
let held = this.storagePromises.get(layout.key);
|
|
1154
|
+
if (!held) {
|
|
1155
|
+
held = this.execute(
|
|
1156
|
+
host,
|
|
1157
|
+
`mkdir -p ${[
|
|
1158
|
+
layout.workspaceDir,
|
|
1159
|
+
`${DATA_ROOT}/agents/${layout.key}/memory`,
|
|
1160
|
+
`${DATA_ROOT}/agents/${layout.key}/skills`,
|
|
1161
|
+
`${DATA_ROOT}/user-memory`,
|
|
1162
|
+
`${DATA_ROOT}/user-packages`,
|
|
1163
|
+
`${RUNTIME_ROOT}/sync`,
|
|
1164
|
+
]
|
|
1165
|
+
.map(shellQuote)
|
|
1166
|
+
.join(" ")}\n`,
|
|
1167
|
+
{ signal, timeoutMs: TIMEOUTS.control, maxOutputBytes: MAX_OUTPUT },
|
|
1168
|
+
"Sprite storage operation failed",
|
|
1169
|
+
).catch((error: unknown) => {
|
|
1170
|
+
this.storagePromises.delete(layout.key);
|
|
1171
|
+
throw error;
|
|
1172
|
+
});
|
|
1173
|
+
this.storagePromises.set(layout.key, held);
|
|
1174
|
+
}
|
|
1175
|
+
return held;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private hostFor(layout: AgentLayout): ComputerHostSurfaceV1 {
|
|
1179
|
+
if (!this.host) {
|
|
1180
|
+
throw new ComputerError(
|
|
1181
|
+
"provider-unavailable",
|
|
1182
|
+
"Set SPRITES_TOKEN to attach a Fly Sprite computer",
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
let held = this.surfaces.get(layout.key);
|
|
1186
|
+
if (!held) {
|
|
1187
|
+
held = this.host(this.identity, { botId: layout.identity.id });
|
|
1188
|
+
this.surfaces.set(layout.key, held);
|
|
1189
|
+
}
|
|
1190
|
+
return held;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private async readyHost(
|
|
1194
|
+
layout: AgentLayout,
|
|
1195
|
+
signal?: AbortSignal,
|
|
1196
|
+
): Promise<ComputerHostSurfaceV1> {
|
|
1197
|
+
await this.ensureAgent(layout, signal);
|
|
1198
|
+
return this.hostFor(layout);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Opens the Computer and attaches this tenant to it.
|
|
1203
|
+
*
|
|
1204
|
+
* One call: the host provisions the Sprite if it must, runs the ensure
|
|
1205
|
+
* script, allocates a display slot, and answers with the tenant's directory
|
|
1206
|
+
* and generation. The Durable Object no longer sequences provisioning steps
|
|
1207
|
+
* because it can no longer see the Sprite — and that is the point.
|
|
1208
|
+
*/
|
|
1209
|
+
private async openAgent(
|
|
1210
|
+
layout: AgentLayout,
|
|
1211
|
+
options?: ComputerOperationOptions,
|
|
1212
|
+
): Promise<ComputerConnection> {
|
|
1213
|
+
const signal = options?.signal;
|
|
1214
|
+
const effectId = options?.effectId;
|
|
1215
|
+
const host = this.hostFor(layout);
|
|
1216
|
+
let opened: ComputerHostOpenResultV1;
|
|
1217
|
+
try {
|
|
1218
|
+
opened = await host.open({
|
|
1219
|
+
signal,
|
|
1220
|
+
timeoutMs: TIMEOUTS.open,
|
|
1221
|
+
...(effectId ? { effectId: `${effectId}:open` } : {}),
|
|
1222
|
+
});
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
// Every display belonging to a tenant this Computer still has open is a
|
|
1225
|
+
// declared outcome, not a crash: the alternative would be two Bots
|
|
1226
|
+
// sharing one screen, and Bots are separated on a Computer exactly so
|
|
1227
|
+
// that does not happen silently.
|
|
1228
|
+
if (isSlotExhaustion(error)) {
|
|
1229
|
+
throw new ComputerError(
|
|
1230
|
+
"capability-unavailable",
|
|
1231
|
+
`Every desktop on this Computer is in use; Bot "${layout.identity.id}" has no display until one is idle`,
|
|
1232
|
+
true,
|
|
1233
|
+
{ cause: error },
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
throw error;
|
|
1237
|
+
}
|
|
1238
|
+
this.expectedSpriteName = opened.spriteName;
|
|
1239
|
+
this.generations.set(layout.key, opened.generation);
|
|
1240
|
+
if (opened.display) this.displays.set(layout.key, opened.display);
|
|
1241
|
+
if (this.respectHumanControl) {
|
|
1242
|
+
await this.assertAgentControl(
|
|
1243
|
+
host,
|
|
1244
|
+
layout,
|
|
1245
|
+
signal,
|
|
1246
|
+
effectId ? `${effectId}:assert-control` : undefined,
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
const viewer = await host.viewer("open", {
|
|
1250
|
+
signal,
|
|
1251
|
+
timeoutMs: TIMEOUTS.viewer,
|
|
1252
|
+
...(effectId ? { effectId: `${effectId}:viewer` } : {}),
|
|
1253
|
+
});
|
|
1254
|
+
if (!viewer.session) {
|
|
1255
|
+
throw new ComputerError(
|
|
1256
|
+
"provider-unavailable",
|
|
1257
|
+
"The Computer host returned no viewer session",
|
|
1258
|
+
true,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
botId: layout.identity.id,
|
|
1263
|
+
botKey: layout.key,
|
|
1264
|
+
spriteName: opened.spriteName,
|
|
1265
|
+
viewerUrl: viewer.session.url,
|
|
1266
|
+
viewerSessionId: viewer.session.id,
|
|
1267
|
+
...(viewer.session.expiresAt
|
|
1268
|
+
? { viewerExpiresAt: viewer.session.expiresAt }
|
|
1269
|
+
: {}),
|
|
1270
|
+
display: opened.display ?? "",
|
|
1271
|
+
directory: `agent-data/agents/${layout.key}`,
|
|
1272
|
+
...(opened.provisioning
|
|
1273
|
+
? { message: provisioningMessage(opened.provisioning) }
|
|
1274
|
+
: {}),
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private async assertAgentControl(
|
|
1279
|
+
host: ComputerHostSurfaceV1,
|
|
1280
|
+
layout: AgentLayout,
|
|
1281
|
+
signal?: AbortSignal,
|
|
1282
|
+
effectId?: string,
|
|
1283
|
+
): Promise<void> {
|
|
1284
|
+
await this.execute(
|
|
1285
|
+
host,
|
|
1286
|
+
`${CONTROL_SCRIPT} assert-agent ${shellQuote(layout.key)} ${shellQuote(DESKTOP_GUI_LEASE_KEY)} ${shellQuote(this.ownerId)} ${LEASE_MAX_AGE_SECONDS}\n`,
|
|
1287
|
+
{
|
|
1288
|
+
signal,
|
|
1289
|
+
effectId,
|
|
1290
|
+
timeoutMs: TIMEOUTS.control,
|
|
1291
|
+
maxOutputBytes: MAX_OUTPUT,
|
|
1292
|
+
},
|
|
1293
|
+
"Sprite command failed",
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* The environment every command this provider runs for a tenant starts in.
|
|
1299
|
+
*
|
|
1300
|
+
* `PATH` leads with the shims and then the Computer's own `bin`: a command
|
|
1301
|
+
* that reaches for `chromium` or `xdotool` by name finds the refusal, and
|
|
1302
|
+
* the browser launcher is still reachable by name.
|
|
1303
|
+
* That is policy, not a boundary — the real binaries are still on the box,
|
|
1304
|
+
* one absolute path away — and it is stated as policy in `browser.md` and in
|
|
1305
|
+
* the refusal itself.
|
|
1306
|
+
*
|
|
1307
|
+
* The Bot's working directory stays its own private workspace; the shared
|
|
1308
|
+
* scratch is named in the environment rather than entered, because a default
|
|
1309
|
+
* cwd every Bot of a User shares is a default cwd where files collide.
|
|
1310
|
+
*/
|
|
1311
|
+
private tenantEnvironment(layout: AgentLayout): string[] {
|
|
1312
|
+
return [
|
|
1313
|
+
`export HOME=${HOME_ROOT}`,
|
|
1314
|
+
`export PATH=${SHIMS_ROOT}:${BIN_ROOT}:$PATH`,
|
|
1315
|
+
`export FROCKBOT_BOT_ID=${shellQuote(layout.identity.id)}`,
|
|
1316
|
+
`export FROCKBOT_BOT_KEY=${shellQuote(layout.key)}`,
|
|
1317
|
+
`export ${SCRATCH_ENV}=${SCRATCH_ROOT}`,
|
|
1318
|
+
`cd ${shellQuote(layout.workspaceDir)}`,
|
|
1319
|
+
];
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Prefixes every command this provider runs for a tenant: the human-control
|
|
1324
|
+
* assertion, and the registry's `last-seen` stamp.
|
|
1325
|
+
*
|
|
1326
|
+
* The stamp is what keeps an exec-only tenant's desktop slot: it never opens
|
|
1327
|
+
* a viewer and never holds an X lock, so without it the slot reclaim would
|
|
1328
|
+
* be free to hand its display to another Bot mid-run.
|
|
1329
|
+
*/
|
|
1330
|
+
private agentControlGuard(layout: AgentLayout): string {
|
|
1331
|
+
return [
|
|
1332
|
+
`${CONTROL_SCRIPT} assert-agent ${shellQuote(layout.key)} ${shellQuote(DESKTOP_GUI_LEASE_KEY)} ${shellQuote(this.ownerId)} ${LEASE_MAX_AGE_SECONDS} || exit $?`,
|
|
1333
|
+
this.tenantStamp(layout),
|
|
1334
|
+
].join("\n");
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** The registry's `last-seen` stamp, without the control assertion. */
|
|
1338
|
+
private tenantStamp(layout: AgentLayout): string {
|
|
1339
|
+
const bot = shellQuote(`${BOTS_ROOT}/${layout.key}`);
|
|
1340
|
+
return `mkdir -p ${bot} && touch ${bot}/last-seen`;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Runs one script and refuses a non-zero exit.
|
|
1345
|
+
*
|
|
1346
|
+
* A non-zero exit here is the outer document's, not the Bot's command's: the
|
|
1347
|
+
* control guard, a missing directory, an unreadable file. Those are failures
|
|
1348
|
+
* of the operation the caller asked for, so they throw with the label the
|
|
1349
|
+
* caller named, exactly as the SDK's own rejection used to.
|
|
1350
|
+
*/
|
|
1351
|
+
private async execute(
|
|
1352
|
+
host: ComputerHostSurfaceV1,
|
|
1353
|
+
script: string,
|
|
1354
|
+
options: {
|
|
1355
|
+
signal?: AbortSignal;
|
|
1356
|
+
effectId?: string;
|
|
1357
|
+
timeoutMs: number;
|
|
1358
|
+
maxOutputBytes: number;
|
|
1359
|
+
},
|
|
1360
|
+
label: string,
|
|
1361
|
+
): Promise<ComputerHostExecOutcomeV1> {
|
|
1362
|
+
let outcome: ComputerHostExecOutcomeV1;
|
|
1363
|
+
try {
|
|
1364
|
+
outcome = await host.exec(
|
|
1365
|
+
{
|
|
1366
|
+
script,
|
|
1367
|
+
timeoutMs: options.timeoutMs,
|
|
1368
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1372
|
+
...(options.effectId ? { effectId: options.effectId } : {}),
|
|
1373
|
+
},
|
|
1374
|
+
);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
if (error instanceof ComputerError) throw error;
|
|
1377
|
+
throw new Error(`${label}: ${errorText(error)}`);
|
|
1378
|
+
}
|
|
1379
|
+
if (outcome.exitCode !== 0) {
|
|
1380
|
+
const detail =
|
|
1381
|
+
outputText(outcome.stderr).trim() ||
|
|
1382
|
+
outputText(outcome.stdout).trim() ||
|
|
1383
|
+
`exit ${String(outcome.exitCode)}`;
|
|
1384
|
+
if (outcome.exitCode === 73) {
|
|
1385
|
+
throw new ComputerError("human-control-active", detail);
|
|
1386
|
+
}
|
|
1387
|
+
throw new Error(`${label}: ${detail}`);
|
|
1388
|
+
}
|
|
1389
|
+
return outcome;
|
|
1390
|
+
}
|
|
1391
|
+
}
|