@opengeni/runtime 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-KNW7AMQB.js → chunk-HGQ252FL.js} +251 -22
- package/dist/chunk-HGQ252FL.js.map +1 -0
- package/dist/index-CSGkld-v.d.ts +1801 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +238 -39
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +4 -1738
- package/dist/sandbox/index.js +11 -1
- package/package.json +3 -3
- package/src/history-sanitizer.ts +35 -38
- package/src/index.ts +133 -10
- package/src/metrics.ts +5 -0
- package/src/sandbox/display-stack.ts +69 -13
- package/src/sandbox/index.ts +100 -13
- package/src/sandbox/providers/modal.ts +225 -0
- package/src/sandbox/routing/routing-session.ts +2 -2
- package/src/sandbox/selfhosted/session.ts +21 -5
- package/src/sandbox-computer.ts +214 -48
- package/src/screenshot-error-card.ts +25 -0
- package/dist/chunk-KNW7AMQB.js.map +0 -1
package/src/sandbox-computer.ts
CHANGED
|
@@ -77,6 +77,19 @@ const SCROLL_MAX_CLICKS = 15;
|
|
|
77
77
|
// short to ride out a cold gVisor XFCE boot, so the turn failed loud on a transient.
|
|
78
78
|
const SCREENSHOT_WARMUP_BUDGET_MS = 30_000;
|
|
79
79
|
const SCREENSHOT_RETRY_DELAY_MS = 750;
|
|
80
|
+
// The screenshot PNG is read back off the box by base64-ing it over the provider's exec
|
|
81
|
+
// channel. Modal's exec collects stdout into a BOUNDED buffer that silently EMPTIES a
|
|
82
|
+
// single oversized read: a fully-painted 1280x800 desktop PNG (~222 KB) base64s to
|
|
83
|
+
// ~296 KB, which trips the cap and returns "" — the blank-frame incident. A partly-
|
|
84
|
+
// painted frame (~200 KB base64) slipped under it, which is why this only surfaced once
|
|
85
|
+
// the paint gate began delivering FULLY-painted frames. (Channel-A's file read already
|
|
86
|
+
// caps its own base64-over-exec output for the same reason; the screenshot read was the
|
|
87
|
+
// one large read that didn't bound itself.) We therefore read the file in 3-byte-aligned
|
|
88
|
+
// CHUNKS whose per-read base64 (~131 KB) stays well under the cap, then VALIDATE the
|
|
89
|
+
// reconstructed byte count against the on-box file size. 98304 = 96 KiB, divisible by 3
|
|
90
|
+
// so each chunk's base64 is self-contained (no cross-chunk padding) and concatenating the
|
|
91
|
+
// decoded chunks reconstructs the PNG byte-exactly.
|
|
92
|
+
const SCREENSHOT_READ_CHUNK_BYTES = 98_304;
|
|
80
93
|
|
|
81
94
|
export type SandboxComputerOptions = {
|
|
82
95
|
display?: string; // ":0"
|
|
@@ -116,7 +129,9 @@ const BUTTON_NUM: Record<ComputerButton, number> = { left: 1, wheel: 2, right: 3
|
|
|
116
129
|
// exec/execCommand (readFile path-validates against /workspace and rejects /tmp).
|
|
117
130
|
type ExecResultLike = { output?: string; stdout?: string; stderr?: string; exitCode?: number | null; sessionId?: number };
|
|
118
131
|
type ComputerSession = {
|
|
119
|
-
|
|
132
|
+
// A native provider exec returns a structured `ExecResultLike`; the routing proxy
|
|
133
|
+
// fronting a Modal box returns the execCommand banner STRING. Both are handled.
|
|
134
|
+
exec?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<ExecResultLike | string>;
|
|
120
135
|
execCommand?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<string>;
|
|
121
136
|
};
|
|
122
137
|
|
|
@@ -124,6 +139,13 @@ type ComputerSession = {
|
|
|
124
139
|
export class ComputerUnavailableError extends Error {
|
|
125
140
|
constructor(message: string) { super(message); this.name = "ComputerUnavailableError"; }
|
|
126
141
|
}
|
|
142
|
+
/** The screenshot file read back SHORT of its on-box byte size — a chunk was truncated
|
|
143
|
+
* by the provider's exec-output cap. This is deterministic, NOT a warm-up transient, so
|
|
144
|
+
* it fails LOUD immediately with the byte counts (never a silent blank the model would
|
|
145
|
+
* read as a real empty screen) and is not retried. */
|
|
146
|
+
export class ScreenshotReadError extends Error {
|
|
147
|
+
constructor(message: string) { super(message); this.name = "ScreenshotReadError"; }
|
|
148
|
+
}
|
|
127
149
|
/** A write action attempted while readOnly. */
|
|
128
150
|
export class ComputerReadOnlyError extends Error {
|
|
129
151
|
constructor() { super("computer-use is read-only — write actions are disabled"); this.name = "ComputerReadOnlyError"; }
|
|
@@ -268,6 +290,12 @@ export class SandboxComputer implements Computer {
|
|
|
268
290
|
return Buffer.from(bytes).toString("base64");
|
|
269
291
|
} catch (error) {
|
|
270
292
|
lastError = error;
|
|
293
|
+
// A short/truncated read is deterministic (the exec-output cap), not a warm-up
|
|
294
|
+
// transient — retrying only burns the budget and delays a legible failure. Fail
|
|
295
|
+
// loud NOW (the `finally` still cleans up the temp file).
|
|
296
|
+
if (error instanceof ScreenshotReadError) {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
271
299
|
} finally {
|
|
272
300
|
// Best-effort cleanup on every attempt (success OR failure); never mask the
|
|
273
301
|
// screenshot result.
|
|
@@ -288,37 +316,75 @@ export class SandboxComputer implements Computer {
|
|
|
288
316
|
throw new ComputerUnavailableError("scrot produced an empty screenshot (display not up?)");
|
|
289
317
|
}
|
|
290
318
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
319
|
+
// Run a read-only command over the SAME command primitive computer actions use
|
|
320
|
+
// (exec ?? execCommand) and return its RAW stdout body — NOT `session.readFile` (Modal
|
|
293
321
|
// path-validates against /workspace and rejects /tmp) and NOT `this.x()` (its
|
|
294
|
-
// `sandboxCommandOutput` parser drops the execCommand STRING body
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
322
|
+
// `sandboxCommandOutput` parser drops the execCommand STRING body). exec exposes a
|
|
323
|
+
// structured stdout; execCommand returns the formatted STRING, so we strip its banner
|
|
324
|
+
// ("…Output:\n<body>") to recover the body.
|
|
325
|
+
//
|
|
326
|
+
// ROUTING-PROXY SEAM: when the selfhosted feature is on, the turn's box is wrapped in a
|
|
327
|
+
// `RoutingSandboxSession`, which ALWAYS exposes an `exec` method — but for a Modal-backed
|
|
328
|
+
// box (no native `exec`) that method internally falls back to `execCommand` and returns
|
|
329
|
+
// the formatted STRING, not a `{output}` object. `sandboxCommandOutput` returns "" for a
|
|
330
|
+
// string, so a naive `sandboxCommandOutput(await session.exec())` silently dropped the
|
|
331
|
+
// whole screenshot body → empty read → "display not up" error card on EVERY Modal
|
|
332
|
+
// computer-use turn once routing was enabled. So a STRING exec result is banner-stripped
|
|
333
|
+
// exactly like the direct execCommand path; only a structured object goes to
|
|
334
|
+
// sandboxCommandOutput.
|
|
335
|
+
private async readCmdRaw(cmd: string): Promise<string> {
|
|
300
336
|
const args = {
|
|
301
|
-
cmd
|
|
337
|
+
cmd,
|
|
302
338
|
...(this.runAs ? { runAs: this.runAs } : {}),
|
|
303
339
|
yieldTimeMs: ACTION_YIELD_MS,
|
|
304
|
-
// null disables the provider's
|
|
305
|
-
//
|
|
340
|
+
// null disables the provider's TOKEN truncation; the byte cap this method chunks
|
|
341
|
+
// around is a separate, lower-level exec-stdout buffer limit.
|
|
306
342
|
maxOutputTokens: null as unknown as number,
|
|
307
343
|
};
|
|
308
|
-
let raw: string;
|
|
309
344
|
if (typeof this.session.exec === "function") {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
345
|
+
const result = await this.session.exec(args);
|
|
346
|
+
// A routing proxy fronting a Modal box returns the execCommand banner STRING;
|
|
347
|
+
// a native structured exec returns an object. Handle both so neither silently
|
|
348
|
+
// yields an empty body.
|
|
349
|
+
return typeof result === "string" ? stripExecBanner(result) : sandboxCommandOutput(result);
|
|
350
|
+
}
|
|
351
|
+
if (typeof this.session.execCommand === "function") {
|
|
352
|
+
return stripExecBanner(await this.session.execCommand(args));
|
|
353
|
+
}
|
|
354
|
+
throw new ComputerUnavailableError("session cannot run commands (no exec/execCommand) — screenshots unavailable");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Read the screenshot PNG bytes off the box. A SINGLE `base64 <file>` over Modal's
|
|
358
|
+
// exec silently returns "" once the base64 exceeds the exec-stdout buffer cap (a full
|
|
359
|
+
// desktop ~296 KB base64 trips it — the blank-frame incident). So we size the file
|
|
360
|
+
// first, then base64 it in SCREENSHOT_READ_CHUNK_BYTES-sized, 3-byte-aligned chunks
|
|
361
|
+
// (each read's base64 stays well under the cap) and reconstruct — validating the total
|
|
362
|
+
// against the on-box size and failing LOUD on any short read rather than handing back a
|
|
363
|
+
// truncated/blank frame. Binary-safe: base64 is plain ASCII over stdout.
|
|
364
|
+
private async readScreenshotBytes(path: string): Promise<Uint8Array> {
|
|
365
|
+
// On-box byte size (tiny output — always safe). A still-warming :0 can yield a
|
|
366
|
+
// zero-byte scrot file; report empty so the caller retries within its warm-up budget.
|
|
367
|
+
const sizeRaw = (await this.readCmdRaw(`wc -c < ${path} 2>/dev/null`)).replace(/[^0-9]/g, "");
|
|
368
|
+
const fileSize = Number.parseInt(sizeRaw, 10);
|
|
369
|
+
if (!Number.isFinite(fileSize) || fileSize <= 0) return new Uint8Array();
|
|
370
|
+
|
|
371
|
+
const chunks: Buffer[] = [];
|
|
372
|
+
const nChunks = Math.ceil(fileSize / SCREENSHOT_READ_CHUNK_BYTES);
|
|
373
|
+
for (let i = 0; i < nChunks; i++) {
|
|
374
|
+
// `dd bs=CHUNK skip=i count=1` reads bytes [i*CHUNK, (i+1)*CHUNK); CHUNK % 3 == 0
|
|
375
|
+
// so each chunk's base64 is self-contained and decoded chunks concatenate exactly.
|
|
376
|
+
const raw = await this.readCmdRaw(
|
|
377
|
+
`dd if=${path} bs=${SCREENSHOT_READ_CHUNK_BYTES} skip=${i} count=1 2>/dev/null | base64`,
|
|
378
|
+
);
|
|
379
|
+
chunks.push(Buffer.from(raw.replace(/\s+/g, ""), "base64"));
|
|
380
|
+
}
|
|
381
|
+
const bytes = Buffer.concat(chunks);
|
|
382
|
+
if (bytes.length !== fileSize) {
|
|
383
|
+
throw new ScreenshotReadError(
|
|
384
|
+
`screenshot read reconstructed ${bytes.length}B of ${fileSize}B (${nChunks} chunk(s), path=${path}) — a chunk was truncated by the exec-output cap; frame incomplete`,
|
|
385
|
+
);
|
|
318
386
|
}
|
|
319
|
-
|
|
320
|
-
if (b64.length === 0) return new Uint8Array();
|
|
321
|
-
return Uint8Array.from(Buffer.from(b64, "base64"));
|
|
387
|
+
return new Uint8Array(bytes);
|
|
322
388
|
}
|
|
323
389
|
|
|
324
390
|
async click(xp: number, yp: number, button: ComputerButton) {
|
|
@@ -385,7 +451,11 @@ export class SandboxComputer implements Computer {
|
|
|
385
451
|
* leaf; the duck-typed `isNativeDesktopSession` probe (below) selects on it. */
|
|
386
452
|
export type NativeDesktopSession = {
|
|
387
453
|
desktopInput(event: DesktopInputRequest["event"]): Promise<void>;
|
|
388
|
-
|
|
454
|
+
// `nativeWidth`/`nativeHeight` are the PRE-DOWNSCALE capture geometry (equal to
|
|
455
|
+
// width/height when the agent did not have to shrink the PNG to fit the transport
|
|
456
|
+
// budget). NativeDesktopComputer scales model clicks from the ENCODED pixel space
|
|
457
|
+
// back to native pixels using their ratio before injecting.
|
|
458
|
+
screenshot(): Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
|
|
389
459
|
};
|
|
390
460
|
|
|
391
461
|
/** Model `Button` → wire `PointerButton`. The proto has no back/forward button, so
|
|
@@ -399,12 +469,35 @@ const POINTER_BUTTON: Record<ComputerButton, PointerButton> = {
|
|
|
399
469
|
forward: PointerButton.POINTER_BUTTON_UNSPECIFIED,
|
|
400
470
|
};
|
|
401
471
|
|
|
472
|
+
// Native capture (ScreenCaptureKit / x11) can hand back a null/empty FIRST frame in
|
|
473
|
+
// the moments right after the agent connects — the capture stream still warming — the
|
|
474
|
+
// same class of transient the scrot path retries around for a cold :0. Native capture
|
|
475
|
+
// is normally sub-second, so the warm-up budget is much shorter than the scrot path's
|
|
476
|
+
// 30 s cold-Modal-box budget; enough to ride a first-frame miss, not enough to hang a
|
|
477
|
+
// turn on a genuinely dead capture. A TERMINAL failure (permission denied) short-
|
|
478
|
+
// circuits the budget and fails immediately.
|
|
479
|
+
const NATIVE_SCREENSHOT_WARMUP_BUDGET_MS = 6_000;
|
|
480
|
+
const NATIVE_SCREENSHOT_RETRY_DELAY_MS = 400;
|
|
481
|
+
|
|
402
482
|
export type NativeDesktopComputerOptions = {
|
|
403
483
|
dimensions?: [number, number]; // the display geometry (must match the capture size)
|
|
404
484
|
environment?: NonNullable<Computer["environment"]>; // "ubuntu" (default) | "mac" | ...; model uses it for OS key conventions
|
|
405
485
|
readOnly?: boolean; // when true, every WRITE action throws ComputerReadOnlyError
|
|
486
|
+
screenshotWarmupBudgetMs?: number; // wall-clock budget to retry a warming/empty first frame
|
|
487
|
+
screenshotRetryDelayMs?: number; // delay between warm-up retries
|
|
406
488
|
};
|
|
407
489
|
|
|
490
|
+
/** A capture failure that RETRYING cannot cure — Screen Recording (TCC) has not been
|
|
491
|
+
* granted to the agent, so every capture will deny until the user grants it. We fail
|
|
492
|
+
* FAST on this rather than burn the warm-up budget, and surface the reason verbatim so
|
|
493
|
+
* the operator sees "grant Screen Recording", not a blank screen. Matches the agent's
|
|
494
|
+
* `capture_rgba` denial strings ("Screen Recording permission is not granted",
|
|
495
|
+
* "no shareable content (Screen Recording denied?)"). */
|
|
496
|
+
function isTerminalCaptureDenial(error: unknown): boolean {
|
|
497
|
+
const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
|
|
498
|
+
return msg.includes("screen recording") || msg.includes("permission is not granted") || msg.includes("consent");
|
|
499
|
+
}
|
|
500
|
+
|
|
408
501
|
/**
|
|
409
502
|
* A `Computer` that drives a SELF-HOSTED machine's OWN desktop NATIVELY over the
|
|
410
503
|
* control plane (`desktopInput` inject + `screenshot` capture on the bound
|
|
@@ -424,6 +517,17 @@ export class NativeDesktopComputer implements Computer {
|
|
|
424
517
|
readonly dimensions: [number, number];
|
|
425
518
|
private session: NativeDesktopSession;
|
|
426
519
|
private readonly readOnly: boolean;
|
|
520
|
+
private readonly screenshotWarmupBudgetMs: number;
|
|
521
|
+
private readonly screenshotRetryDelayMs: number;
|
|
522
|
+
// The ENCODED vs NATIVE geometry of the MOST RECENT screenshot the model saw. The
|
|
523
|
+
// model computes click coordinates in the encoded-pixel space of that screenshot;
|
|
524
|
+
// when the agent downscaled the PNG to fit the transport budget, encoded < native,
|
|
525
|
+
// so we scale coordinates back up to native pixels before injecting (the agent's
|
|
526
|
+
// native inject — macOS CGEvent / Linux XTEST — expects native-pixel coordinates,
|
|
527
|
+
// exactly as it received them pre-downscale). Null until the first screenshot;
|
|
528
|
+
// equal encoded==native (or absent) ⇒ scale factor 1.0 ⇒ byte-identical behavior.
|
|
529
|
+
private lastEncoded: [number, number] | null = null;
|
|
530
|
+
private lastNative: [number, number] | null = null;
|
|
427
531
|
|
|
428
532
|
constructor(session: NativeDesktopSession, opts: NativeDesktopComputerOptions = {}) {
|
|
429
533
|
this.session = session;
|
|
@@ -432,6 +536,8 @@ export class NativeDesktopComputer implements Computer {
|
|
|
432
536
|
// should pass "mac" so the model uses ⌘-based shortcuts — see the coordinate TODO.
|
|
433
537
|
this.environment = opts.environment ?? "ubuntu";
|
|
434
538
|
this.readOnly = opts.readOnly ?? false;
|
|
539
|
+
this.screenshotWarmupBudgetMs = opts.screenshotWarmupBudgetMs ?? NATIVE_SCREENSHOT_WARMUP_BUDGET_MS;
|
|
540
|
+
this.screenshotRetryDelayMs = opts.screenshotRetryDelayMs ?? NATIVE_SCREENSHOT_RETRY_DELAY_MS;
|
|
435
541
|
}
|
|
436
542
|
|
|
437
543
|
/** Rebind to a freshly resumed-by-id session after a box rollover / re-establish. */
|
|
@@ -441,31 +547,90 @@ export class NativeDesktopComputer implements Computer {
|
|
|
441
547
|
if (this.readOnly) throw new ComputerReadOnlyError();
|
|
442
548
|
}
|
|
443
549
|
|
|
550
|
+
/** Scale a coordinate the model expressed in the MOST RECENT screenshot's
|
|
551
|
+
* ENCODED pixel space back to NATIVE pixels. When the last frame was not
|
|
552
|
+
* downscaled (encoded == native), or no screenshot has been taken yet, this is a
|
|
553
|
+
* 1:1 identity — the byte-identical current behavior. The agent then applies its
|
|
554
|
+
* own platform mapping (macOS divides native pixels by the backing scale to reach
|
|
555
|
+
* CGEvent points; Linux XTEST is 1:1) exactly as it did pre-downscale. */
|
|
556
|
+
private toNative(x: number, y: number): { x: number; y: number } {
|
|
557
|
+
const enc = this.lastEncoded;
|
|
558
|
+
const nat = this.lastNative;
|
|
559
|
+
if (!enc || !nat || enc[0] <= 0 || enc[1] <= 0) return { x, y };
|
|
560
|
+
if (enc[0] === nat[0] && enc[1] === nat[1]) return { x, y };
|
|
561
|
+
return {
|
|
562
|
+
x: Math.round((x * nat[0]) / enc[0]),
|
|
563
|
+
y: Math.round((y * nat[1]) / enc[1]),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
444
567
|
private async pointer(x: number, y: number, action: PointerAction, button: PointerButton): Promise<void> {
|
|
445
|
-
// COORDINATE SEAM
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// any
|
|
453
|
-
|
|
454
|
-
await this.session.desktopInput({ $case: "pointer", pointer: { x, y, action, button } });
|
|
568
|
+
// COORDINATE SEAM: the model computes x/y against the pixels of the screenshot it
|
|
569
|
+
// just saw — which the agent may have DOWNSCALED to fit the transport's max
|
|
570
|
+
// payload (a full-res Retina/busy screen exceeds NATS's 1 MiB default). We scale
|
|
571
|
+
// those encoded-pixel coordinates back to native pixels here, using the native
|
|
572
|
+
// geometry the last screenshot reported, so the agent's native inject lands the
|
|
573
|
+
// click where the model intended. When no downscale occurred the factor is 1.0
|
|
574
|
+
// and the coordinates pass through unchanged. Self-hosted Linux (XTEST/x11) and
|
|
575
|
+
// any non-downscaled frame are 1:1 and unaffected.
|
|
576
|
+
const n = this.toNative(x, y);
|
|
577
|
+
await this.session.desktopInput({ $case: "pointer", pointer: { x: n.x, y: n.y, action, button } });
|
|
455
578
|
}
|
|
456
579
|
|
|
457
580
|
async screenshot(): Promise<string> {
|
|
458
581
|
// CRITICAL CONTRACT (mirrors SandboxComputer.screenshot): NEVER return "". The
|
|
459
582
|
// Agents SDK builds the model image as `data:image/png;base64,${output}`; an
|
|
460
|
-
// empty output → `image_url: ''
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
583
|
+
// empty output → `image_url: ''`. On the hosted computer_use_preview path the SDK
|
|
584
|
+
// catches a throw here, sets output='', and the wire-seam sanitizer rewrites the
|
|
585
|
+
// empty image_url to a BLANK 1×1 placeholder to dodge a 400 — so a capture MISS
|
|
586
|
+
// silently reaches the model as a "blank desktop" it then confidently reports.
|
|
587
|
+
//
|
|
588
|
+
// Native capture (ScreenCaptureKit / x11) can hand back a null/empty FIRST frame
|
|
589
|
+
// in the moments right after the agent connects (the capture stream warming) — the
|
|
590
|
+
// same transient the scrot path retries around for a cold :0. This path previously
|
|
591
|
+
// did a SINGLE capture, so one warm-up miss became a blank. We now RETRY across a
|
|
592
|
+
// bounded budget and, on a terminal failure, FAIL LOUD with the agent's actual
|
|
593
|
+
// reason (logged) — never a silent blank. A permission (TCC) denial is terminal:
|
|
594
|
+
// retrying cannot grant Screen Recording, so we break immediately.
|
|
595
|
+
let lastError: unknown;
|
|
596
|
+
const deadline = Date.now() + this.screenshotWarmupBudgetMs;
|
|
597
|
+
let attempt = 0;
|
|
598
|
+
while (true) {
|
|
599
|
+
if (attempt > 0) {
|
|
600
|
+
await new Promise((r) => setTimeout(r, this.screenshotRetryDelayMs));
|
|
601
|
+
}
|
|
602
|
+
attempt++;
|
|
603
|
+
try {
|
|
604
|
+
const { png, width, height, nativeWidth, nativeHeight } = await this.session.screenshot();
|
|
605
|
+
if (png.length === 0) {
|
|
606
|
+
// A warming capture yields an empty frame — retry rather than hand the model
|
|
607
|
+
// a blank; throw once the budget is spent.
|
|
608
|
+
throw new ComputerUnavailableError("native desktop screenshot returned an empty frame (display not up?)");
|
|
609
|
+
}
|
|
610
|
+
// Record the encoded (what the model sees) vs native geometry of THIS frame so
|
|
611
|
+
// the next click/move/scroll/drag scales its coordinates back to native pixels.
|
|
612
|
+
this.lastEncoded = [width, height];
|
|
613
|
+
this.lastNative = [nativeWidth || width, nativeHeight || height];
|
|
614
|
+
return Buffer.from(png).toString("base64");
|
|
615
|
+
} catch (error) {
|
|
616
|
+
lastError = error;
|
|
617
|
+
// A permission denial will deny on every retry — fail fast with the reason.
|
|
618
|
+
if (isTerminalCaptureDenial(error)) break;
|
|
619
|
+
}
|
|
620
|
+
// Stop once the warm-up budget is spent — the NEXT sleep would push us past it.
|
|
621
|
+
if (Date.now() + this.screenshotRetryDelayMs >= deadline) {
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Exhausted the budget (or hit a terminal denial): FAIL LOUD. Log the specific
|
|
626
|
+
// reason so the failure is DIAGNOSABLE (not a silent blank the model misreads),
|
|
627
|
+
// then rethrow — never return "".
|
|
628
|
+
const reason = lastError instanceof Error ? lastError.message : String(lastError);
|
|
629
|
+
console.warn(`[NativeDesktopComputer] screenshot failed after ${attempt} attempt(s): ${reason}`);
|
|
630
|
+
if (lastError instanceof Error) {
|
|
631
|
+
throw lastError;
|
|
467
632
|
}
|
|
468
|
-
|
|
633
|
+
throw new ComputerUnavailableError("native desktop screenshot returned an empty frame (display not up?)");
|
|
469
634
|
}
|
|
470
635
|
|
|
471
636
|
async click(x: number, y: number, button: ComputerButton) {
|
|
@@ -482,11 +647,12 @@ export class NativeDesktopComputer implements Computer {
|
|
|
482
647
|
}
|
|
483
648
|
async scroll(x: number, y: number, sx: number, sy: number) {
|
|
484
649
|
this.guardWrite();
|
|
485
|
-
// The
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
// xdotool
|
|
489
|
-
|
|
650
|
+
// The scroll ANCHOR (x,y) is a screenshot-pixel position → scale to native like a
|
|
651
|
+
// click. The deltas (sx,sy) are relative scroll AMOUNTS, not positions; the agent
|
|
652
|
+
// owns the platform-appropriate wheel translation, so they pass through unscaled
|
|
653
|
+
// (no xdotool "notch" quantization here — that is an xdotool-specific artifact).
|
|
654
|
+
const n = this.toNative(x, y);
|
|
655
|
+
await this.session.desktopInput({ $case: "scroll", scroll: { x: n.x, y: n.y, deltaX: sx, deltaY: sy } });
|
|
490
656
|
}
|
|
491
657
|
async type(text: string) {
|
|
492
658
|
this.guardWrite();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The legible "screen capture failed" error card the history-sanitizer wire seam
|
|
2
|
+
// substitutes for an EMPTY `computer_call_output` image_url (see
|
|
3
|
+
// `rewriteEmptyComputerCallOutputImageUrls`). The hosted `computer_use_preview`
|
|
4
|
+
// protocol has only an IMAGE channel back to the model, so a capture failure has to
|
|
5
|
+
// be a legible IMAGE — otherwise the SDK's empty-output placeholder (a 1×1
|
|
6
|
+
// transparent PNG) reaches the model as a plausible-but-wrong BLANK DESKTOP it then
|
|
7
|
+
// confidently reports (the exact failure mode of the 0.1.3 TCC-denied incident).
|
|
8
|
+
//
|
|
9
|
+
// This is a valid 8-bit RGBA PNG (verified: signature + per-chunk CRC + IDAT inflates
|
|
10
|
+
// to the exact scanline size) rendered from a fixed message, so the provider's image
|
|
11
|
+
// decoder accepts it (an invalid/empty image_url is what 400s the turn). The specific
|
|
12
|
+
// failure REASON (permission denied / null image / timeout / display down) is not in
|
|
13
|
+
// the card — it is logged worker-side by `NativeDesktopComputer.screenshot()`; the
|
|
14
|
+
// card's job is only to stop the model from hallucinating a real blank desktop.
|
|
15
|
+
//
|
|
16
|
+
// Regenerate with: `bun run scripts/gen-screenshot-error-card.mjs`
|
|
17
|
+
// Rendered text:
|
|
18
|
+
// SCREEN CAPTURE FAILED
|
|
19
|
+
// THE DESKTOP COULD NOT BE CAPTURED.
|
|
20
|
+
// THIS IS A PLACEHOLDER, NOT THE REAL SCREEN.
|
|
21
|
+
// DO NOT SAY THE SCREEN IS BLANK.
|
|
22
|
+
// TELL THE USER TO CHECK THE DISPLAY AND GRANT
|
|
23
|
+
// SCREEN RECORDING PERMISSION.
|
|
24
|
+
export const SCREENSHOT_FAILURE_CARD_IMAGE_URL =
|
|
25
|
+
"data:image/png;base64,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";
|