humanish 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,12 +53,14 @@ npx humanish run first-run # a study with no keys and no spend — about a mi
53
53
  npx humanish run try-live # a REAL study on a hosted desktop, capped at $2
54
54
  ```
55
55
 
56
- **What it finds, measured (2026-09-01, receipts in `bench/` and `docs/goals/`):** 29 of 30 planted
57
- defects over two benchmark runs on an app we wrote, none invented on the clean arm; on two apps we
58
- did not write, 16 of 18 distinct findings confirmed against the source and 0 invented, across 14
59
- participants; and a modal that stopped 2 of 3 keyboard-first participants, was reported by
60
- all 3, and was never mentioned by 3 mouse-driving newcomers. Each file states
61
- its own caveats.
56
+ **What it finds, measured (2026-09-01 and 09-03, receipts in `bench/` and `docs/goals/`):** 43 of
57
+ 45 planted defects over three benchmark runs on an app we wrote, none invented on the clean arm; on
58
+ two apps we did not write, 16 of 18 distinct findings confirmed against the source and 0 invented,
59
+ across 14 participants; a drawDB modal reported by 5 of 5 keyboard-first participants (3 stopped
60
+ there) and never mentioned by 5 mouse-driving newcomers; a TodoMVC rename that blocked 6 of 6
61
+ keyboard-first participants and none of the others; and, on a phone-sized desktop, a drawDB
62
+ relationship drag intercepted by a popover that both phone participants hit (one stopped) and no
63
+ desktop participant reported. Each file states its own caveats.
62
64
 
63
65
  `try-live` studies a real public app so that your first live run works without
64
66
  configuring anything; point its `subject` at your own app once you have seen it
@@ -343,7 +345,8 @@ persisted evidence: screenshots, events, terminal tails). See
343
345
  | `humanish runs` | List local runs and latest pointers. |
344
346
  | `humanish export` | One self-contained `.html` of a run's Observer with screenshots inlined; verify and the share_ready gate run inside, `--local-only` watermarks a raw-screenshot bundle. |
345
347
  | `humanish stats` | Cost, outcome, and duration roll-ups across run history; `--lab`, `--since`. Estimates stay labelled; unknown costs count as unknown. |
346
- | `humanish feedback issue` | Print a public-safe GitHub issue draft without API mutation. |
348
+ | `humanish feedback list` | List a run's draft state and every feedback candidate (one per participant finding), with the ids `--candidate` takes. |
349
+ | `humanish feedback issue` | Print a public-safe GitHub issue draft without API mutation. `--candidate <id>` chooses which finding; default is the first. |
347
350
  | `humanish lab run oss` | Repo-maintainer contract example: dry-run Observer-of-Observers for authorized repo selections. |
348
351
  | `humanish lab run oss-smoke` | Repo-maintainer dogfood example: disposable clone smoke test against public OSS repos. |
349
352
 
@@ -463,12 +466,26 @@ viewport is smaller because browser chrome occupies space; Chromium-family live
463
466
  measure it through CDP and record it separately from requested/verified screen geometry.
464
467
  Browsers without that measurement seam omit the viewport rather than guessing. A
465
468
  site's width-based responsive CSS still fires, and the model is *told* its device in the
466
- prompt, matching how those sims run organic mobile lanes. There is no touch input, the
467
- device-pixel-ratio isn't rendered, and the user-agent stays desktop on this route; true
468
- touch/DPR/UA emulation arrives with the deterministic CDP actor. Device is run-wide
469
- today; per-*persona* device (N personas × devices) lands with fan-out.
469
+ prompt, matching how those sims run organic mobile lanes. Without the block below there is no
470
+ touch input, the device-pixel-ratio isn't rendered, and the user-agent stays desktop on this
471
+ route. Device is run-wide today; per-*persona* device (N personas × devices) lands with fan-out.
470
472
  `execution.desktop.resolution` is a raw escape hatch that overrides the preset.
471
473
 
474
+ **Mobile emulation.** `execution.desktop.fidelity: { mobileEmulation: true }` turns every hosted
475
+ Chrome/Chromium computer-use lane on a mobile preset (`mobile`, `small-mobile`, `narrow-mobile`)
476
+ into a mobile-emulated browser before the participant arrives, and leaves desktop, tablet and
477
+ wide lanes in the same run untouched: the lane's preset width/height become the CSS viewport (414 px for `mobile`, where the
478
+ X screen itself cannot go below 500), the preset's device pixel ratio applies (`deviceScaleFactor`
479
+ overrides it), touch events are on (`touch: false` turns them off) and the browser presents a
480
+ mobile user agent (`userAgent` replaces the default iPhone Safari string). The run bundle records
481
+ `desktopGeometry.fidelity` with `tier: mobile-emulated`, the request, the CDP methods applied,
482
+ and `resolved`: what the page itself reported afterwards (`navigator.userAgent`,
483
+ `devicePixelRatio`, `innerWidth`, `maxTouchPoints`, coarse pointer). A page without a viewport
484
+ meta lays out at 980 px, as it would on a phone, and the bundle says so. Firefox cannot be
485
+ emulated, so the lane fails closed instead of shipping a desktop run labelled mobile. The
486
+ emulation covers the launch tab (the user agent and touch flags are browser-wide); a bundle
487
+ without a `fidelity` block is a responsive-viewport study whatever its preset is called.
488
+
472
489
  **Desktop browser choice.** Hosted computer-use lanes and shared-world actor seats use the
473
490
  route's historical opener unless you set `execution.desktop.browser` to `chrome`, `chromium`,
474
491
  or `firefox`. A concrete value means "launch this browser or fail"; it never silently
@@ -0,0 +1,93 @@
1
+ /** Which target to attribute when the endpoint lists several pages. */
2
+ export type ChromeCdpPagePreference = "pinned" | "active";
3
+ export interface ChromeCdpProbeArgs {
4
+ /** Launch-time CDP port, if the launch capture caught it. */
5
+ cdpPort?: number;
6
+ /** The launched profile dir; the probe re-reads DevToolsActivePort at observe time. */
7
+ profileDir?: string;
8
+ /** The URL this lane opened; attributes the page when no target id is pinned yet. */
9
+ targetUrl: string;
10
+ /** The pinned page target id from the launch-time geometry capture. */
11
+ targetId?: string;
12
+ /**
13
+ * "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
14
+ * (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
15
+ * targets most-recently-focused first. The state observer must follow the participant: a
16
+ * verification link that opens in a NEW tab left a pinned observer reading the old tab forever,
17
+ * so the observed URL never changed again and stopWhen/task criteria went blind (a live run's
18
+ * funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
19
+ */
20
+ prefer?: ChromeCdpPagePreference;
21
+ /**
22
+ * "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution
23
+ * only. "emulate": apply mobile emulation (#221) to the selected page and exit (the overrides that
24
+ * are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold": the same, then keep
25
+ * the socket open until killed, which is how a lane keeps them for its whole life. "fidelity": read
26
+ * back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
27
+ */
28
+ mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
29
+ /** For "emulate": what to apply. */
30
+ emulation?: ChromeMobileEmulationRequest;
31
+ }
32
+ /** Mobile emulation request (#221): the CDP Emulation domain applied to one page target. */
33
+ export interface ChromeMobileEmulationRequest {
34
+ width: number;
35
+ height: number;
36
+ deviceScaleFactor: number;
37
+ touch: boolean;
38
+ userAgent: string;
39
+ platform?: string;
40
+ }
41
+ /** What the page reports after emulation: the proof, never copied from the request. */
42
+ export interface ChromeFidelityRead {
43
+ userAgent: string;
44
+ devicePixelRatio: number;
45
+ innerWidth: number;
46
+ innerHeight: number;
47
+ maxTouchPoints: number;
48
+ coarsePointer: boolean;
49
+ }
50
+ /** The probe's stdout, before the caller narrows it. */
51
+ export interface ChromeCdpProbeResult {
52
+ unavailable?: string;
53
+ cdpPort?: number;
54
+ url?: string;
55
+ title?: string;
56
+ text?: string;
57
+ scrollY?: number;
58
+ targetId?: string;
59
+ browserWindow?: {
60
+ x: number;
61
+ y: number;
62
+ width: number;
63
+ height: number;
64
+ };
65
+ viewport?: {
66
+ width: number;
67
+ height: number;
68
+ deviceScaleFactor: number;
69
+ };
70
+ /** "emulate": the CDP methods that returned without error, in order. */
71
+ applied?: string[];
72
+ /** "fidelity": the read-back. */
73
+ fidelity?: ChromeFidelityRead;
74
+ }
75
+ /**
76
+ * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
77
+ * (tests/chrome-cdp-probe.test.ts runs it under the real python3 against a real headless Chrome).
78
+ *
79
+ * WebSocket is hand-rolled because python's stdlib has no client: one masked text frame out, frames
80
+ * in until the reply with id 1 arrives, 1.5 s budget, and NO Origin header (Chrome refuses
81
+ * cross-origin DevTools sockets unless --remote-allow-origins is set; a header-less client is a
82
+ * local one). urllib is opened WITHOUT proxy handlers so a sandbox-wide http_proxy cannot redirect
83
+ * a loopback read.
84
+ */
85
+ export declare const CHROME_CDP_PROBE_PY: string;
86
+ /** The exact shell command a sandbox runs for one probe. */
87
+ export declare function chromeCdpProbeCommand(args: ChromeCdpProbeArgs): string;
88
+ /**
89
+ * Narrow one probe's stdout. A parse failure is reported as unavailable with the reason, never as
90
+ * an empty success: the difference between "nothing to observe" and "could not observe" is the
91
+ * whole point of #514.
92
+ */
93
+ export declare function parseChromeCdpProbeOutput(stdout: string | undefined): ChromeCdpProbeResult;
@@ -0,0 +1,395 @@
1
+ // The in-sandbox Chrome DevTools probe behind every URL / page-text / viewport observation.
2
+ //
3
+ // It runs on python3, stdlib only. It used to run on node, and that was the #514 root cause: the
4
+ // stock E2B desktop template ships python3 and curl but NO Node, and Node only arrives when a
5
+ // subject's serve pipeline needs it (subject-runtime.ts). So on the app-url route, and on any
6
+ // subject served by something other than Node (the taskly benchmark is `python3 -m http.server`),
7
+ // `node -e` exited 127 on every turn, the probe degraded to `{}`, and every urlIncludes /
8
+ // textIncludes stop condition and task criterion went blind for the whole session. The only trace
9
+ // was a geometry warning that the CSS viewport "could not be measured", which named the symptom
10
+ // and not the cause. The tab-pinning fix that preceded this one (prefer "active") was diagnosed on
11
+ // a Node subject, where the probe happened to work.
12
+ //
13
+ // The same lesson was learned once already: the comms catch was rewritten from node to python3 in
14
+ // 0.29.0 (comms-sandbox-catch.ts). This is the third in-sandbox runtime dependency to move.
15
+ //
16
+ // The script takes ONE JSON argument and prints ONE JSON line. Failures print
17
+ // `{"unavailable": "<reason>"}` with exit 0 so the caller can say WHY the channel is dark instead
18
+ // of swallowing an exit code; the TypeScript side turns that into a lane warning that names the
19
+ // consequence ("url/text criteria will read as NEVER MEASURED").
20
+ /**
21
+ * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
22
+ * (tests/chrome-cdp-probe.test.ts runs it under the real python3 against a real headless Chrome).
23
+ *
24
+ * WebSocket is hand-rolled because python's stdlib has no client: one masked text frame out, frames
25
+ * in until the reply with id 1 arrives, 1.5 s budget, and NO Origin header (Chrome refuses
26
+ * cross-origin DevTools sockets unless --remote-allow-origins is set; a header-less client is a
27
+ * local one). urllib is opened WITHOUT proxy handlers so a sandbox-wide http_proxy cannot redirect
28
+ * a loopback read.
29
+ */
30
+ export const CHROME_CDP_PROBE_PY = String.raw `
31
+ import base64, json, os, re, socket, struct, sys, time, urllib.request
32
+
33
+ def resolve_port(args):
34
+ port = args.get("cdpPort")
35
+ if isinstance(port, int) and port > 0:
36
+ return port
37
+ profile_dir = str(args.get("profileDir") or "")
38
+ if profile_dir:
39
+ try:
40
+ with open(os.path.join(profile_dir, "DevToolsActivePort"), "r", encoding="utf-8") as handle:
41
+ first = handle.readline().strip()
42
+ parsed = int(first)
43
+ if parsed > 0:
44
+ return parsed
45
+ except Exception:
46
+ pass
47
+ return 9222
48
+
49
+ def list_pages(port):
50
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
51
+ with opener.open("http://127.0.0.1:%d/json" % port, timeout=2) as response:
52
+ pages = json.loads(response.read().decode("utf-8"))
53
+ return pages if isinstance(pages, list) else []
54
+
55
+ def select_page(pages, args):
56
+ http_pages = [
57
+ page for page in pages
58
+ if isinstance(page, dict) and page.get("type") == "page" and re.match(r"^https?:", str(page.get("url") or ""))
59
+ ]
60
+ target_id = str(args.get("targetId") or "")
61
+ target_url = str(args.get("targetUrl") or "")
62
+ normalize = lambda value: str(value or "").rstrip("/")
63
+ if args.get("prefer") == "active":
64
+ if http_pages:
65
+ return http_pages[0]
66
+ if target_id:
67
+ return next((page for page in http_pages if page.get("id") == target_id), None)
68
+ return None
69
+ if target_id:
70
+ return next((page for page in http_pages if page.get("id") == target_id), None)
71
+ match = next((page for page in http_pages if normalize(page.get("url")) == normalize(target_url)), None)
72
+ if match is not None:
73
+ return match
74
+ return http_pages[0] if len(http_pages) == 1 else None
75
+
76
+ def ws_session(ws_url, messages, timeout=1.5, hold=False):
77
+ """Send CDP messages over one page socket, in order, and return their replies (None on failure).
78
+ hold=True prints the replies and then keeps the socket open until the process is killed."""
79
+ match = re.match(r"^ws://([^/:]+):(\d+)(/.*)$", str(ws_url or ""))
80
+ if not match:
81
+ return None
82
+ host, port, path = match.group(1), int(match.group(2)), match.group(3)
83
+ sock = None
84
+ try:
85
+ sock = socket.create_connection((host, port), timeout=timeout)
86
+ sock.settimeout(timeout)
87
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
88
+ handshake = (
89
+ "GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
90
+ "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, host, port, key)
91
+ )
92
+ sock.sendall(handshake.encode("ascii"))
93
+ buffer = b""
94
+ while b"\r\n\r\n" not in buffer:
95
+ chunk = sock.recv(4096)
96
+ if not chunk:
97
+ return None
98
+ buffer += chunk
99
+ head, buffer = buffer.split(b"\r\n\r\n", 1)
100
+ if b" 101 " not in head.split(b"\r\n", 1)[0]:
101
+ return None
102
+ state = {"buffer": buffer}
103
+
104
+ def need(count):
105
+ while len(state["buffer"]) < count:
106
+ chunk = sock.recv(65536)
107
+ if not chunk:
108
+ raise EOFError("socket closed")
109
+ state["buffer"] += chunk
110
+
111
+ def send(message):
112
+ payload = json.dumps(message).encode("utf-8")
113
+ mask = os.urandom(4)
114
+ frame = bytearray([0x81])
115
+ size = len(payload)
116
+ if size < 126:
117
+ frame.append(0x80 | size)
118
+ elif size < 65536:
119
+ frame.append(0x80 | 126)
120
+ frame += struct.pack(">H", size)
121
+ else:
122
+ frame.append(0x80 | 127)
123
+ frame += struct.pack(">Q", size)
124
+ frame += mask + bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
125
+ sock.sendall(bytes(frame))
126
+
127
+ def receive(wanted_id):
128
+ message = b""
129
+ while True:
130
+ need(2)
131
+ first, second = state["buffer"][0], state["buffer"][1]
132
+ fin, opcode = first & 0x80, first & 0x0F
133
+ masked, length, offset = second & 0x80, second & 0x7F, 2
134
+ if length == 126:
135
+ need(4)
136
+ length, offset = struct.unpack(">H", state["buffer"][2:4])[0], 4
137
+ elif length == 127:
138
+ need(10)
139
+ length, offset = struct.unpack(">Q", state["buffer"][2:10])[0], 10
140
+ frame_mask = b""
141
+ if masked:
142
+ need(offset + 4)
143
+ frame_mask, offset = state["buffer"][offset:offset + 4], offset + 4
144
+ need(offset + length)
145
+ data = state["buffer"][offset:offset + length]
146
+ state["buffer"] = state["buffer"][offset + length:]
147
+ if masked:
148
+ data = bytes(byte ^ frame_mask[index % 4] for index, byte in enumerate(data))
149
+ if opcode == 8:
150
+ return None
151
+ if opcode in (9, 10):
152
+ continue
153
+ message += data
154
+ if fin:
155
+ try:
156
+ reply = json.loads(message.decode("utf-8"))
157
+ except Exception:
158
+ return None
159
+ message = b""
160
+ if isinstance(reply, dict) and reply.get("id") == wanted_id:
161
+ return reply
162
+
163
+ replies = []
164
+ for index, message in enumerate(messages, 1):
165
+ send({"id": index, **message})
166
+ replies.append(receive(index))
167
+ if hold:
168
+ # Announce the result now (the caller reads stdout once), then stay attached.
169
+ applied = [m["method"] for m, r in zip(messages, replies) if isinstance(r, dict) and "error" not in r]
170
+ failed = [m["method"] for m, r in zip(messages, replies) if not (isinstance(r, dict) and "error" not in r)]
171
+ print(json.dumps({"applied": applied, "held": not failed, **({"unavailable": "%s failed" % failed[0]} if failed else {})}), flush=True)
172
+ if failed:
173
+ return replies
174
+ sock.settimeout(None)
175
+ while True:
176
+ time.sleep(30)
177
+ return replies
178
+ except Exception:
179
+ return None
180
+ finally:
181
+ if sock is not None:
182
+ try:
183
+ sock.close()
184
+ except Exception:
185
+ pass
186
+
187
+ def evaluate(ws_url, expression, timeout=1.5):
188
+ replies = ws_session(ws_url, [{"method": "Runtime.evaluate", "params": {"returnByValue": True, "expression": expression}}], timeout)
189
+ if not replies or not isinstance(replies[0], dict):
190
+ return None
191
+ result = replies[0].get("result") or {}
192
+ inner = result.get("result") if isinstance(result, dict) else None
193
+ return inner.get("value") if isinstance(inner, dict) else None
194
+
195
+ STATE_EXPRESSION = (
196
+ "({ url: location.href, title: document.title, "
197
+ "text: (document.body && document.body.innerText || '').slice(0, 20000), "
198
+ "scrollY: (window.scrollY || 0) })"
199
+ )
200
+ GEOMETRY_EXPRESSION = (
201
+ "({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, "
202
+ "viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })"
203
+ )
204
+ FIDELITY_EXPRESSION = (
205
+ "({ userAgent: navigator.userAgent, devicePixelRatio: window.devicePixelRatio, "
206
+ "innerWidth: window.innerWidth, innerHeight: window.innerHeight, "
207
+ "maxTouchPoints: navigator.maxTouchPoints || 0, "
208
+ "coarsePointer: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) })"
209
+ )
210
+
211
+ def emulate(ws_url, request, hold=False):
212
+ """Apply the Emulation domain to the page and reload it so scripts that read the UA at load see it.
213
+ With hold=True the socket stays open (the overrides are bound to this session) until the process is killed."""
214
+ width = int(request.get("width") or 0)
215
+ height = int(request.get("height") or 0)
216
+ scale = float(request.get("deviceScaleFactor") or 1)
217
+ user_agent = str(request.get("userAgent") or "")
218
+ platform = str(request.get("platform") or "")
219
+ messages = [
220
+ {"method": "Emulation.setDeviceMetricsOverride", "params": {
221
+ "width": width, "height": height, "deviceScaleFactor": scale, "mobile": True,
222
+ "screenWidth": width, "screenHeight": height}},
223
+ ]
224
+ if request.get("touch"):
225
+ messages.append({"method": "Emulation.setTouchEmulationEnabled", "params": {"enabled": True, "maxTouchPoints": 5}})
226
+ messages.append({"method": "Emulation.setEmitTouchEventsForMouse", "params": {"enabled": True, "configuration": "mobile"}})
227
+ if user_agent:
228
+ params = {"userAgent": user_agent}
229
+ if platform:
230
+ params["platform"] = platform
231
+ messages.append({"method": "Emulation.setUserAgentOverride", "params": params})
232
+ messages.append({"method": "Page.reload", "params": {}})
233
+ replies = ws_session(ws_url, messages, timeout=5, hold=hold)
234
+ if replies is None:
235
+ return None, "the page socket could not be opened"
236
+ applied = []
237
+ for message, reply in zip(messages, replies):
238
+ if isinstance(reply, dict) and "error" not in reply:
239
+ applied.append(message["method"])
240
+ else:
241
+ detail = (reply or {}).get("error", {}).get("message") if isinstance(reply, dict) else "no reply"
242
+ return applied, "%s failed: %s" % (message["method"], detail)
243
+ return applied, None
244
+
245
+ def main():
246
+ args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
247
+ mode = args.get("mode") or "state"
248
+ port = resolve_port(args)
249
+ if mode == "port":
250
+ print(json.dumps({"cdpPort": port}))
251
+ return
252
+ try:
253
+ pages = list_pages(port)
254
+ except Exception as error:
255
+ print(json.dumps({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)}))
256
+ return
257
+ page = select_page(pages, args)
258
+ if page is None:
259
+ print(json.dumps({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)}))
260
+ return
261
+ ws_url = page.get("webSocketDebuggerUrl")
262
+ if mode == "emulate":
263
+ applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
264
+ if failure is not None:
265
+ print(json.dumps({"unavailable": failure, "applied": applied or []}))
266
+ return
267
+ print(json.dumps({"applied": applied, "targetId": str(page.get("id") or "")}))
268
+ return
269
+ if mode == "hold":
270
+ if not ws_url:
271
+ print(json.dumps({"unavailable": "the page has no socket"}), flush=True)
272
+ return
273
+ # Prints its own line from inside ws_session (before blocking) and never returns on success.
274
+ applied, failure = emulate(ws_url, args.get("emulation") or {}, hold=True)
275
+ if failure is not None:
276
+ print(json.dumps({"unavailable": failure, "applied": applied or []}), flush=True)
277
+ return
278
+ if mode == "fidelity":
279
+ result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
280
+ if not isinstance(result, dict):
281
+ print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
282
+ return
283
+ print(json.dumps({"fidelity": result, "targetId": str(page.get("id") or "")}))
284
+ return
285
+ if mode == "geometry":
286
+ result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
287
+ if not isinstance(result, dict):
288
+ print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
289
+ return
290
+ result["targetId"] = str(page.get("id") or "")
291
+ print(json.dumps(result))
292
+ return
293
+ url = str(page.get("url") or "")
294
+ title = str(page.get("title") or "")
295
+ text = ""
296
+ scroll_y = None
297
+ result = evaluate(ws_url, STATE_EXPRESSION) if ws_url else None
298
+ if isinstance(result, dict):
299
+ url = result["url"] if isinstance(result.get("url"), str) else url
300
+ title = result["title"] if isinstance(result.get("title"), str) else title
301
+ text = result["text"] if isinstance(result.get("text"), str) else ""
302
+ scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
303
+ print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y}))
304
+
305
+ main()
306
+ `;
307
+ function shellSingleQuote(value) {
308
+ return `'${value.replace(/'/g, "'\\''")}'`;
309
+ }
310
+ /** The exact shell command a sandbox runs for one probe. */
311
+ export function chromeCdpProbeCommand(args) {
312
+ const payload = { mode: args.mode, targetUrl: args.targetUrl };
313
+ if (args.cdpPort !== undefined)
314
+ payload.cdpPort = args.cdpPort;
315
+ if (args.profileDir !== undefined)
316
+ payload.profileDir = args.profileDir;
317
+ if (args.targetId !== undefined)
318
+ payload.targetId = args.targetId;
319
+ if (args.prefer !== undefined)
320
+ payload.prefer = args.prefer;
321
+ if (args.emulation !== undefined)
322
+ payload.emulation = args.emulation;
323
+ return `python3 -c ${shellSingleQuote(CHROME_CDP_PROBE_PY)} ${shellSingleQuote(JSON.stringify(payload))}`;
324
+ }
325
+ /**
326
+ * Narrow one probe's stdout. A parse failure is reported as unavailable with the reason, never as
327
+ * an empty success: the difference between "nothing to observe" and "could not observe" is the
328
+ * whole point of #514.
329
+ */
330
+ export function parseChromeCdpProbeOutput(stdout) {
331
+ const trimmed = (stdout ?? "").trim();
332
+ if (trimmed.length === 0)
333
+ return { unavailable: "probe printed nothing" };
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(trimmed);
337
+ }
338
+ catch {
339
+ return { unavailable: "probe output was not JSON" };
340
+ }
341
+ if (!parsed || typeof parsed !== "object")
342
+ return { unavailable: "probe output was not an object" };
343
+ const record = parsed;
344
+ const applied = Array.isArray(record.applied)
345
+ ? record.applied.filter((item) => typeof item === "string")
346
+ : undefined;
347
+ if (typeof record.unavailable === "string") {
348
+ return { unavailable: record.unavailable, ...(applied === undefined ? {} : { applied }) };
349
+ }
350
+ const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
351
+ const box = (value, keys) => {
352
+ if (!value || typeof value !== "object")
353
+ return undefined;
354
+ const source = value;
355
+ const out = {};
356
+ for (const key of keys) {
357
+ const n = numberOr(source[key]);
358
+ if (n === undefined)
359
+ return undefined;
360
+ out[key] = n;
361
+ }
362
+ return out;
363
+ };
364
+ const browserWindow = box(record.browserWindow, ["x", "y", "width", "height"]);
365
+ const viewport = box(record.viewport, ["width", "height", "deviceScaleFactor"]);
366
+ const cdpPort = numberOr(record.cdpPort);
367
+ const scrollY = numberOr(record.scrollY);
368
+ const fidelity = (() => {
369
+ const raw = record.fidelity;
370
+ if (!raw || typeof raw !== "object")
371
+ return undefined;
372
+ const source = raw;
373
+ const dpr = numberOr(source.devicePixelRatio);
374
+ const innerWidth = numberOr(source.innerWidth);
375
+ const innerHeight = numberOr(source.innerHeight);
376
+ const maxTouchPoints = numberOr(source.maxTouchPoints);
377
+ if (typeof source.userAgent !== "string" || dpr === undefined || innerWidth === undefined || innerHeight === undefined || maxTouchPoints === undefined) {
378
+ return undefined;
379
+ }
380
+ return { userAgent: source.userAgent, devicePixelRatio: dpr, innerWidth, innerHeight, maxTouchPoints, coarsePointer: source.coarsePointer === true };
381
+ })();
382
+ return {
383
+ ...(applied === undefined ? {} : { applied }),
384
+ ...(fidelity === undefined ? {} : { fidelity }),
385
+ ...(cdpPort === undefined ? {} : { cdpPort }),
386
+ ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
387
+ ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
388
+ ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
389
+ ...(scrollY === undefined ? {} : { scrollY }),
390
+ ...(typeof record.targetId === "string" && record.targetId.length > 0 ? { targetId: record.targetId } : {}),
391
+ ...(browserWindow === undefined ? {} : { browserWindow }),
392
+ ...(viewport === undefined ? {} : { viewport })
393
+ };
394
+ }
395
+ //# sourceMappingURL=chrome-cdp-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chrome-cdp-probe.js","sourceRoot":"","sources":["../src/chrome-cdp-probe.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,7 @@ import { type E2BDesktopModule, type E2BDesktopSandbox } from "./e2b-desktop-lau
10
10
  import { type DetachedTimers } from "./e2b-detached.js";
