humanish 0.136.1 → 0.138.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/observation.js +2 -0
  5. package/dist/actors/computer-use/loop/session.d.ts +3 -2
  6. package/dist/actors/computer-use/loop/session.js +8 -12
  7. package/dist/actors/computer-use/loop/types.d.ts +14 -1
  8. package/dist/cli/commands/runs.js +2 -2
  9. package/dist/cli/doctor.js +24 -0
  10. package/dist/evidence/browser-network.d.ts +108 -0
  11. package/dist/evidence/browser-network.js +516 -0
  12. package/dist/evidence/trace-page.d.ts +31 -1
  13. package/dist/evidence/trace-page.js +68 -2
  14. package/dist/keys/key-resolution.js +53 -2
  15. package/dist/observer-app.html +2 -2
  16. package/dist/routes/computer-use/bundle.js +1 -0
  17. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  18. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  19. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  20. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  21. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  22. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  23. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  24. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  25. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  26. package/dist/routes/computer-use/fanout-records.js +10 -0
  27. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  28. package/dist/routes/computer-use/participant-model.js +2 -1
  29. package/dist/routes/computer-use/plan.js +2 -1
  30. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  31. package/dist/routes/computer-use/single-bundle.js +4 -0
  32. package/dist/routes/computer-use/types.d.ts +7 -0
  33. package/dist/routes/scripted/plan.js +2 -2
  34. package/dist/routes/shared-world/plan.js +3 -2
  35. package/dist/routes/terminal/live-sandbox.d.ts +2 -0
  36. package/dist/routes/terminal/live-sandbox.js +8 -3
  37. package/dist/routes/terminal/plan.js +51 -8
  38. package/dist/routes/terminal/session.js +8 -1
  39. package/dist/run/bundle.d.ts +2 -1
  40. package/dist/run/cost-summary.js +9 -4
  41. package/dist/run/live-items.d.ts +20 -2
  42. package/dist/run/live-items.js +23 -14
  43. package/dist/run/pricing.d.ts +11 -0
  44. package/dist/run/pricing.js +18 -5
  45. package/dist/run/reclaim-outcomes.d.ts +4 -1
  46. package/dist/run/reclaim-outcomes.js +8 -6
  47. package/dist/run/reclaim.d.ts +4 -1
  48. package/dist/run/reclaim.js +81 -34
  49. package/dist/run/sandbox-creates.d.ts +7 -4
  50. package/dist/run/sandbox-creates.js +17 -9
  51. package/dist/run/sandbox-receipts.d.ts +13 -5
  52. package/dist/run/sandbox-receipts.js +18 -6
  53. package/dist/run/streams.d.ts +7 -0
  54. package/dist/study/composition-rules.js +11 -10
  55. package/dist/study/config.js +4 -1
  56. package/dist/study/doctor.js +1 -1
  57. package/dist/study/keys.d.ts +3 -0
  58. package/dist/study/keys.js +2 -0
  59. package/dist/study/parse/comms.js +1 -20
  60. package/dist/study/parse/execution.js +35 -3
  61. package/dist/study/parse/values.d.ts +5 -0
  62. package/dist/study/parse/values.js +22 -0
  63. package/dist/study/plan-base.js +6 -1
  64. package/dist/study/plan-types.d.ts +5 -2
  65. package/dist/study/requirements.js +7 -2
  66. package/dist/study/routing.d.ts +1 -1
  67. package/dist/study/routing.js +1 -1
  68. package/dist/study/study-fields.d.ts +7 -1
  69. package/dist/study/study-fields.js +10 -0
  70. package/dist/study/types.d.ts +12 -1
  71. package/dist/study/validation.d.ts +8 -0
  72. package/dist/study/validation.js +31 -2
  73. package/dist/substrates/detached.d.ts +5 -0
  74. package/dist/substrates/detached.js +9 -0
  75. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  76. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  77. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  78. package/dist/substrates/e2b/cdp-probe.js +98 -34
  79. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  80. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  81. package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
  82. package/dist/substrates/e2b/desktop-resources.js +2 -2
  83. package/dist/substrates/e2b/peer-install.js +1 -1
  84. package/dist/substrates/e2b/sandbox.d.ts +3 -0
  85. package/dist/substrates/e2b/sandbox.js +5 -4
  86. package/dist/substrates/modal/image.d.ts +9 -0
  87. package/dist/substrates/modal/image.js +36 -0
  88. package/dist/substrates/modal/sandbox.d.ts +26 -0
  89. package/dist/substrates/modal/sandbox.js +367 -0
  90. package/dist/substrates/modal/sdk.d.ts +112 -0
  91. package/dist/substrates/modal/sdk.js +61 -0
  92. package/dist/verify/network.d.ts +10 -0
  93. package/dist/verify/network.js +162 -0
  94. package/dist/verify/pages.js +1 -16
  95. package/dist/verify/verify.js +8 -2
  96. package/docs/architecture/terminal-product-route.md +1 -1
  97. package/docs/contracts/run-bundle.md +90 -3
  98. package/docs/contracts/schemas.md +6 -5
  99. package/package.json +8 -2
