@opengeni/runtime 0.3.0 → 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.
@@ -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
- exec?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<ExecResultLike>;
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
- // Read the screenshot PNG bytes by base64-ing the absolute /tmp path through the
292
- // SAME command primitive (exec ?? execCommand) — NOT `session.readFile` (Modal
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, returning ""
295
- // only the exec-object path has a structured body). We capture the RAW result,
296
- // strip the execCommand banner ("…Output:\n<base64>"), strip whitespace, and
297
- // decode. Binary-safe: base64 of the scrot is plain ASCII over stdout, no
298
- // truncation (maxOutputTokens:null), mirroring recording.ts readRecordingBytes.
299
- private async readScreenshotBytes(path: string): Promise<Uint8Array> {
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: `DISPLAY=${this.display} base64 ${path}`,
337
+ cmd,
302
338
  ...(this.runAs ? { runAs: this.runAs } : {}),
303
339
  yieldTimeMs: ACTION_YIELD_MS,
304
- // null disables the provider's output truncation so a full-screen PNG's
305
- // base64 is never clipped (the SDK's truncateOutput passes through on null).
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
- // The exec-object path exposes a structured stdout/output body.
311
- raw = sandboxCommandOutput(await this.session.exec(args));
312
- } else if (typeof this.session.execCommand === "function") {
313
- // execCommand returns the formatted STRING — strip the banner to recover the
314
- // base64 body (sandboxCommandOutput would drop it for the string form).
315
- raw = stripExecBanner(await this.session.execCommand(args));
316
- } else {
317
- throw new ComputerUnavailableError("session cannot run commands (no exec/execCommand) — screenshots unavailable");
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"));
318
380
  }
319
- const b64 = raw.replace(/\s+/g, "");
320
- if (b64.length === 0) return new Uint8Array();
321
- return Uint8Array.from(Buffer.from(b64, "base64"));
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
+ );
386
+ }
387
+ return new Uint8Array(bytes);
322
388
  }
323
389
 
324
390
  async click(xp: number, yp: number, button: ComputerButton) {
@@ -403,12 +469,35 @@ const POINTER_BUTTON: Record<ComputerButton, PointerButton> = {
403
469
  forward: PointerButton.POINTER_BUTTON_UNSPECIFIED,
404
470
  };
405
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
+
406
482
  export type NativeDesktopComputerOptions = {
407
483
  dimensions?: [number, number]; // the display geometry (must match the capture size)
408
484
  environment?: NonNullable<Computer["environment"]>; // "ubuntu" (default) | "mac" | ...; model uses it for OS key conventions
409
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
410
488
  };
411
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
+
412
501
  /**
413
502
  * A `Computer` that drives a SELF-HOSTED machine's OWN desktop NATIVELY over the
414
503
  * control plane (`desktopInput` inject + `screenshot` capture on the bound
@@ -428,6 +517,8 @@ export class NativeDesktopComputer implements Computer {
428
517
  readonly dimensions: [number, number];
429
518
  private session: NativeDesktopSession;
430
519
  private readonly readOnly: boolean;
520
+ private readonly screenshotWarmupBudgetMs: number;
521
+ private readonly screenshotRetryDelayMs: number;
431
522
  // The ENCODED vs NATIVE geometry of the MOST RECENT screenshot the model saw. The
432
523
  // model computes click coordinates in the encoded-pixel space of that screenshot;
433
524
  // when the agent downscaled the PNG to fit the transport budget, encoded < native,
@@ -445,6 +536,8 @@ export class NativeDesktopComputer implements Computer {
445
536
  // should pass "mac" so the model uses ⌘-based shortcuts — see the coordinate TODO.
446
537
  this.environment = opts.environment ?? "ubuntu";
447
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;
448
541
  }
449
542
 
450
543
  /** Rebind to a freshly resumed-by-id session after a box rollover / re-establish. */
@@ -487,19 +580,57 @@ export class NativeDesktopComputer implements Computer {
487
580
  async screenshot(): Promise<string> {
488
581
  // CRITICAL CONTRACT (mirrors SandboxComputer.screenshot): NEVER return "". The
489
582
  // Agents SDK builds the model image as `data:image/png;base64,${output}`; an
490
- // empty output → `image_url: ''` the model API 400s and kills the turn. A
491
- // missing/empty frame is therefore a THROW, never a silent "". Native capture
492
- // (ScreenCaptureKit / x11) does not have the cold-scrot warm-up the xdotool path
493
- // retries around, so a single capture + a hard empty-guard is sufficient.
494
- const { png, width, height, nativeWidth, nativeHeight } = await this.session.screenshot();
495
- if (png.length === 0) {
496
- throw new ComputerUnavailableError("native desktop screenshot returned an empty frame (display not up?)");
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;
497
632
  }
498
- // Record the encoded (what the model sees) vs native geometry of THIS frame so
499
- // the next click/move/scroll/drag scales its coordinates back to native pixels.
500
- this.lastEncoded = [width, height];
501
- this.lastNative = [nativeWidth || width, nativeHeight || height];
502
- return Buffer.from(png).toString("base64");
633
+ throw new ComputerUnavailableError("native desktop screenshot returned an empty frame (display not up?)");
503
634
  }
504
635
 
505
636
  async click(x: number, y: number, button: ComputerButton) {
@@ -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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";