11
11
  import { type DevicePreset } from "./device-presets.js";
12
12
  import { type LabActorLane, type LabCommsEmail, type LabCommsRecipient, type LabConfig, type LabDesktopBrowser, type LabSubjectServe, type LabSubjectState } from "./lab-config.js";
13
+ import { type ChromeMobileEmulationRequest } from "./chrome-cdp-probe.js";
13
14
  import { type ResolvedPersona } from "./persona.js";
14
15
  import { type LabTask } from "./tasks.js";
15
16
  import { renderObserver, type ObserverResult } from "./observer.js";
@@ -661,9 +662,9 @@ export declare function desktopBrowserFamily(value: string | undefined): Desktop
661
662
  /**
662
663
  * Runtime-only CDP endpoint attribution for the exact chromium this lane launched. Port
663
664
  * resolution at OBSERVE time: the cached launch-time `cdpPort` wins; absent that, the observer
664
- * script re-reads `profileDir`'s DevToolsActivePort marker (a slow cold start can publish it
665
+ * probe re-reads `profileDir`'s DevToolsActivePort marker (a slow cold start can publish it
665
666
  * AFTER the launch-time poll gave up); absent both it falls back to the legacy fixed 9222,
666
- * where a dead endpoint degrades into an honest warning.
667
+ * where a dead endpoint degrades into an honest warning that names the cause.
667
668
  */