@@ -15,6 +15,7 @@
15
15
  // of swallowing an exit code; the TypeScript side turns that into a participant warning that names the
16
16
  // consequence ("url/text criteria will read as never measured").
17
17
  import { shellQuote } from "../shell.js";
18
+ import { NETWORK_CAPTURE_PY } from "./cdp-network-capture.js";
18
19
  /**
19
20
  * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
20
21
  * (tests/substrates/e2b/cdp-probe.test.ts runs it under the real python3 against a real
@@ -272,17 +273,20 @@ def browser_ws_url(port):
272
273
  info = json.loads(response.read().decode("utf-8"))
273
274
  return str(info.get("webSocketDebuggerUrl") or "")
274
275
 
275
- def hold(port, page, request):
276
- """Emulate the launch page and then every page target Chrome opens later, for as long as this
277
- process lives. One browser-level socket with flattened sessions: the launch page is
278
- attached by id and reloaded so scripts that read the UA at load see it; a target that appears
279
- later is attached the moment it exists, sent the same overrides, and reloaded once after its
280
- first real navigation commits (touch emulation reaches a document only when it loads under it).
276
+ ${NETWORK_CAPTURE_PY}
277
+ def hold(port, page, request, network):
278
+ """Hold the launch page and every page target Chrome opens later, for as long as this process
279
+ lives, over one browser-level socket with flattened sessions. With an emulation request, the
280
+ launch page is attached by id and reloaded so scripts that read the UA at load see it; a target
281
+ that appears later is attached the moment it exists, sent the same overrides, and reloaded once
282
+ after its first real navigation commits (touch emulation reaches a document only when it loads
283
+ under it). Without one, nothing is emulated and nothing reloads. With a network request, every
284
+ held page also reports its traffic to the events file (NETWORK_CAPTURE_PY).
281
285
  Two things this loop must never do (measured on a real desktop, 2026-09-04): pause new targets
282
286
  (waitForDebuggerOnStart) and wait for their replies. A popup a participant taps open shares its
283
287
  opener's renderer, answers Emulation commands only once it runs, and a loop blocked on that
284
- reply never resumed the tab (it loaded forever) and never reached the next one. Overrides are
285
- sent without waiting; replies, errors included, are logged as they arrive.
288
+ reply never resumed the tab (it loaded forever) and never reached the next one. Commands to a
289
+ later target are sent without waiting; replies, errors included, are logged as they arrive.
286
290
  Prints one JSON line per attached target; the first line is the announce the lane reads."""
287
291
  launch_id = str(page.get("id") or "")
288
292
  try:
@@ -295,15 +299,36 @@ def hold(port, page, request):
295
299
  if not session_id:
296
300
  print(json.dumps({"unavailable": "Target.attachToTarget failed for the launch page"}), flush=True)
297
301
  return
298
- covered = {launch_id}
299
- applied, failure = apply_over(ws, request, session_id, reload=True)
300
- print(json.dumps({"applied": applied, "held": failure is None, "targetId": launch_id, **({"unavailable": failure} if failure else {})}), flush=True)
301
- if failure:
302
- return
302
+ log, capture = start_capture(ws, session_id, network) if network is not None else (None, None)
303
+ reason = "the holder stopped"
304
+ try:
305
+ applied, failure = apply_over(ws, request, session_id, reload=True) if request else ([], None)
306
+ held = failure is None and (bool(request) or log is not None)
307
+ announce = {"applied": applied, "held": held, "targetId": launch_id}
308
+ if capture is not None:
309
+ announce["network"] = capture
310
+ if failure:
311
+ announce["unavailable"] = failure
312
+ print(json.dumps(announce), flush=True)
313
+ if not held:
314
+ reason = failure or "nothing to hold"
315
+ return
316
+ reason = follow_targets(ws, launch_id, request, log)
317
+ except Exception as error:
318
+ reason = "%s: %s" % (type(error).__name__, error)
319
+ raise
320
+ finally:
321
+ if log is not None:
322
+ log.close(reason)
323
+
324
+ def follow_targets(ws, launch_id, request, log):
325
+ """The holder's loop: attach every later page target, log replies and hand each event to the
326
+ capture log. Returns why it ended."""
303
327
  auto = ws.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
304
328
  if not isinstance(auto, dict) or "error" in auto:
305
- print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; later tabs are not emulated"}), flush=True)
329
+ print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; the holder does not reach later tabs"}), flush=True)
306
330
  ws.sock.settimeout(None)
331
+ covered = {launch_id}
307
332
  pending = {}
308
333
  # Sessions of later targets that still owe one reload: touch emulation reaches a document only
309
334
  # when it loads under the override (measured: a popup's first document reported
@@ -314,11 +339,12 @@ def hold(port, page, request):
314
339
  try:
315
340
  message = ws.events.pop(0) if ws.events else ws.recv()
316
341
  except Exception as error:
317
- print(json.dumps({"holderExit": "%s: %s" % (type(error).__name__, error)}), flush=True)
318
- return
342
+ reason = "%s: %s" % (type(error).__name__, error)
343
+ print(json.dumps({"holderExit": reason}), flush=True)
344
+ return reason
319
345
  if message is None:
320
346
  print(json.dumps({"holderExit": "browser socket closed"}), flush=True)
321
- return
347
+ return "browser socket closed"
322
348
  reply_id = message.get("id")
323
349
  session = message.get("sessionId")
324
350
  if reply_id in pending:
@@ -336,6 +362,8 @@ def hold(port, page, request):
336
362
  continue
337
363
  method = message.get("method")
338
364
  params = message.get("params") or {}
365
+ if log is not None and method:
366
+ log.event(method, params, session)
339
367
  if method == "Page.frameNavigated" and session in reload_owed:
340
368
  frame = params.get("frame") or {}
341
369
  url = str(frame.get("url") or "")
@@ -351,35 +379,50 @@ def hold(port, page, request):
351
379
  target_id = str(info.get("targetId") or "")
352
380
  if info.get("type") == "page" and target_id and target_id not in covered:
353
381
  covered.add(target_id)
354
- messages = emulation_messages(request, reload=False)
382
+ messages = emulation_messages(request, reload=False) if request else []
355
383
  for m in messages:
356
384
  pending[ws.send(m["method"], m.get("params"), new_session)] = m["method"]
385
+ sent = [m["method"] for m in messages]
386
+ # After the overrides, so a later tab's emulation timing is what it was without capture.
387
+ if log is not None:
388
+ log.session(new_session)
389
+ pending[ws.send("Network.enable", NETWORK_ENABLE, new_session)] = "Network.enable"
390
+ sent.append("Network.enable")
357
391
  pending[ws.send("Page.enable", {}, new_session)] = "Page.enable"
358
392
  url = str(info.get("url") or "")
359
- if url and not url.startswith("about:"):
393
+ if not request:
394
+ print(json.dumps({"attached": target_id, "sent": sent}), flush=True)
395
+ elif url and not url.startswith("about:"):
360
396
  pending[ws.send("Page.reload", {}, new_session)] = "Page.reload"
361
- print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages] + ["Page.reload"]}), flush=True)
397
+ print(json.dumps({"attached": target_id, "sent": sent + ["Page.reload"]}), flush=True)
362
398
  else:
363
399
  reload_owed[new_session] = target_id
364
400
  # Two ways to learn the first real navigation committed, whichever answers first:
365
401
  # Page.frameNavigated (if Page.enable landed before the commit) or the page's own
366
402
  # location.href (if it did not).
367
403
  pending[ws.send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True}, new_session)] = "href"
368
- print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages], "reloadAfterNavigation": True}), flush=True)
404
+ print(json.dumps({"attached": target_id, "sent": sent, "reloadAfterNavigation": True}), flush=True)
369
405
  if params.get("waitingForDebugger"):
370
406
  ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
371
407
 
408
+ def emit(answer):
409
+ """Print one answer line stamped with this sandbox's clock. CDP wallTime uses the same clock, so
410
+ a turn mark taken from it compares with request times without host skew, and the reading needs
411
+ nothing from the page, so a page too busy to answer still leaves a mark."""
412
+ answer["now"] = time.time()
413
+ print(json.dumps(answer))
414
+
372
415
  def main():
373
416
  args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
374
417
  mode = args.get("mode") or "state"
375
418
  port = resolve_port(args)
376
419
  if mode == "port":
377
- print(json.dumps({"cdpPort": port}))
420
+ emit({"cdpPort": port})
378
421
  return
379
422
  try:
380
423
  pages = list_pages(port)
381
424
  except Exception as error:
382
- print(json.dumps({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)}))
425
+ emit({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)})
383
426
  return
384
427
  page = select_page(pages, args)
385
428
  # A listing with targets but no http page is usually an instant: a reload in flight, a tab
@@ -396,7 +439,7 @@ def main():
396
439
  break
397
440
  page = select_page(pages, args)
398
441
  if page is None:
399
- print(json.dumps({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)}))
442
+ emit({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)})
400
443
  return
401
444
  # Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
402
445
  # page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
@@ -406,29 +449,29 @@ def main():
406
449
  if mode == "emulate":
407
450
  applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
408
451
  if failure is not None:
409
- print(json.dumps({"unavailable": failure, "applied": applied or []}))
452
+ emit({"unavailable": failure, "applied": applied or []})
410
453
  return
411
- print(json.dumps({"applied": applied, "targetId": str(page.get("id") or "")}))
454
+ emit({"applied": applied, "targetId": str(page.get("id") or "")})
412
455
  return
413
456
  if mode == "hold":
414
457
  # Prints its announce line, then stays attached (and attaches to every later page target)
415
458
  # until killed.
416
- hold(port, page, args.get("emulation") or {})
459
+ hold(port, page, args.get("emulation") or None, args.get("network"))
417
460
  return
418
461
  if mode == "fidelity":
419
462
  result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
420
463
  if not isinstance(result, dict):
421
- print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
464
+ emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
422
465
  return
423
- print(json.dumps({"fidelity": result, "targetId": str(page.get("id") or "")}))
466
+ emit({"fidelity": result, "targetId": str(page.get("id") or "")})
424
467
  return
425
468
  if mode == "geometry":
426
469
  result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
427
470
  if not isinstance(result, dict):
428
- print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
471
+ emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
429
472
  return
430
473
  result["targetId"] = str(page.get("id") or "")
431
- print(json.dumps(result))
474
+ emit(result)
432
475
  return
433
476
  url = str(page.get("url") or "")
434
477
  title = str(page.get("title") or "")
@@ -440,7 +483,7 @@ def main():
440
483
  title = result["title"] if isinstance(result.get("title"), str) else title
441
484
  text = result["text"] if isinstance(result.get("text"), str) else ""
442
485
  scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
443
- print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")}))
486
+ emit({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")})
444
487
 
445
488
  main()
446
489
  `;
@@ -457,6 +500,8 @@ export function chromeCdpProbeCommand(args) {
457
500
  payload.prefer = args.prefer;
458
501
  if (args.emulation !== undefined)
459
502
  payload.emulation = args.emulation;
503
+ if (args.network !== undefined)
504
+ payload.network = args.network;
460
505
  return `python3 -c ${shellQuote(CHROME_CDP_PROBE_PY)} ${shellQuote(JSON.stringify(payload))}`;
461
506
  }
462
507
  /**
@@ -481,10 +526,17 @@ export function parseChromeCdpProbeOutput(stdout) {
481
526
  const applied = Array.isArray(record.applied)
482
527
  ? record.applied.filter((item) => typeof item === "string")
483
528
  : undefined;
529
+ const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
530
+ const now = numberOr(record.now);
531
+ const network = networkOf(record.network);
484
532
  if (typeof record.unavailable === "string") {
485
- return { unavailable: record.unavailable, ...(applied === undefined ? {} : { applied }) };
533
+ return {
534
+ unavailable: record.unavailable,
535
+ ...(applied === undefined ? {} : { applied }),
536
+ ...(network === undefined ? {} : { network }),
537
+ ...(now === undefined ? {} : { now }),
538
+ };
486
539
  }
487
- const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
488
540
  const box = (value, keys) => {
489
541
  if (!value || typeof value !== "object")
490
542
  return undefined;
@@ -549,5 +601,17 @@ export function parseChromeCdpProbeOutput(stdout) {
549
601
  : {}),
550
602
  ...(browserWindow === undefined ? {} : { browserWindow }),
551
603
  ...(viewport === undefined ? {} : { viewport }),
604
+ ...(network === undefined ? {} : { network }),
605
+ ...(now === undefined ? {} : { now }),
552
606
  };
553
607
  }
608
+ function networkOf(value) {
609
+ if (!value || typeof value !== "object")
610
+ return undefined;
611
+ const source = value;
612
+ if (typeof source.startedAt === "number" && Number.isFinite(source.startedAt))
613
+ return { startedAt: source.startedAt };
614
+ if (typeof source.unavailable === "string")
615
+ return { unavailable: source.unavailable };
616
+ return undefined;
617
+ }
@@ -1,4 +1,5 @@
1
- import { type ChromeMobileEmulationRequest } from "./cdp-probe.js";
1
+ import type { ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
2
+ import { type ChromeCdpProbeResult, type ChromeMobileEmulationRequest } from "./cdp-probe.js";
2
3
  import type { RunDesktopGeometry } from "../../run/streams.js";
3
4
  import type { E2BDesktopSandbox } from "./sdk.js";
4
5
  /**
@@ -53,6 +54,12 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
53
54
  /** Called once, with the reason, on the first probe that could not read the page. */
