humanish 0.74.0 → 0.75.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.
@@ -0,0 +1,63 @@
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
+ /** "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution only. */
22
+ mode: "state" | "geometry" | "port";
23
+ }
24
+ /** The probe's stdout, before the caller narrows it. */
25
+ export interface ChromeCdpProbeResult {
26
+ unavailable?: string;
27
+ cdpPort?: number;
28
+ url?: string;
29
+ title?: string;
30
+ text?: string;
31
+ scrollY?: number;
32
+ targetId?: string;
33
+ browserWindow?: {
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ };
39
+ viewport?: {
40
+ width: number;
41
+ height: number;
42
+ deviceScaleFactor: number;
43
+ };
44
+ }
45
+ /**
46
+ * The probe itself. Kept as one string so the shipped command is exactly what the tests execute
47
+ * (tests/chrome-cdp-probe.test.ts runs it under the real python3 against a real headless Chrome).
48
+ *
49
+ * WebSocket is hand-rolled because python's stdlib has no client: one masked text frame out, frames
50
+ * in until the reply with id 1 arrives, 1.5 s budget, and NO Origin header (Chrome refuses
51
+ * cross-origin DevTools sockets unless --remote-allow-origins is set; a header-less client is a
52
+ * local one). urllib is opened WITHOUT proxy handlers so a sandbox-wide http_proxy cannot redirect
53
+ * a loopback read.
54
+ */
55
+ export declare const CHROME_CDP_PROBE_PY: string;
56
+ /** The exact shell command a sandbox runs for one probe. */
57
+ export declare function chromeCdpProbeCommand(args: ChromeCdpProbeArgs): string;
58
+ /**
59
+ * Narrow one probe's stdout. A parse failure is reported as unavailable with the reason, never as
60
+ * an empty success: the difference between "nothing to observe" and "could not observe" is the
61
+ * whole point of #514.
62
+ */
63
+ export declare function parseChromeCdpProbeOutput(stdout: string | undefined): ChromeCdpProbeResult;
@@ -0,0 +1,288 @@
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, 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 evaluate(ws_url, expression, timeout=1.5):
77
+ match = re.match(r"^ws://([^/:]+):(\d+)(/.*)$", str(ws_url or ""))
78
+ if not match:
79
+ return None
80
+ host, port, path = match.group(1), int(match.group(2)), match.group(3)
81
+ sock = None
82
+ try:
83
+ sock = socket.create_connection((host, port), timeout=timeout)
84
+ sock.settimeout(timeout)
85
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
86
+ handshake = (
87
+ "GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
88
+ "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, host, port, key)
89
+ )
90
+ sock.sendall(handshake.encode("ascii"))
91
+ buffer = b""
92
+ while b"\r\n\r\n" not in buffer:
93
+ chunk = sock.recv(4096)
94
+ if not chunk:
95
+ return None
96
+ buffer += chunk
97
+ head, buffer = buffer.split(b"\r\n\r\n", 1)
98
+ if b" 101 " not in head.split(b"\r\n", 1)[0]:
99
+ return None
100
+ payload = json.dumps({
101
+ "id": 1,
102
+ "method": "Runtime.evaluate",
103
+ "params": {"returnByValue": True, "expression": expression},
104
+ }).encode("utf-8")
105
+ mask = os.urandom(4)
106
+ frame = bytearray([0x81])
107
+ size = len(payload)
108
+ if size < 126:
109
+ frame.append(0x80 | size)
110
+ elif size < 65536:
111
+ frame.append(0x80 | 126)
112
+ frame += struct.pack(">H", size)
113
+ else:
114
+ frame.append(0x80 | 127)
115
+ frame += struct.pack(">Q", size)
116
+ frame += mask + bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
117
+ sock.sendall(bytes(frame))
118
+
119
+ state = {"buffer": buffer}
120
+
121
+ def need(count):
122
+ while len(state["buffer"]) < count:
123
+ chunk = sock.recv(65536)
124
+ if not chunk:
125
+ raise EOFError("socket closed")
126
+ state["buffer"] += chunk
127
+
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") == 1:
161
+ result = reply.get("result") or {}
162
+ inner = result.get("result") if isinstance(result, dict) else None
163
+ return inner.get("value") if isinstance(inner, dict) else None
164
+ except Exception:
165
+ return None
166
+ finally:
167
+ if sock is not None:
168
+ try:
169
+ sock.close()
170
+ except Exception:
171
+ pass
172
+
173
+ STATE_EXPRESSION = (
174
+ "({ url: location.href, title: document.title, "
175
+ "text: (document.body && document.body.innerText || '').slice(0, 20000), "
176
+ "scrollY: (window.scrollY || 0) })"
177
+ )
178
+ GEOMETRY_EXPRESSION = (
179
+ "({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, "
180
+ "viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })"
181
+ )
182
+
183
+ def main():
184
+ args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
185
+ mode = args.get("mode") or "state"
186
+ port = resolve_port(args)
187
+ if mode == "port":
188
+ print(json.dumps({"cdpPort": port}))
189
+ return
190
+ try:
191
+ pages = list_pages(port)
192
+ except Exception as error:
193
+ print(json.dumps({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)}))
194
+ return
195
+ page = select_page(pages, args)
196
+ if page is None:
197
+ print(json.dumps({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)}))
198
+ return
199
+ ws_url = page.get("webSocketDebuggerUrl")
200
+ if mode == "geometry":
201
+ result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
202
+ if not isinstance(result, dict):
203
+ print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
204
+ return
205
+ result["targetId"] = str(page.get("id") or "")
206
+ print(json.dumps(result))
207
+ return
208
+ url = str(page.get("url") or "")
209
+ title = str(page.get("title") or "")
210
+ text = ""
211
+ scroll_y = None
212
+ result = evaluate(ws_url, STATE_EXPRESSION) if ws_url else None
213
+ if isinstance(result, dict):
214
+ url = result["url"] if isinstance(result.get("url"), str) else url
215
+ title = result["title"] if isinstance(result.get("title"), str) else title
216
+ text = result["text"] if isinstance(result.get("text"), str) else ""
217
+ scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
218
+ print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y}))
219
+
220
+ main()
221
+ `;
222
+ function shellSingleQuote(value) {
223
+ return `'${value.replace(/'/g, "'\\''")}'`;
224
+ }
225
+ /** The exact shell command a sandbox runs for one probe. */
226
+ export function chromeCdpProbeCommand(args) {
227
+ const payload = { mode: args.mode, targetUrl: args.targetUrl };
228
+ if (args.cdpPort !== undefined)
229
+ payload.cdpPort = args.cdpPort;
230
+ if (args.profileDir !== undefined)
231
+ payload.profileDir = args.profileDir;
232
+ if (args.targetId !== undefined)
233
+ payload.targetId = args.targetId;
234
+ if (args.prefer !== undefined)
235
+ payload.prefer = args.prefer;
236
+ return `python3 -c ${shellSingleQuote(CHROME_CDP_PROBE_PY)} ${shellSingleQuote(JSON.stringify(payload))}`;
237
+ }
238
+ /**
239
+ * Narrow one probe's stdout. A parse failure is reported as unavailable with the reason, never as
240
+ * an empty success: the difference between "nothing to observe" and "could not observe" is the
241
+ * whole point of #514.
242
+ */
243
+ export function parseChromeCdpProbeOutput(stdout) {
244
+ const trimmed = (stdout ?? "").trim();
245
+ if (trimmed.length === 0)
246
+ return { unavailable: "probe printed nothing" };
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(trimmed);
250
+ }
251
+ catch {
252
+ return { unavailable: "probe output was not JSON" };
253
+ }
254
+ if (!parsed || typeof parsed !== "object")
255
+ return { unavailable: "probe output was not an object" };
256
+ const record = parsed;
257
+ if (typeof record.unavailable === "string")
258
+ return { unavailable: record.unavailable };
259
+ const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
260
+ const box = (value, keys) => {
261
+ if (!value || typeof value !== "object")
262
+ return undefined;
263
+ const source = value;
264
+ const out = {};
265
+ for (const key of keys) {
266
+ const n = numberOr(source[key]);
267
+ if (n === undefined)
268
+ return undefined;
269
+ out[key] = n;
270
+ }
271
+ return out;
272
+ };
273
+ const browserWindow = box(record.browserWindow, ["x", "y", "width", "height"]);
274
+ const viewport = box(record.viewport, ["width", "height", "deviceScaleFactor"]);
275
+ const cdpPort = numberOr(record.cdpPort);
276
+ const scrollY = numberOr(record.scrollY);
277
+ return {
278
+ ...(cdpPort === undefined ? {} : { cdpPort }),
279
+ ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
280
+ ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
281
+ ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
282
+ ...(scrollY === undefined ? {} : { scrollY }),
283
+ ...(typeof record.targetId === "string" && record.targetId.length > 0 ? { targetId: record.targetId } : {}),
284
+ ...(browserWindow === undefined ? {} : { browserWindow }),
285
+ ...(viewport === undefined ? {} : { viewport })
286
+ };
287
+ }
288
+ //# 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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"}
@@ -661,9 +661,9 @@ export declare function desktopBrowserFamily(value: string | undefined): Desktop
661
661
  /**
662
662
  * Runtime-only CDP endpoint attribution for the exact chromium this lane launched. Port
663
663
  * 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
664
+ * probe re-reads `profileDir`'s DevToolsActivePort marker (a slow cold start can publish it
665
665
  * 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.
666
+ * where a dead endpoint degrades into an honest warning that names the cause.
667
667
  */
