humanish 0.136.0 → 0.137.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 (63) hide show
  1. package/CHANGELOG.md +22 -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/evidence/browser-network.d.ts +108 -0
  9. package/dist/evidence/browser-network.js +516 -0
  10. package/dist/evidence/trace-page.d.ts +31 -1
  11. package/dist/evidence/trace-page.js +68 -2
  12. package/dist/observer-app.html +4 -4
  13. package/dist/routes/computer-use/bundle.js +1 -0
  14. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  15. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  16. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  17. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  18. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  19. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  20. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  21. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  22. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  23. package/dist/routes/computer-use/fanout-records.js +10 -0
  24. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  25. package/dist/routes/computer-use/participant-model.js +2 -1
  26. package/dist/routes/computer-use/plan.js +2 -1
  27. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  28. package/dist/routes/computer-use/single-bundle.js +4 -0
  29. package/dist/routes/computer-use/types.d.ts +7 -0
  30. package/dist/routes/scripted/plan.js +2 -2
  31. package/dist/routes/shared-world/plan.js +3 -2
  32. package/dist/routes/terminal/plan.js +2 -2
  33. package/dist/run/live-items.d.ts +20 -2
  34. package/dist/run/live-items.js +23 -14
  35. package/dist/run/streams.d.ts +7 -0
  36. package/dist/study/config.js +4 -1
  37. package/dist/study/keys.d.ts +3 -0
  38. package/dist/study/keys.js +2 -0
  39. package/dist/study/parse/comms.js +1 -20
  40. package/dist/study/parse/execution.js +30 -1
  41. package/dist/study/parse/values.d.ts +5 -0
  42. package/dist/study/parse/values.js +22 -0
  43. package/dist/study/plan-base.js +6 -1
  44. package/dist/study/plan-types.d.ts +1 -1
  45. package/dist/study/study-fields.d.ts +7 -1
  46. package/dist/study/study-fields.js +10 -0
  47. package/dist/study/types.d.ts +11 -0
  48. package/dist/study/validation.d.ts +8 -0
  49. package/dist/study/validation.js +31 -2
  50. package/dist/substrates/detached.d.ts +5 -0
  51. package/dist/substrates/detached.js +9 -0
  52. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  53. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  54. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  55. package/dist/substrates/e2b/cdp-probe.js +100 -36
  56. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  57. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  58. package/dist/verify/network.d.ts +10 -0
  59. package/dist/verify/network.js +162 -0
  60. package/dist/verify/pages.js +1 -16
  61. package/dist/verify/verify.js +8 -2
  62. package/docs/contracts/run-bundle.md +86 -1
  63. package/package.json +6 -6