54
55
  onUnavailable?: (reason: string) => void;
55
56
  drift?: ChromeEmulationDrift | undefined;
57
+ /**
58
+ * Called with undefined when a probe starts, then with the sandbox clock the probe printed
59
+ * (ChromeCdpProbeResult.now), so the caller holds the time of the latest read and never a
60
+ * stale one from a probe that failed.
61
+ */
62
+ onClock?: (now: number | undefined) => void;
56
63
  }): () => Promise<{
57
64
  url?: string;
58
65
  title?: string;
@@ -65,13 +72,36 @@ export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhon
65
72
  * Apply mobile emulation to the participant's launch page and read back what the page reports.
66
73
  * Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
67
74
  * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
68
- * (the emulation was applied; only the proof is missing).
75
+ * (the emulation was applied; only the proof is missing). With `network`, the same holder also
76
+ * captures requests, and a capture that could not start is reported in `network` without failing
77
+ * the emulation.
69
78
  */
70
79
  export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, request: ChromeMobileEmulationRequest, options?: {
71
80
  targetId?: string | undefined;
81
+ network?: ChromeNetworkCaptureRequest | undefined;
72
82
  }): Promise<{
73
83
  fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
74
84
  warnings: string[];
75
85
  targetId?: string;
76
86
  holderName: string;
87
+ /** The holder's capture status, when `network` was passed and the holder said. */
88
+ network?: ChromeCdpProbeResult["network"];
77
89
  }>;