668
668
  export interface ChromeCdpEndpoint {
669
669
  cdpPort?: number;
@@ -673,14 +673,18 @@ export interface ChromeCdpEndpoint {
673
673
  targetUrl: string;
674
674
  }
675
675
  /**
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.
676
+ * The URL / title / page-text / scroll observer behind stopWhen and task criteria. One probe per
677
+ * observation, run on the sandbox's python3 (see chrome-cdp-probe.ts for why not node: #514).
678
+ *
679
+ * "active": follow the participant to whatever tab they are driving now — never pin the state
680
+ * observer to the launch tab (a verification link that opened in a NEW tab left a pinned observer
681
+ * reading the old tab forever).
682
+ *
683
+ * `onUnavailable` fires ONCE, on the first probe that could not read the page, with the reason.
684
+ * The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
685
+ * url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
681
686
  */
682
- export declare function chromeCdpPortResolutionScript(endpoint: ChromeCdpEndpoint): string[];
683
- export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string): () => Promise<{
687
+ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<{
684
688
  url?: string;
685
689
  title?: string;
686
690
  text?: string;
@@ -690,8 +694,10 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
690
694
  * Read the running browser's actual outer-window bounds and CSS layout viewport through the
691
695
  * already-enabled local Chrome DevTools endpoint. The returned values come from `window.*` in
692
696
  * the target page; requested E2B resolution is deliberately not an input to this function.
697
+ * `undefined` carries the reason the measurement is missing via `onUnavailable`, so the geometry
698
+ * warning can name the cause (a dead CDP endpoint, no python3) instead of only the symptom.
693
699
  */
694
- export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
700
+ export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
695
701
  targetId?: string;
696
702
  }) | undefined>;
697
703
  /** Shared hosted-browser geometry capture used by per-lane and sequential shared-world routes. */