668
669
  export interface ChromeCdpEndpoint {
669
670
  cdpPort?: number;
@@ -673,14 +674,18 @@ export interface ChromeCdpEndpoint {
673
674
  targetUrl: string;
674
675
  }
675
676
  /**
676
- * Observe-time CDP port resolution lines (pure; exported for contract tests): cached
677
- * launch-time port first, then a re-read of the profile's DevToolsActivePort marker, then the
678
- * legacy fixed 9222. The re-read is a local best-effort file read inside the already
679
- * time-bounded observer command, so a missing/garbled marker degrades to the fallback,
680
- * never a hang.
677
+ * The URL / title / page-text / scroll observer behind stopWhen and task criteria. One probe per
678
+ * observation, run on the sandbox's python3 (see chrome-cdp-probe.ts for why not node: #514).
679
+ *
680
+ * "active": follow the participant to whatever tab they are driving now — never pin the state
681
+ * observer to the launch tab (a verification link that opened in a NEW tab left a pinned observer
682
+ * reading the old tab forever).
683
+ *
684
+ * `onUnavailable` fires ONCE, on the first probe that could not read the page, with the reason.
685
+ * The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
686
+ * url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
681
687
  */
682
- export declare function chromeCdpPortResolutionScript(endpoint: ChromeCdpEndpoint): string[];
683
- export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string): () => Promise<{
688
+ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<{
684
689
  url?: string;
685
690
  title?: string;
686
691
  text?: string;
@@ -690,10 +695,24 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
690
695
  * Read the running browser's actual outer-window bounds and CSS layout viewport through the
691
696
  * already-enabled local Chrome DevTools endpoint. The returned values come from `window.*` in
692
697
  * the target page; requested E2B resolution is deliberately not an input to this function.
698
+ * `undefined` carries the reason the measurement is missing via `onUnavailable`, so the geometry
699
+ * warning can name the cause (a dead CDP endpoint, no python3) instead of only the symptom.
693
700
  */
694
- export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
701
+ export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
695
702
  targetId?: string;
696
703
  }) | undefined>;
704
+ /** The user agent a mobile-emulated lane presents unless the lab sets its own. */
705
+ export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
706
+ /**
707
+ * Apply mobile emulation (#221) to the lane's launch page and read back what the page reports.
708
+ * Fails CLOSED: a request that cannot be applied throws, because a desktop run labelled mobile is
709
+ * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
710
+ * (the emulation was applied; only the proof is missing).
711
+ */
712
+ export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId: string | undefined, request: ChromeMobileEmulationRequest): Promise<{
713
+ fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
714
+ warnings: string[];
715
+ }>;
697
716
  /** Shared hosted-browser geometry capture used by per-lane and sequential shared-world routes. */
698
717
  export declare function captureDesktopBrowserGeometry(args: {
699
718
  desktop: E2BDesktopSandbox;