90
+ /**
91
+ * Start a holder that records the launch page's requests, and every later tab's, to
92
+ * `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
93
+ * the request to the holder it starts. Sends no Emulation command and no reload. Returns the
94
+ * holder's process name, which readNetworkCaptureStart reads.
95
+ */
96
+ export declare function startNetworkCapture(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, network: ChromeNetworkCaptureRequest): Promise<string>;
97
+ /**
98
+ * What the capture holder announced: when capture started on the launch page, on the sandbox
99
+ * clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
100
+ * `desktop.wait`, and reports a holder that printed nothing as unavailable.
101
+ */
102
+ export declare function readNetworkCaptureStart(desktop: E2BDesktopSandbox, requestTimeoutMs: number, holderName: string): Promise<NonNullable<ChromeCdpProbeResult["network"]>>;
103
+ /**
104
+ * The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
105
+ * Throws when the sandbox cannot stream files or the file cannot be read.
106
+ */
107
+ export declare function readNetworkEvents(desktop: E2BDesktopSandbox, eventsPath: string, requestTimeoutMs: number): Promise<string>;
@@ -1,5 +1,3 @@
1
- // Chromium DevTools on a hosted E2B desktop, reached from inside the sandbox: browser state reads
2
- // and mobile emulation.
3
1
  import { chromeCdpProbeCommand, parseChromeCdpProbeOutput, } from "./cdp-probe.js";
