@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
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bot Durable Object's side of the shared Computer host (ADR 0004).
|
|
3
|
+
*
|
|
4
|
+
* This is a transport and nothing else. It speaks the v1 protocol of
|
|
5
|
+
* `@frockbot/computer-host-protocol` over the `COMPUTER_HOST` service binding,
|
|
6
|
+
* decodes every answer at the seam, and translates a host failure into the
|
|
7
|
+
* provider-neutral `ComputerError` the Computer interface declares. It holds
|
|
8
|
+
* no Sprites SDK, no `SPRITES_TOKEN`, and no knowledge of what a script does:
|
|
9
|
+
* "Secrets remain server-side and cross interfaces only as opaque references
|
|
10
|
+
* when necessary", so what leaves the Durable Object is a `credentialRef` the
|
|
11
|
+
* host resolves.
|
|
12
|
+
*
|
|
13
|
+
* It lives in this Package rather than in `computer-core` because the protocol
|
|
14
|
+
* it speaks is not provider-neutral: `ComputerHostOpenResultV1` answers with a
|
|
15
|
+
* `spriteName`, and the host it addresses is the Fly Sprites host. "Electron,
|
|
16
|
+
* Cloudflare, provider SDK, and Computer implementation types remain inside
|
|
17
|
+
* their adapters" — this is that adapter, and `computer-core` stays free of
|
|
18
|
+
* both the wire protocol and the binding.
|
|
19
|
+
*
|
|
20
|
+
* Two behaviours are load-bearing and are the reason this is a class rather
|
|
21
|
+
* than a function:
|
|
22
|
+
*
|
|
23
|
+
* - **Framing.** A streamed exec answers NDJSON, and a transport chunk
|
|
24
|
+
* boundary means nothing: a frame may be split across two chunks or three
|
|
25
|
+
* frames may arrive in one. `ComputerHostExecFrameReaderV1` finds the
|
|
26
|
+
* newline; this client never reads a chunk as a frame.
|
|
27
|
+
* - **Cancellation.** "Connections to the Computer are expected to drop on
|
|
28
|
+
* every pause." A caller's abort aborts the fetch *and* posts a `cancel` for
|
|
29
|
+
* the same `effectId`, because a dropped connection alone leaves the process
|
|
30
|
+
* running on the Computer.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { ComputerError, type ComputerErrorCode } from "@frockbot/computer-core";
|
|
34
|
+
import {
|
|
35
|
+
COMPUTER_HOST_LIMITS,
|
|
36
|
+
COMPUTER_HOST_PROTOCOL_VERSION,
|
|
37
|
+
COMPUTER_HOST_ROUTES,
|
|
38
|
+
COMPUTER_HOST_TOKEN_HEADER,
|
|
39
|
+
ComputerHostExecFrameReaderV1,
|
|
40
|
+
decodeComputerHostCancelResultV1,
|
|
41
|
+
decodeComputerHostControlResultV1,
|
|
42
|
+
decodeComputerHostExecResultV1,
|
|
43
|
+
decodeComputerHostFileDeleteResultV1,
|
|
44
|
+
decodeComputerHostFileListResultV1,
|
|
45
|
+
decodeComputerHostFileReadResultV1,
|
|
46
|
+
decodeComputerHostFileStatResultV1,
|
|
47
|
+
decodeComputerHostFileWriteResultV1,
|
|
48
|
+
decodeComputerHostOpenResultV1,
|
|
49
|
+
decodeComputerHostProblemV1,
|
|
50
|
+
decodeComputerHostServiceResultV1,
|
|
51
|
+
decodeComputerHostViewerResultV1,
|
|
52
|
+
encodeComputerHostRequestV1,
|
|
53
|
+
type ComputerHostCancelResultV1,
|
|
54
|
+
type ComputerHostControlActionV1,
|
|
55
|
+
type ComputerHostControlScopeV1,
|
|
56
|
+
type ComputerHostControlResultV1,
|
|
57
|
+
type ComputerHostErrorCodeV1,
|
|
58
|
+
type ComputerHostFileDeleteResultV1,
|
|
59
|
+
type ComputerHostFileListResultV1,
|
|
60
|
+
type ComputerHostFileReadResultV1,
|
|
61
|
+
type ComputerHostFileStatResultV1,
|
|
62
|
+
type ComputerHostFileWriteResultV1,
|
|
63
|
+
type ComputerHostOpenResultV1,
|
|
64
|
+
type ComputerHostOperationKindV1,
|
|
65
|
+
type ComputerHostOperationV1,
|
|
66
|
+
type ComputerHostServiceResultV1,
|
|
67
|
+
type ComputerHostViewerResultV1,
|
|
68
|
+
} from "@frockbot/computer-host-protocol";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The origin every request is addressed to. A service binding routes by
|
|
72
|
+
* binding and ignores the host, so this names the seam rather than a network
|
|
73
|
+
* location: the Computer host has no public route (`apps/computer-host`
|
|
74
|
+
* declares none) and is unreachable except through the binding.
|
|
75
|
+
*/
|
|
76
|
+
export const COMPUTER_HOST_ORIGIN = "http://computer-host.internal";
|
|
77
|
+
|
|
78
|
+
/** How long past a caller's own timeout the client waits before giving up. */
|
|
79
|
+
export const COMPUTER_HOST_TIMEOUT_GRACE_MS = 5_000;
|
|
80
|
+
|
|
81
|
+
/** The deadline for a call that declares none of its own. */
|
|
82
|
+
export const COMPUTER_HOST_DEFAULT_TIMEOUT_MS = 120_000;
|
|
83
|
+
|
|
84
|
+
/** The deadline for a best-effort cancel, which must never outlive its caller. */
|
|
85
|
+
const CANCEL_TIMEOUT_MS = 5_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The `COMPUTER_HOST` service binding, narrowed to the one method used.
|
|
89
|
+
* A Worker `Fetcher` satisfies it; so does a test double, which is the point.
|
|
90
|
+
*/
|
|
91
|
+
export interface ComputerHostFetcherV1 {
|
|
92
|
+
fetch(request: Request): Promise<Response>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ComputerHostClientOptions {
|
|
96
|
+
fetcher: ComputerHostFetcherV1;
|
|
97
|
+
/**
|
|
98
|
+
* The shared secret between this Worker, the host Worker, and the container.
|
|
99
|
+
* The service binding is already unroutable; this is the second lock, so a
|
|
100
|
+
* container reached by any other path still refuses.
|
|
101
|
+
*/
|
|
102
|
+
hostToken: string;
|
|
103
|
+
/** Whose Computer this is. One Computer per User (ADR 0012). */
|
|
104
|
+
identity: { userId: string };
|
|
105
|
+
/** The Bot making the call, a tenant on that Computer. */
|
|
106
|
+
tenant: { botId: string };
|
|
107
|
+
/**
|
|
108
|
+
* The opaque reference the host resolves to a credential. It carries no
|
|
109
|
+
* credential material and is `sprites:user:<userId>` unless a caller names
|
|
110
|
+
* another; shipping it from day one is what lets the ADR 0004 credential
|
|
111
|
+
* broker land without a protocol version bump.
|
|
112
|
+
*/
|
|
113
|
+
credentialRef?: string;
|
|
114
|
+
origin?: string;
|
|
115
|
+
timeoutGraceMs?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Mints the effect identifier for a call that supplies none. The Durable
|
|
118
|
+
* Object supplies one for every effect it has recorded; this covers the
|
|
119
|
+
* calls that have no recorded intent, so a cancel can still name what it is
|
|
120
|
+
* cancelling.
|
|
121
|
+
*/
|
|
122
|
+
newEffectId?: () => string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ComputerHostCallOptions {
|
|
126
|
+
signal?: AbortSignal;
|
|
127
|
+
effectId?: string;
|
|
128
|
+
/** Overrides the deadline this call is given, in milliseconds. */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ComputerHostExecCommandV1 {
|
|
133
|
+
/** Shell source delivered on the command's stdin. Never on its argv. */
|
|
134
|
+
script: string;
|
|
135
|
+
cwd?: string;
|
|
136
|
+
env?: Record<string, string>;
|
|
137
|
+
/** Extra stdin appended after the script. */
|
|
138
|
+
stdin?: Uint8Array;
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
maxOutputBytes?: number;
|
|
141
|
+
/**
|
|
142
|
+
* NDJSON frames rather than one buffered answer. On by default: a streamed
|
|
143
|
+
* exec bounds its output as it arrives and its cancel reaches a process that
|
|
144
|
+
* is still running.
|
|
145
|
+
*/
|
|
146
|
+
stream?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ComputerHostExecOutcomeV1 {
|
|
150
|
+
effectId: string;
|
|
151
|
+
exitCode: number | null;
|
|
152
|
+
signal?: string;
|
|
153
|
+
stdout: Uint8Array;
|
|
154
|
+
stderr: Uint8Array;
|
|
155
|
+
/** True when the host truncated, or when this client stopped accumulating. */
|
|
156
|
+
outputTruncated: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const EMPTY = new Uint8Array(0);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The host's failure vocabulary mapped onto the Computer interface's.
|
|
163
|
+
*
|
|
164
|
+
* `timeout` becomes `provider-unavailable` rather than a code of its own: to
|
|
165
|
+
* the Bot, a Computer that did not answer in time is a Computer that is not
|
|
166
|
+
* available right now, and the retry decision is the same one.
|
|
167
|
+
*/
|
|
168
|
+
const ERROR_CODES: Record<ComputerHostErrorCodeV1, ComputerErrorCode> = {
|
|
169
|
+
"invalid-request": "invalid-request",
|
|
170
|
+
// The token is wrong or missing. That is a deployment fault, not a Computer
|
|
171
|
+
// fault, and retrying it changes nothing.
|
|
172
|
+
"not-authorized": "provider-failure",
|
|
173
|
+
"not-found": "provider-failure",
|
|
174
|
+
conflict: "conflict",
|
|
175
|
+
"limit-exceeded": "limit-exceeded",
|
|
176
|
+
"human-control-active": "human-control-active",
|
|
177
|
+
"computer-updating": "updating",
|
|
178
|
+
aborted: "aborted",
|
|
179
|
+
timeout: "provider-unavailable",
|
|
180
|
+
"provider-unavailable": "provider-unavailable",
|
|
181
|
+
"provider-failure": "provider-failure",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function toBytes(base64: string): Uint8Array {
|
|
185
|
+
if (!base64) return EMPTY;
|
|
186
|
+
const binary = atob(base64);
|
|
187
|
+
const bytes = new Uint8Array(binary.length);
|
|
188
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
189
|
+
bytes[index] = binary.charCodeAt(index);
|
|
190
|
+
}
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fromBytes(bytes: Uint8Array): string {
|
|
195
|
+
let binary = "";
|
|
196
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
197
|
+
return btoa(binary);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function concat(parts: readonly Uint8Array[], total: number): Uint8Array {
|
|
201
|
+
if (parts.length === 1) return parts[0] ?? EMPTY;
|
|
202
|
+
const joined = new Uint8Array(total);
|
|
203
|
+
let offset = 0;
|
|
204
|
+
for (const part of parts) {
|
|
205
|
+
joined.set(part, offset);
|
|
206
|
+
offset += part.byteLength;
|
|
207
|
+
}
|
|
208
|
+
return joined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Accumulates one output stream up to a declared ceiling, and no further. */
|
|
212
|
+
class BoundedOutput {
|
|
213
|
+
private readonly parts: Uint8Array[] = [];
|
|
214
|
+
private length = 0;
|
|
215
|
+
truncated = false;
|
|
216
|
+
|
|
217
|
+
constructor(private readonly limit: number) {}
|
|
218
|
+
|
|
219
|
+
push(chunk: Uint8Array): void {
|
|
220
|
+
if (!chunk.byteLength) return;
|
|
221
|
+
const room = this.limit - this.length;
|
|
222
|
+
if (room <= 0) {
|
|
223
|
+
this.truncated = true;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (chunk.byteLength > room) {
|
|
227
|
+
this.parts.push(chunk.subarray(0, room));
|
|
228
|
+
this.length += room;
|
|
229
|
+
this.truncated = true;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.parts.push(chunk);
|
|
233
|
+
this.length += chunk.byteLength;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
bytes(): Uint8Array {
|
|
237
|
+
return this.parts.length ? concat(this.parts, this.length) : EMPTY;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function defaultEffectId(): string {
|
|
242
|
+
return crypto.randomUUID();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A call in flight: its deadline, the caller's abort, and which of the two
|
|
247
|
+
* fired.
|
|
248
|
+
*
|
|
249
|
+
* They are one object because the answer to "why did this stop" decides the
|
|
250
|
+
* `ComputerError` the caller sees, and reading it off a bare `AbortSignal`
|
|
251
|
+
* cannot tell a caller's cancellation from an expired deadline.
|
|
252
|
+
*/
|
|
253
|
+
class CallLease {
|
|
254
|
+
readonly controller = new AbortController();
|
|
255
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
256
|
+
private detach: (() => void) | undefined;
|
|
257
|
+
timedOut = false;
|
|
258
|
+
|
|
259
|
+
constructor(
|
|
260
|
+
deadlineMs: number,
|
|
261
|
+
private readonly caller: AbortSignal | undefined,
|
|
262
|
+
) {
|
|
263
|
+
this.timer = setTimeout(() => {
|
|
264
|
+
this.timedOut = true;
|
|
265
|
+
this.controller.abort();
|
|
266
|
+
}, deadlineMs);
|
|
267
|
+
if (caller) {
|
|
268
|
+
if (caller.aborted) {
|
|
269
|
+
this.controller.abort();
|
|
270
|
+
} else {
|
|
271
|
+
const onAbort = () => this.controller.abort();
|
|
272
|
+
caller.addEventListener("abort", onAbort, { once: true });
|
|
273
|
+
this.detach = () => caller.removeEventListener("abort", onAbort);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get callerAborted(): boolean {
|
|
279
|
+
return this.caller?.aborted === true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
release(): void {
|
|
283
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
284
|
+
this.timer = undefined;
|
|
285
|
+
this.detach?.();
|
|
286
|
+
this.detach = undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export class ComputerHostClient {
|
|
291
|
+
private readonly fetcher: ComputerHostFetcherV1;
|
|
292
|
+
private readonly hostToken: string;
|
|
293
|
+
private readonly origin: string;
|
|
294
|
+
private readonly grace: number;
|
|
295
|
+
private readonly newEffectId: () => string;
|
|
296
|
+
readonly identity: { userId: string };
|
|
297
|
+
readonly tenant: { botId: string };
|
|
298
|
+
readonly credentialRef: string;
|
|
299
|
+
|
|
300
|
+
constructor(options: ComputerHostClientOptions) {
|
|
301
|
+
this.fetcher = options.fetcher;
|
|
302
|
+
this.hostToken = options.hostToken;
|
|
303
|
+
this.identity = { userId: options.identity.userId };
|
|
304
|
+
this.tenant = { botId: options.tenant.botId };
|
|
305
|
+
this.credentialRef =
|
|
306
|
+
options.credentialRef ?? `sprites:user:${options.identity.userId}`;
|
|
307
|
+
this.origin = options.origin ?? COMPUTER_HOST_ORIGIN;
|
|
308
|
+
this.grace = options.timeoutGraceMs ?? COMPUTER_HOST_TIMEOUT_GRACE_MS;
|
|
309
|
+
this.newEffectId = options.newEffectId ?? defaultEffectId;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A client for another Bot on the same User's Computer. */
|
|
313
|
+
forTenant(botId: string): ComputerHostClient {
|
|
314
|
+
return new ComputerHostClient({
|
|
315
|
+
fetcher: this.fetcher,
|
|
316
|
+
hostToken: this.hostToken,
|
|
317
|
+
identity: this.identity,
|
|
318
|
+
tenant: { botId },
|
|
319
|
+
credentialRef: this.credentialRef,
|
|
320
|
+
origin: this.origin,
|
|
321
|
+
timeoutGraceMs: this.grace,
|
|
322
|
+
newEffectId: this.newEffectId,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
open(options?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1> {
|
|
327
|
+
return this.json({ kind: "open" }, decodeComputerHostOpenResultV1, options);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Runs one bash document on the Computer.
|
|
332
|
+
*
|
|
333
|
+
* The script travels in the request body and reaches the command on its
|
|
334
|
+
* stdin. It is never argv: the Sprites SDK appends every argv element to the
|
|
335
|
+
* request URL, and a provisioning script answered HTTP 431 — the measurement
|
|
336
|
+
* recorded in ADR 0004 and the reason this seam exists.
|
|
337
|
+
*/
|
|
338
|
+
async exec(
|
|
339
|
+
command: ComputerHostExecCommandV1,
|
|
340
|
+
options?: ComputerHostCallOptions,
|
|
341
|
+
): Promise<ComputerHostExecOutcomeV1> {
|
|
342
|
+
const timeoutMs = Math.max(
|
|
343
|
+
1,
|
|
344
|
+
Math.min(
|
|
345
|
+
command.timeoutMs ?? COMPUTER_HOST_DEFAULT_TIMEOUT_MS,
|
|
346
|
+
COMPUTER_HOST_LIMITS.execTimeoutMs,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
const maxOutputBytes = Math.max(
|
|
350
|
+
1,
|
|
351
|
+
Math.min(
|
|
352
|
+
command.maxOutputBytes ?? COMPUTER_HOST_LIMITS.maxOutputBytes,
|
|
353
|
+
COMPUTER_HOST_LIMITS.maxOutputBytes,
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
const stream = command.stream ?? true;
|
|
357
|
+
const operation: ComputerHostOperationV1 = {
|
|
358
|
+
kind: "exec",
|
|
359
|
+
script: command.script,
|
|
360
|
+
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
361
|
+
...(command.env === undefined ? {} : { env: command.env }),
|
|
362
|
+
...(command.stdin === undefined
|
|
363
|
+
? {}
|
|
364
|
+
: { stdinBase64: fromBytes(command.stdin) }),
|
|
365
|
+
timeoutMs,
|
|
366
|
+
maxOutputBytes,
|
|
367
|
+
stream,
|
|
368
|
+
};
|
|
369
|
+
const effectId = this.effectIdFor(options);
|
|
370
|
+
const lease = this.lease(timeoutMs, options);
|
|
371
|
+
let response: Response;
|
|
372
|
+
try {
|
|
373
|
+
response = await this.send(operation, effectId, lease);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
lease.release();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
if (!stream) {
|
|
379
|
+
try {
|
|
380
|
+
const result = decodeComputerHostExecResultV1(
|
|
381
|
+
await this.body(response, lease, effectId),
|
|
382
|
+
);
|
|
383
|
+
return {
|
|
384
|
+
effectId,
|
|
385
|
+
exitCode: result.exitCode,
|
|
386
|
+
...(result.signal ? { signal: result.signal } : {}),
|
|
387
|
+
stdout: toBytes(result.stdoutBase64),
|
|
388
|
+
stderr: toBytes(result.stderrBase64),
|
|
389
|
+
outputTruncated: result.outputTruncated,
|
|
390
|
+
};
|
|
391
|
+
} finally {
|
|
392
|
+
lease.release();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
return await this.drain(response, lease, effectId, maxOutputBytes);
|
|
397
|
+
} finally {
|
|
398
|
+
lease.release();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fileRead(
|
|
403
|
+
path: string,
|
|
404
|
+
options?: ComputerHostCallOptions,
|
|
405
|
+
): Promise<ComputerHostFileReadResultV1> {
|
|
406
|
+
return this.json(
|
|
407
|
+
{ kind: "file/read", path },
|
|
408
|
+
decodeComputerHostFileReadResultV1,
|
|
409
|
+
options,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fileWrite(
|
|
414
|
+
path: string,
|
|
415
|
+
bytes: Uint8Array,
|
|
416
|
+
options?: ComputerHostCallOptions & { mode?: number },
|
|
417
|
+
): Promise<ComputerHostFileWriteResultV1> {
|
|
418
|
+
return this.json(
|
|
419
|
+
{
|
|
420
|
+
kind: "file/write",
|
|
421
|
+
path,
|
|
422
|
+
bytesBase64: fromBytes(bytes),
|
|
423
|
+
...(options?.mode === undefined ? {} : { mode: options.mode }),
|
|
424
|
+
},
|
|
425
|
+
decodeComputerHostFileWriteResultV1,
|
|
426
|
+
options,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fileList(
|
|
431
|
+
path: string,
|
|
432
|
+
options?: ComputerHostCallOptions & { recursive?: boolean },
|
|
433
|
+
): Promise<ComputerHostFileListResultV1> {
|
|
434
|
+
return this.json(
|
|
435
|
+
{ kind: "file/list", path, recursive: options?.recursive ?? false },
|
|
436
|
+
decodeComputerHostFileListResultV1,
|
|
437
|
+
options,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fileStat(
|
|
442
|
+
path: string,
|
|
443
|
+
options?: ComputerHostCallOptions,
|
|
444
|
+
): Promise<ComputerHostFileStatResultV1> {
|
|
445
|
+
return this.json(
|
|
446
|
+
{ kind: "file/stat", path },
|
|
447
|
+
decodeComputerHostFileStatResultV1,
|
|
448
|
+
options,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fileDelete(
|
|
453
|
+
path: string,
|
|
454
|
+
options?: ComputerHostCallOptions & { recursive?: boolean },
|
|
455
|
+
): Promise<ComputerHostFileDeleteResultV1> {
|
|
456
|
+
return this.json(
|
|
457
|
+
{ kind: "file/delete", path, recursive: options?.recursive ?? false },
|
|
458
|
+
decodeComputerHostFileDeleteResultV1,
|
|
459
|
+
options,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
control(
|
|
464
|
+
action: ComputerHostControlActionV1,
|
|
465
|
+
ownerId: string,
|
|
466
|
+
maxAgeSeconds: number,
|
|
467
|
+
options?: ComputerHostCallOptions & {
|
|
468
|
+
scope?: ComputerHostControlScopeV1;
|
|
469
|
+
},
|
|
470
|
+
): Promise<ComputerHostControlResultV1> {
|
|
471
|
+
return this.json(
|
|
472
|
+
{
|
|
473
|
+
kind: "control",
|
|
474
|
+
action,
|
|
475
|
+
ownerId,
|
|
476
|
+
maxAgeSeconds,
|
|
477
|
+
// Absent ⇒ legacy `bot`. Human sessions and `computerUse` explicitly
|
|
478
|
+
// name the User-wide `desktop-gui` screen lease.
|
|
479
|
+
...(options?.scope === undefined ? {} : { scope: options.scope }),
|
|
480
|
+
},
|
|
481
|
+
decodeComputerHostControlResultV1,
|
|
482
|
+
options,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
viewer(
|
|
487
|
+
action: "open" | "renew" | "revoke",
|
|
488
|
+
options?: ComputerHostCallOptions & { sessionId?: string },
|
|
489
|
+
): Promise<ComputerHostViewerResultV1> {
|
|
490
|
+
return this.json(
|
|
491
|
+
{
|
|
492
|
+
kind: "viewer",
|
|
493
|
+
action,
|
|
494
|
+
...(options?.sessionId === undefined
|
|
495
|
+
? {}
|
|
496
|
+
: { sessionId: options.sessionId }),
|
|
497
|
+
},
|
|
498
|
+
decodeComputerHostViewerResultV1,
|
|
499
|
+
options,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
service(
|
|
504
|
+
name: string,
|
|
505
|
+
options?: ComputerHostCallOptions,
|
|
506
|
+
): Promise<ComputerHostServiceResultV1> {
|
|
507
|
+
return this.json(
|
|
508
|
+
{ kind: "service", name },
|
|
509
|
+
decodeComputerHostServiceResultV1,
|
|
510
|
+
options,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Cancels the effect this identifier names.
|
|
516
|
+
*
|
|
517
|
+
* There is no second identifier in the DTO: the envelope's `effectId` *is*
|
|
518
|
+
* what is being cancelled, so a cancel cannot disagree with itself.
|
|
519
|
+
*/
|
|
520
|
+
cancel(
|
|
521
|
+
effectId: string,
|
|
522
|
+
options?: ComputerHostCallOptions,
|
|
523
|
+
): Promise<ComputerHostCancelResultV1> {
|
|
524
|
+
return this.json({ kind: "cancel" }, decodeComputerHostCancelResultV1, {
|
|
525
|
+
...options,
|
|
526
|
+
effectId,
|
|
527
|
+
timeoutMs: options?.timeoutMs ?? CANCEL_TIMEOUT_MS,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --- internals -----------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
private effectIdFor(options: ComputerHostCallOptions | undefined): string {
|
|
534
|
+
return options?.effectId?.trim() || this.newEffectId();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The client's own deadline: the host's, plus a grace.
|
|
539
|
+
*
|
|
540
|
+
* The grace is what lets the host's per-phase timeout answer first. Without
|
|
541
|
+
* it the two deadlines race, and a command that the host killed cleanly at
|
|
542
|
+
* 120 s would reach the caller as an unexplained transport failure instead
|
|
543
|
+
* of the `timeout` problem the host wrote.
|
|
544
|
+
*/
|
|
545
|
+
private lease(
|
|
546
|
+
timeoutMs: number,
|
|
547
|
+
options: ComputerHostCallOptions | undefined,
|
|
548
|
+
): CallLease {
|
|
549
|
+
return new CallLease(
|
|
550
|
+
(options?.timeoutMs ?? timeoutMs) + this.grace,
|
|
551
|
+
options?.signal,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async json<T>(
|
|
556
|
+
operation: ComputerHostOperationV1,
|
|
557
|
+
decode: (input: unknown) => T,
|
|
558
|
+
options: ComputerHostCallOptions | undefined,
|
|
559
|
+
): Promise<T> {
|
|
560
|
+
const effectId = this.effectIdFor(options);
|
|
561
|
+
const lease = this.lease(COMPUTER_HOST_DEFAULT_TIMEOUT_MS, options);
|
|
562
|
+
try {
|
|
563
|
+
const response = await this.send(operation, effectId, lease);
|
|
564
|
+
return decode(await this.body(response, lease, effectId));
|
|
565
|
+
} finally {
|
|
566
|
+
lease.release();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async send(
|
|
571
|
+
operation: ComputerHostOperationV1,
|
|
572
|
+
effectId: string,
|
|
573
|
+
lease: CallLease,
|
|
574
|
+
): Promise<Response> {
|
|
575
|
+
const kind: ComputerHostOperationKindV1 = operation.kind;
|
|
576
|
+
const body = JSON.stringify(
|
|
577
|
+
encodeComputerHostRequestV1({
|
|
578
|
+
version: COMPUTER_HOST_PROTOCOL_VERSION,
|
|
579
|
+
effectId,
|
|
580
|
+
identity: this.identity,
|
|
581
|
+
tenant: this.tenant,
|
|
582
|
+
credentialRef: this.credentialRef,
|
|
583
|
+
operation,
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
let response: Response;
|
|
587
|
+
try {
|
|
588
|
+
response = await this.fetcher.fetch(
|
|
589
|
+
new Request(`${this.origin}${COMPUTER_HOST_ROUTES[kind]}`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: {
|
|
592
|
+
"content-type": "application/json",
|
|
593
|
+
[COMPUTER_HOST_TOKEN_HEADER]: this.hostToken,
|
|
594
|
+
},
|
|
595
|
+
body,
|
|
596
|
+
signal: lease.controller.signal,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
throw this.refuse(error, lease, effectId, kind !== "cancel");
|
|
601
|
+
}
|
|
602
|
+
if (response.ok) return response;
|
|
603
|
+
throw await this.problem(response, lease, effectId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Reads one JSON answer, translating a mid-read abort like any other. */
|
|
607
|
+
private async body(
|
|
608
|
+
response: Response,
|
|
609
|
+
lease: CallLease,
|
|
610
|
+
effectId: string,
|
|
611
|
+
): Promise<unknown> {
|
|
612
|
+
try {
|
|
613
|
+
return await response.json();
|
|
614
|
+
} catch (error) {
|
|
615
|
+
throw this.refuse(error, lease, effectId, false);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Reads an NDJSON exec stream to its end.
|
|
621
|
+
*
|
|
622
|
+
* Output past `maxOutputBytes` is dropped rather than accumulated, and the
|
|
623
|
+
* stream is still read to completion: the exit frame is what says whether
|
|
624
|
+
* the command succeeded, and abandoning the read to save bytes would trade a
|
|
625
|
+
* known outcome for an unknown one.
|
|
626
|
+
*/
|
|
627
|
+
private async drain(
|
|
628
|
+
response: Response,
|
|
629
|
+
lease: CallLease,
|
|
630
|
+
effectId: string,
|
|
631
|
+
maxOutputBytes: number,
|
|
632
|
+
): Promise<ComputerHostExecOutcomeV1> {
|
|
633
|
+
if (!response.body) {
|
|
634
|
+
throw new ComputerError(
|
|
635
|
+
"provider-failure",
|
|
636
|
+
"The Computer host answered an exec stream with no body",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const reader = response.body.getReader();
|
|
640
|
+
const frames = new ComputerHostExecFrameReaderV1();
|
|
641
|
+
const stdout = new BoundedOutput(maxOutputBytes);
|
|
642
|
+
const stderr = new BoundedOutput(maxOutputBytes);
|
|
643
|
+
let exit: { exitCode: number | null; signal?: string } | undefined;
|
|
644
|
+
let hostTruncated = false;
|
|
645
|
+
let failure: ComputerError | undefined;
|
|
646
|
+
|
|
647
|
+
const consume = (
|
|
648
|
+
batch: ReturnType<ComputerHostExecFrameReaderV1["push"]>,
|
|
649
|
+
) => {
|
|
650
|
+
for (const frame of batch) {
|
|
651
|
+
if (frame.type === "stdout") {
|
|
652
|
+
stdout.push(toBytes(frame.dataBase64));
|
|
653
|
+
} else if (frame.type === "stderr") {
|
|
654
|
+
stderr.push(toBytes(frame.dataBase64));
|
|
655
|
+
} else if (frame.type === "exit") {
|
|
656
|
+
exit = {
|
|
657
|
+
exitCode: frame.exitCode,
|
|
658
|
+
...(frame.signal ? { signal: frame.signal } : {}),
|
|
659
|
+
};
|
|
660
|
+
hostTruncated = frame.outputTruncated;
|
|
661
|
+
} else {
|
|
662
|
+
failure ??= new ComputerError(
|
|
663
|
+
ERROR_CODES[frame.code],
|
|
664
|
+
frame.message,
|
|
665
|
+
frame.retryable,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
for (;;) {
|
|
673
|
+
const { done, value } = await reader.read();
|
|
674
|
+
if (done) break;
|
|
675
|
+
if (value) consume(frames.push(value));
|
|
676
|
+
}
|
|
677
|
+
consume(frames.end());
|
|
678
|
+
} catch (error) {
|
|
679
|
+
throw this.refuse(error, lease, effectId, true);
|
|
680
|
+
} finally {
|
|
681
|
+
reader.releaseLock();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (failure) throw failure;
|
|
685
|
+
if (!exit) {
|
|
686
|
+
// The stream ended without an outcome. That is the shape of a container
|
|
687
|
+
// that restarted mid-exec: "In-flight exec dies; the DO sees a stream
|
|
688
|
+
// error", and the effect's outcome is unknown rather than failed.
|
|
689
|
+
throw new ComputerError(
|
|
690
|
+
"provider-unavailable",
|
|
691
|
+
"The Computer host exec stream ended before the command exited",
|
|
692
|
+
true,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
effectId,
|
|
697
|
+
exitCode: exit.exitCode,
|
|
698
|
+
...(exit.signal ? { signal: exit.signal } : {}),
|
|
699
|
+
stdout: stdout.bytes(),
|
|
700
|
+
stderr: stderr.bytes(),
|
|
701
|
+
outputTruncated: hostTruncated || stdout.truncated || stderr.truncated,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Turns a non-2xx answer into the `ComputerError` its problem body declares. */
|
|
706
|
+
private async problem(
|
|
707
|
+
response: Response,
|
|
708
|
+
lease: CallLease,
|
|
709
|
+
effectId: string,
|
|
710
|
+
): Promise<ComputerError> {
|
|
711
|
+
let decoded: ReturnType<typeof decodeComputerHostProblemV1> | undefined;
|
|
712
|
+
try {
|
|
713
|
+
decoded = decodeComputerHostProblemV1(await response.json());
|
|
714
|
+
} catch {
|
|
715
|
+
decoded = undefined;
|
|
716
|
+
}
|
|
717
|
+
if (!decoded) {
|
|
718
|
+
// A body this client cannot decode is still a refusal, and the status is
|
|
719
|
+
// the only thing left that means anything. 429 is the load shed the
|
|
720
|
+
// container declares; everything else is the host misbehaving.
|
|
721
|
+
return response.status === 429
|
|
722
|
+
? new ComputerError(
|
|
723
|
+
"limit-exceeded",
|
|
724
|
+
"The Computer host is shedding load",
|
|
725
|
+
true,
|
|
726
|
+
)
|
|
727
|
+
: new ComputerError(
|
|
728
|
+
"provider-failure",
|
|
729
|
+
`The Computer host answered ${response.status} with an undecodable body`,
|
|
730
|
+
response.status >= 500,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (decoded.code === "aborted" && lease.callerAborted) {
|
|
734
|
+
this.cancelQuietly(effectId);
|
|
735
|
+
}
|
|
736
|
+
return new ComputerError(
|
|
737
|
+
ERROR_CODES[decoded.code],
|
|
738
|
+
decoded.message,
|
|
739
|
+
decoded.retryable,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Classifies a thrown transport failure.
|
|
745
|
+
*
|
|
746
|
+
* Three things look alike at the `fetch` seam and mean different things: the
|
|
747
|
+
* caller cancelled, the deadline expired, or the host never answered. Only
|
|
748
|
+
* the first is the caller's own doing, and only it posts a cancel — the
|
|
749
|
+
* other two leave the host to its own timeout.
|
|
750
|
+
*/
|
|
751
|
+
private refuse(
|
|
752
|
+
error: unknown,
|
|
753
|
+
lease: CallLease,
|
|
754
|
+
effectId: string,
|
|
755
|
+
cancellable: boolean,
|
|
756
|
+
): ComputerError {
|
|
757
|
+
if (lease.callerAborted) {
|
|
758
|
+
if (cancellable) this.cancelQuietly(effectId);
|
|
759
|
+
return new ComputerError(
|
|
760
|
+
"aborted",
|
|
761
|
+
"The Computer effect was cancelled",
|
|
762
|
+
false,
|
|
763
|
+
{ cause: error },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (lease.timedOut) {
|
|
767
|
+
return new ComputerError(
|
|
768
|
+
"provider-unavailable",
|
|
769
|
+
"The Computer host did not answer within the effect's deadline",
|
|
770
|
+
true,
|
|
771
|
+
{ cause: error },
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
return new ComputerError(
|
|
775
|
+
"provider-unavailable",
|
|
776
|
+
`The Computer host is unreachable: ${
|
|
777
|
+
error instanceof Error ? error.message : String(error)
|
|
778
|
+
}`,
|
|
779
|
+
true,
|
|
780
|
+
{ cause: error },
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Tells the host to kill a process this client has stopped listening to.
|
|
786
|
+
*
|
|
787
|
+
* Best-effort by construction: the caller has already given up, so a failed
|
|
788
|
+
* cancel must not become the error it sees. The host's own per-phase timeout
|
|
789
|
+
* is the backstop when this never arrives.
|
|
790
|
+
*/
|
|
791
|
+
private cancelQuietly(effectId: string): void {
|
|
792
|
+
void this.cancel(effectId).catch(() => undefined);
|
|
793
|
+
}
|
|
794
|
+
}
|