@@ -0,0 +1,194 @@
1
+ // What the in-sandbox holder keeps from a participant's browser traffic. The holder (cdp-probe.ts,
2
+ // `mode: "hold"`) already owns the one long-lived DevTools socket on a hosted desktop; with a
3
+ // network request it also enables the Network domain and appends one short JSON line per kept
4
+ // event to a file the host reads at teardown.
5
+ //
6
+ // The CDP events carry headers, cookies, post data, initiator stacks and full URLs. A line copies
7
+ // none of them: only the request id, the two clocks, method, resource type, status, cache flags,
8
+ // error text, and an address for an allowed origin, cut at the first `?` or `#` with user info
9
+ // removed. A request to any other origin gets a line with no address, method or type, so the host
10
+ // can count it, and nothing more is written for it. A method or resource type that is not plain
11
+ // letters is written as `Other`, and error text outside Chrome's `net::ERR_*` form as
12
+ // `net::ERR_OTHER`, so no free text reaches the file.
13
+ /** The segment a path the holder cut ends with: the marker tracePage ends a cut path with. */
14
+ const PATH_CUT = "[TRUNCATED]";
15
+ /**
16
+ * The capture half of the holder, spliced into CHROME_CDP_PROBE_PY so the shipped script stays one
17
+ * string. Line kinds: `start` (Network answered on the launch page), `request`, `response`,
18
+ * `servedFromCache`, `finished`, `failed`, `backForward` (a main-frame restore from the
19
+ * back-forward cache, which sends no request), `cap` (the file reached 32 MiB and writing stopped)
20
+ * and `exit` (the holder stopped; written in a finally). Ids are `<session>:<requestId>`, the
21
+ * session numbered in attach order from 0 for the launch page, because request ids repeat across
22
+ * renderers. `wallTime` is seconds since the epoch on the sandbox clock; `timestamp` is CDP's
23
+ * monotonic clock, for durations. A path longer than 4,096 characters ends at its last whole
24
+ * segment within them, then `/[TRUNCATED]`: the host redacts each segment, and a token cut in two
25
+ * can fall below the length its rules withhold.
26
+ */
27
+ export const NETWORK_CAPTURE_PY = String.raw `
28
+ NETWORK_ENABLE = {"maxTotalBufferSize": 0, "maxResourceBufferSize": 0, "maxPostDataSize": 0}
29
+ NETWORK_EVENTS_CAP = 32 * 1024 * 1024
30
+ # Kept free under the cap so the cap and exit lines always fit.
31
+ NETWORK_TAIL_RESERVE = 512
32
+ NETWORK_PATH_LIMIT = 4096
33
+ WEB_ADDRESS = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://(?:[^/?#]*@)?([^/?#]*)([^?#]*)")
34
+ NETWORK_WORD = re.compile(r"^[A-Za-z]{1,32}$")
35
+ NETWORK_ERROR = re.compile(r"^net::ERR_[A-Z0-9_]{1,64}$")
36
+
37
+ def allowed_address(url, origins):
38
+ """origin + path for an allowed origin, without user info, query or fragment; else None."""
39
+ match = WEB_ADDRESS.match(str(url or ""))
40
+ if not match:
41
+ return None
42
+ origin = ("%s://%s" % (match.group(1), match.group(2))).lower()
43
+ if origin not in origins:
44
+ return None
45
+ path = match.group(3) or "/"
46
+ if len(path) > NETWORK_PATH_LIMIT:
47
+ path = path[:path.rfind("/", 0, NETWORK_PATH_LIMIT + 1)] + "/${PATH_CUT}"
48
+ return origin + path
49
+
50
+ def network_word(value, fallback):
51
+ text = str(value or "")
52
+ return text if NETWORK_WORD.match(text) else fallback
53
+
54
+ def clock(value):
55
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
56
+
57
+ def response_fields(response):
58
+ fields = {}
59
+ if not isinstance(response, dict):
60
+ return fields
61
+ status = clock(response.get("status"))
62
+ if status is not None and 100 <= status <= 599:
63
+ fields["status"] = int(status)
64
+ for flag in ("fromDiskCache", "fromPrefetchCache", "fromServiceWorker"):
65
+ if response.get(flag) is True:
66
+ fields[flag] = True
67
+ return fields
68
+
69
+ class NetworkLog:
70
+ """The events file of one holder. Writes go straight to the file, one line per write, so a
71
+ read while the holder runs sees whole lines except possibly the last."""
72
+ def __init__(self, request):
73
+ self.path = str(request.get("eventsPath") or "/tmp/humanish-network/events.ndjson")
74
+ self.origins = set(str(origin).rstrip("/").lower() for origin in request.get("origins") or [] if isinstance(origin, str))
75
+ folder = os.path.dirname(self.path)
76
+ if folder:
77
+ os.makedirs(folder, exist_ok=True)
78
+ self.handle = open(self.path, "ab", buffering=0)
79
+ self.written = os.fstat(self.handle.fileno()).st_size
80
+ self.capped = False
81
+ self.sessions = {}
82
+ # Ids whose current hop has an allowed address; only those get lines after the request.
83
+ self.open = set()
84
+ self.faults = 0
85
+
86
+ def session(self, session_id):
87
+ if session_id not in self.sessions:
88
+ self.sessions[session_id] = len(self.sessions)
89
+ return self.sessions[session_id]
90
+
91
+ def write(self, line, tail=False):
92
+ if self.capped and not tail:
93
+ return
94
+ data = (json.dumps({key: value for key, value in line.items() if value is not None}, separators=(",", ":")) + "\n").encode("utf-8")
95
+ room = NETWORK_EVENTS_CAP - (0 if tail else NETWORK_TAIL_RESERVE)
96
+ if self.written + len(data) > room:
97
+ if not tail:
98
+ self.capped = True
99
+ self.write({"e": "cap", "wallTime": time.time()}, tail=True)
100
+ return
101
+ self.handle.write(data)
102
+ self.written += len(data)
103
+
104
+ def event(self, method, params, session):
105
+ """Record one CDP event. A fault is counted, never raised: emulation must keep running."""
106
+ try:
107
+ self.record(method, params, session)
108
+ except Exception:
109
+ self.faults += 1
110
+
111
+ def record(self, method, params, session):
112
+ # Past the cap nothing more is written, so nothing more is tracked.
113
+ if self.capped:
114
+ return
115
+ if method == "Page.frameNavigated":
116
+ frame = params.get("frame") or {}
117
+ if params.get("type") == "BackForwardCacheRestore" and not frame.get("parentId"):
118
+ self.write({"e": "backForward", "wallTime": time.time(), "url": allowed_address(frame.get("url"), self.origins)})
119
+ return
120
+ if not str(method).startswith("Network."):
121
+ return
122
+ kind = method[len("Network."):]
123
+ request_id = "%d:%s" % (self.session(session), str(params.get("requestId") or "")[:64])
124
+ timestamp = clock(params.get("timestamp"))
125
+ if kind == "requestWillBeSent":
126
+ request = params.get("request") or {}
127
+ line = {"e": "request", "id": request_id, "wallTime": clock(params.get("wallTime")), "timestamp": timestamp}
128
+ if "redirectResponse" in params:
129
+ line["redirectResponse"] = response_fields(params.get("redirectResponse"))
130
+ address = allowed_address(request.get("url"), self.origins)
131
+ if address is None:
132
+ self.open.discard(request_id)
133
+ else:
134
+ self.open.add(request_id)
135
+ line["url"] = address
136
+ line["method"] = network_word(request.get("method"), "Other")
137
+ line["type"] = network_word(params.get("type"), "Other")
138
+ self.write(line)
139
+ elif request_id not in self.open:
140
+ return
141
+ elif kind == "responseReceived":
142
+ line = {"e": "response", "id": request_id, "timestamp": timestamp}
143
+ line.update(response_fields(params.get("response")))
144
+ self.write(line)
145
+ elif kind == "requestServedFromCache":
146
+ self.write({"e": "servedFromCache", "id": request_id})
147
+ elif kind == "loadingFinished":
148
+ self.open.discard(request_id)
149
+ self.write({"e": "finished", "id": request_id, "timestamp": timestamp})
150
+ elif kind == "loadingFailed":
151
+ self.open.discard(request_id)
152
+ error = str(params.get("errorText") or "")
153
+ self.write({"e": "failed", "id": request_id, "timestamp": timestamp, "errorText": error if NETWORK_ERROR.match(error) else "net::ERR_OTHER"})
154
+
155
+ def close(self, reason):
156
+ line = {"e": "exit", "reason": str(reason)[:200]}
157
+ if self.faults:
158
+ line["faults"] = self.faults
159
+ try:
160
+ self.write(line, tail=True)
161
+ self.handle.close()
162
+ except Exception:
163
+ pass
164
+
165
+ def start_capture(ws, session_id, network):
166
+ """Network on the launch session, answered before any Emulation message goes out, so a mobile
167
+ participant's launch reload is captured. Returns the log, or None, and the announce field."""
168
+ try:
169
+ log = NetworkLog(network)
170
+ except Exception as error:
171
+ return None, {"unavailable": "the events file could not be opened (%s)" % type(error).__name__}
172
+ # Read before the call, so a request whose events queued behind the reply started after it.
173
+ started = time.time()
174
+ try:
175
+ reply = ws.call("Network.enable", NETWORK_ENABLE, session_id)
176
+ except Exception as error:
177
+ reply = {"error": {"message": type(error).__name__}}
178
+ if not isinstance(reply, dict) or "error" in reply:
179
+ detail = str(((reply or {}).get("error") or {}).get("message")) if isinstance(reply, dict) else "no reply"
180
+ log.close("Network.enable failed: %s" % detail)
181
+ return None, {"unavailable": "Network.enable failed on the launch page: %s" % detail}
182
+ try:
183
+ log.session(session_id)
184
+ log.write({"e": "start", "wallTime": started})
185
+ except Exception as error:
186
+ log.close("the start line could not be written: %s" % type(error).__name__)
187
+ return None, {"unavailable": "the events file could not be written (%s)" % type(error).__name__}
188
+ # Page events report back-forward restores; without them only those lines are missing.
189
+ try:
190
+ ws.call("Page.enable", {}, session_id)
191
+ except Exception:
192
+ pass
193
+ return log, {"startedAt": started}
194
+ `;
@@ -1,3 +1,4 @@
1
+ import { type ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
1
2
  /** Which target to attribute when the endpoint lists several pages. */
2
3
  export type ChromeCdpPagePreference = "pinned" | "active";
3
4
  export interface ChromeCdpProbeArgs {
@@ -23,15 +24,17 @@ export interface ChromeCdpProbeArgs {
23
24
  /**
24
25
  * "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution
25
26
  * only. "emulate": apply mobile emulation to the selected page and exit (the overrides that
26
- * are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold": the same over a browser-level
27
- * socket, then stay attached until killed (how a participant keeps them for its whole life) and attach
28
- * to every page target Chrome opens later, sending it the same overrides and a reload the moment
29
- * it exists, never pausing it. "fidelity": read
27
+ * are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold": stay attached over
28
+ * a browser-level socket until killed and attach to every page target Chrome opens later, never
29
+ * pausing it; it applies `emulation` (how a participant keeps the overrides for its whole life,
30
+ * with a reload on each page) and `network` capture, either or both. "fidelity": read
30
31
  * back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
31
32
  */
32
33
  mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
33
- /** For "emulate": what to apply. */
34
+ /** For "emulate" and "hold": what to apply. */
34
35
  emulation?: ChromeMobileEmulationRequest;
36
+ /** For "hold": capture each held page's requests to a sandbox file. */
37
+ network?: ChromeNetworkCaptureRequest;
35
38
  }
36
39
  /** Mobile emulation request: the CDP Emulation domain applied to one page target. */
37
40
  export interface ChromeMobileEmulationRequest {
@@ -75,6 +78,18 @@ export interface ChromeCdpProbeResult {
75
78
  applied?: string[];
76
79
  /** "fidelity": the read-back. */
77
80
  fidelity?: ChromeFidelityRead;
81
+ /** "hold" with `network`: when capture started on the launch page, or why it could not. */
82
+ network?: {
83
+ startedAt: number;
84
+ } | {
85
+ unavailable: string;
86
+ };
87
+ /**
88
+ * Seconds since the epoch on the sandbox clock when the probe printed, on every answer including
89
+ * an unavailable one. It is the clock CDP `wallTime` uses, so a turn mark compares with request
90
+ * times without host clock skew.
91
+ */
92
+ now?: number;
78
93
  }
79
94
  /**
80
95
  * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
@@ -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:
@@ -332,17 +358,19 @@ def hold(port, page, request):
332
358
  if href and not href.startswith("about:"):
333
359
  target_id = reload_owed.pop(session)
334
360
  pending[ws.send("Page.reload", {}, session)] = "Page.reload"
335
- print(json.dumps({"reloaded": target_id, "url": href, "by": "href"}), flush=True)
361
+ print(json.dumps({"reloaded": target_id, "by": "href"}), flush=True)
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 "")
342
370
  if not frame.get("parentId") and url and not url.startswith("about:"):
343
371
  target_id = reload_owed.pop(session)
344
372
  pending[ws.send("Page.reload", {}, session)] = "Page.reload"
345
- print(json.dumps({"reloaded": target_id, "url": url}), flush=True)
373
+ print(json.dumps({"reloaded": target_id}), flush=True)
346
374
  continue
347
375
  if method != "Target.attachedToTarget":
348
376
  continue
@@ -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"], "url": url}), 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>;