4
2
  import { failureTail, toErrorMessage } from "../../evidence/redaction.js";
5
3
  import { readDetachedLog, startDetachedProcess } from "../detached.js";
@@ -18,7 +16,7 @@ import { e2bShell } from "./shell.js";
18
16
  * url/text criteria are not being measured, instead of letting the funnel report 0/N.
19
17
  */
20
18
  export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, options = {}) {
21
- const { targetId, onUnavailable, drift } = options;
19
+ const { targetId, onUnavailable, drift, onClock } = options;
22
20
  let reported = false;
23
21
  let drifted = false;
24
22
  const checkedTargets = new Set(drift === undefined ? [] : [drift.emulatedTargetId]);
@@ -68,6 +66,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
68
66
  : `the participant drove a page target other than the emulated launch tab and that page reports a ${fidelity.innerWidth} px viewport where ${drift.expectedWidth} px was requested (DPR ${fidelity.devicePixelRatio}); the mobile user agent and touch events are browser-wide, the viewport override was not re-applied to it`);
69
67
  };
70
68
  return async () => {
69
+ onClock?.(undefined);
71
70
  let result;
72
71
  try {
73
72
  result = await shell.run(chromeCdpProbeCommand({
@@ -85,6 +84,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
85
84
  return unavailable(`probe exited ${result.exitCode}: ${failureTail(result.stderr || result.stdout)}`);
86
85
  }
87
86
  const parsed = parseChromeCdpProbeOutput(result.stdout);
87
+ onClock?.(parsed.now);
88
88
  if (parsed.unavailable !== undefined)
89
89
  return unavailable(parsed.unavailable);
90
90
  if (parsed.targetId !== undefined)
@@ -103,16 +103,19 @@ export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_
103
103
  * Apply mobile emulation to the participant's launch page and read back what the page reports.
104
104
  * Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
105
105
  * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
106
- * (the emulation was applied; only the proof is missing).
106
+ * (the emulation was applied; only the proof is missing). With `network`, the same holder also
107
+ * captures requests, and a capture that could not start is reported in `network` without failing
108
+ * the emulation.
107
109
  */
108
110
  export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint, request, options = {}) {
109
- const { targetId } = options;
111
+ const { targetId, network } = options;
110
112
  const command = (mode) => chromeCdpProbeCommand({
111
113
  ...endpoint,
112
114
  ...(targetId === undefined ? {} : { targetId }),
113
115
  prefer: "pinned",
114
116
  mode,
115
117
  emulation: request,
118
+ ...(mode === "hold" && network !== undefined ? { network } : {}),
116
119
  });
117
120
  const shell = e2bShell(desktop);
118
121
  const read = async () => {
@@ -178,6 +181,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
178
181
  userAgent: request.userAgent,
179
182
  };
180
183
  const emulatedTargetId = applied.targetId ?? fidelityRead.targetId;
184
+ const capture = applied.network === undefined ? {} : { network: applied.network };
181
185
  if (fidelityRead.fidelity === undefined) {
182
186
  warnings.push(`Mobile emulation was applied but the page's own report could not be read (${fidelityRead.unavailable ?? "no fidelity read"}); desktopGeometry.fidelity carries the request without a resolved block.`);
183
187
  return {
@@ -185,6 +189,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
185
189
  warnings,
186
190
  holderName,
187
191
  ...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
192
+ ...capture,
188
193
  };
189
194
  }
190
195
  const resolved = { ...fidelityRead.fidelity, source: "cdp" };
@@ -207,5 +212,82 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
207
212
  warnings,
208
213
  holderName,
209
214
  ...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
215
+ ...capture,
210
216
  };
211
217
  }
218
+ /** The holder stops writing its events file at 32 MiB, so a read stops there too. */
219
+ const NETWORK_EVENTS_MAX_BYTES = 32 * 1024 * 1024;
220
+ /** How many times the capture holder's log is read for its announce, and how far apart. */
221
+ const NETWORK_ANNOUNCE_READS = 6;
222
+ const NETWORK_ANNOUNCE_EVERY_MS = 500;
223
+ /**
224
+ * Start a holder that records the launch page's requests, and every later tab's, to
225
+ * `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
226
+ * the request to the holder it starts. Sends no Emulation command and no reload. Returns the
227
+ * holder's process name, which readNetworkCaptureStart reads.
228
+ */
229
+ export async function startNetworkCapture(desktop, requestTimeoutMs, endpoint, network) {
230
+ const holderName = `network-capture-${Date.now().toString(36)}`;
231
+ await startDetachedProcess(e2bShell(desktop), {
232
+ name: holderName,
233
+ command: chromeCdpProbeCommand({ ...endpoint, prefer: "pinned", mode: "hold", network }),
234
+ requestTimeoutMs,
235
+ });
236
+ return holderName;
237
+ }
238
+ /**
239
+ * What the capture holder announced: when capture started on the launch page, on the sandbox
240
+ * clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
241
+ * `desktop.wait`, and reports a holder that printed nothing as unavailable.
242
+ */
243
+ export async function readNetworkCaptureStart(desktop, requestTimeoutMs, holderName) {
244
+ const shell = e2bShell(desktop);
245
+ for (let attempt = 0; attempt < NETWORK_ANNOUNCE_READS; attempt += 1) {
246
+ if (attempt > 0)
247
+ await desktop.wait(NETWORK_ANNOUNCE_EVERY_MS).catch(() => undefined);
248
+ const log = await readDetachedLog(shell, holderName, requestTimeoutMs).catch(() => "");
249
+ const line = log.split("\n").find((candidate) => candidate.trim().startsWith("{"));
250
+ if (line === undefined)
251
+ continue;
252
+ const announced = parseChromeCdpProbeOutput(line);
253
+ return (announced.network ?? {
254
+ unavailable: announced.unavailable ?? "the holder announced without starting capture",
255
+ });
256
+ }
257
+ return {
258
+ unavailable: `the in-sandbox holder printed nothing within ${(NETWORK_ANNOUNCE_READS * NETWORK_ANNOUNCE_EVERY_MS) / 1000} s of the desktop settling`,
259
+ };
260
+ }
261
+ /**
262
+ * The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
263
+ * Throws when the sandbox cannot stream files or the file cannot be read.
264
+ */
265
+ export async function readNetworkEvents(desktop, eventsPath, requestTimeoutMs) {
266
+ if (desktop.files.read === undefined)
267
+ throw new Error("this E2B SDK cannot stream a sandbox file to the host");
268
+ const stream = await desktop.files.read(eventsPath, {
269
+ format: "stream",
270
+ requestTimeoutMs,
271
+ streamIdleTimeoutMs: requestTimeoutMs,
272
+ signal: AbortSignal.timeout(requestTimeoutMs),
273
+ });
274
+ const reader = stream.getReader();
275
+ const chunks = [];
276
+ let total = 0;
277
+ try {
278
+ while (total < NETWORK_EVENTS_MAX_BYTES) {
279
+ const { done, value } = await reader.read();
280
+ if (done)
281
+ break;
282
+ const chunk = Buffer.from(value.subarray(0, NETWORK_EVENTS_MAX_BYTES - total));
283
+ chunks.push(chunk);
284
+ total += chunk.length;
285
+ }
286
+ }
287
+ finally {
288
+ await reader.cancel().catch(() => undefined);
289
+ }
290
+ const bytes = Buffer.concat(chunks);
291
+ // A line still being written, or one the cap cut, is dropped.
292
+ return bytes.subarray(0, bytes.lastIndexOf(0x0a) + 1).toString("utf8");
293
+ }
@@ -1,11 +1,13 @@
1
1
  import type { E2BDesktopSandbox } from "./sdk.js";
2
2
  import { type DesktopResources } from "../../run/pricing.js";
3
+ /** Where an allocation's size came from: E2B's report, or the size humanish asked Modal for. */
4
+ export type DesktopResourceSource = "e2b.getInfo" | "modal.create";
3
5
  export type DesktopResourceObservation = {
4
6
  resources: DesktopResources;
5
- source: "e2b.getInfo";
7
+ source: DesktopResourceSource;
6
8
  } | {
7
9
  reason: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
8
10
  };
9
11
  /** Called once after the caller owns and journals the handle. Never throws or delays cleanup
10
12
  * beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
11
- export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number): Promise<DesktopResourceObservation>;
13
+ export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number, source?: DesktopResourceSource): Promise<DesktopResourceObservation>;
@@ -1,7 +1,7 @@
1
1
  import { isDesktopResources } from "../../run/pricing.js";
2
2
  /** Called once after the caller owns and journals the handle. Never throws or delays cleanup
3
3
  * beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
4
- export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
4
+ export async function observeDesktopResources(desktop, timeoutMs = 1_000, source = "e2b.getInfo") {
5
5
  const controller = new AbortController();
6
6
  let timer;
7
7
  try {
@@ -16,7 +16,7 @@ export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
16
16
  });
17
17
  const resources = { cpuCount: info?.cpuCount, memoryMiB: info?.memoryMB };
18
18
  return isDesktopResources(resources)
19
- ? { resources, source: "e2b.getInfo" }
19
+ ? { resources, source }
20
20
  : { reason: "metadata_invalid" };
21
21
  }),
22
22
  new Promise((resolve) => {
@@ -139,7 +139,7 @@ export function desktopPeerAdvice(install, spec = "@e2b/desktop") {
139
139
  return {
140
140
  where: ", and humanish is running from a source checkout",
141
141
  command: "pnpm install",
142
- advice: spec === "@e2b/desktop"
142
+ advice: spec === "@e2b/desktop" || spec === "modal"
143
143
  ? "Run `pnpm install` in the humanish checkout, which installs it as a dev dependency."
144
144
  : `Set ${spec} in the humanish checkout's package.json, then run \`pnpm install\` there.`,
145
145
  };
@@ -1,3 +1,4 @@
1
+ import { type SandboxProviderId } from "../../run/sandbox-receipts.js";
1
2
  import type { PreparedOutputRoot } from "../../run/contained-output.js";
2
3
  import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
3
4
  import type { StudyConfig } from "../../study/types.js";
@@ -13,6 +14,8 @@ interface E2BSandboxReceiptTarget {
13
14
  }
14
15
  export interface E2BSandboxRequest {
15
16
  module: E2BDesktopModule;
17
+ /** Who `module` creates sandboxes on; receipts and owner lines name it. Defaults to e2b. */
18
+ provider?: SandboxProviderId;
16
19
  options: E2BDesktopCreateOptions;
17
20
  retry?: TransientRetryHooks;
18
21
  /**
@@ -9,7 +9,7 @@
9
9
  // journal at that point, so a startup error that quotes it is scrubbed from every run file.
10
10
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
11
11
  import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
12
- import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
12
+ import { appendSandboxOwner, appendSandboxReceipt, } from "../../run/sandbox-receipts.js";
13
13
  import { ownDesktopAllocation, } from "../desktop-session.js";
14
14
  import { e2bAccount } from "./connection.js";
15
15
  import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
@@ -23,9 +23,10 @@ export function acquireE2BShellSandbox(request) {
23
23
  }
24
24
  async function acquire(request, template) {
25
25
  const { module } = request;
26
+ const provider = request.provider ?? "e2b";
26
27
  const target = request.receipt;
27
28
  // Refused before any provider call once the run is stopping.
28
- const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId);
29
+ const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId, provider);
29
30
  // The receipts written when the guard reported an id, before desktop startup. A startup error
30
31
  // can quote the id and the create may never return, so the journal holds it first.
31
32
  const reported = new Map();
@@ -35,7 +36,7 @@ async function acquire(request, template) {
35
36
  // journal records them first, so reclaim knows which tags to search for.
36
37
  const owner = target === null ? undefined : sandboxOwnerTags(target.root);
37
38
  if (target !== null && owner !== undefined)
38
- await recordSandboxOwnerOnce(target.root, () => appendSandboxOwner(target.root, owner));
39
+ await recordSandboxOwnerOnce(target.root, provider, () => appendSandboxOwner(target.root, owner, provider));
39
40
  const options = owner === undefined
40
41
  ? request.options
41
42
  : { ...request.options, metadata: { ...request.options.metadata, ...owner } };
@@ -46,7 +47,7 @@ async function acquire(request, template) {
46
47
  return appendSandboxReceipt(root, {
47
48
  at: new Date(now()).toISOString(),
48
49
  laneId: participantId,
49
- provider: "e2b",
50
+ provider,
50
51
  sandboxId,
51
52
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
52
53
  });
@@ -0,0 +1,9 @@
1
+ import type { ModalClient, ModalImage } from "./sdk.js";
2
+ /** The sandbox account commands run as, matching E2B's `user` and its home. */
3
+ export declare const MODAL_SANDBOX_USER: {
4
+ readonly name: "user";
5
+ readonly uid: 1000;
6
+ readonly home: "/home/user";
7
+ };
8
+ /** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
9
+ export declare function modalShellImage(client: ModalClient): ModalImage;
@@ -0,0 +1,36 @@
1
+ const DEBIAN_BASE = "debian@sha256:7792b1f7702a86946cd518db72b6a407302c3e9bc1635634368b878189e8221c";
2
+ const DEBIAN_SNAPSHOT = "20260923T083042Z";
3
+ const SECURITY_SNAPSHOT = "20260922T123247Z";
4
+ /** The sandbox account commands run as, matching E2B's `user` and its home. */
5
+ export const MODAL_SANDBOX_USER = { name: "user", uid: 1000, home: "/home/user" };
6
+ const SNAPSHOT_SOURCES = `printf 'Types: deb\\nURIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/\\nSuites: trixie trixie-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n\\nTypes: deb\\nURIs: http://snapshot.debian.org/archive/debian-security/${SECURITY_SNAPSHOT}/\\nSuites: trixie-security\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n'` +
7
+ " > /etc/apt/sources.list.d/humanish.sources";
8
+ // What the terminal route's steps call: the Node bootstrap's prerequisites (curl, coreutils, tar,
9
+ // gzip, sudo), git for product installs, and python3 and unzip, which E2B's stock template has.
10
+ const SHELL_PACKAGES = [
11
+ "bash",
12
+ "ca-certificates",
13
+ "curl",
14
+ "git",
15
+ "gzip",
16
+ "less",
17
+ "procps",
18
+ "python3",
19
+ "sudo",
20
+ "tar",
21
+ "unzip",
22
+ "xz-utils",
23
+ ];
24
+ /** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
25
+ export function modalShellImage(client) {
26
+ const { name, uid, home } = MODAL_SANDBOX_USER;
27
+ // prose-check: script
28
+ return client.images
29
+ .fromRegistry(DEBIAN_BASE)
30
+ .dockerfileCommands([
31
+ "ENV DEBIAN_FRONTEND=noninteractive",
32
+ `RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && ${SNAPSHOT_SOURCES} && apt-get update && apt-get install -y --no-install-recommends ${SHELL_PACKAGES.join(" ")} && rm -rf /var/lib/apt/lists/*`,
33
+ `RUN useradd --uid ${uid} --create-home --home-dir ${home} --shell /bin/bash ${name} && printf '${name} ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/${name} && chmod 0440 /etc/sudoers.d/${name}`,
34
+ `WORKDIR ${home}`,
35
+ ]);
36
+ }