humanish 0.75.0 → 0.77.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 +30 -11
- package/dist/chrome-cdp-probe.d.ts +34 -2
- package/dist/chrome-cdp-probe.js +291 -56
- package/dist/chrome-cdp-probe.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +35 -1
- package/dist/cua-actor-lab.js +220 -12
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/e2b-desktop-launch.d.ts +33 -1
- package/dist/e2b-desktop-launch.js +49 -4
- package/dist/e2b-desktop-launch.js.map +1 -1
- package/dist/feedback.d.ts +20 -5
- package/dist/feedback.js +47 -16
- package/dist/feedback.js.map +1 -1
- package/dist/lab-config.d.ts +21 -0
- package/dist/lab-config.js +30 -0
- package/dist/lab-config.js.map +1 -1
- package/dist/openai-responses-cu.d.ts +14 -0
- package/dist/openai-responses-cu.js +55 -2
- package/dist/openai-responses-cu.js.map +1 -1
- package/dist/program.js +26 -8
- package/dist/program.js.map +1 -1
- package/dist/run.d.ts +44 -0
- package/dist/run.js.map +1 -1
- package/dist/telemetry.d.ts +10 -0
- package/dist/telemetry.js +12 -0
- package/dist/telemetry.js.map +1 -1
- package/docs/contracts/schemas.md +4 -2
- package/docs/goals/current.md +39 -2
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
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/`):**
|
|
57
|
-
defects over
|
|
58
|
-
did not write, 16 of 18 distinct findings confirmed against the source and 0 invented,
|
|
59
|
-
participants;
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
**What it finds, measured (2026-09-01 to 09-04, receipts in `bench/` and `docs/goals/`):** 58 of
|
|
57
|
+
60 planted defects over four benchmark runs on an app we wrote, none invented in 15 clean runs; 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
|
|
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,28 @@ 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.
|
|
467
|
-
device-pixel-ratio isn't rendered, and the user-agent stays desktop on this
|
|
468
|
-
|
|
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
|
+
viewport and DPR override cover the launch tab (the user agent and touch flags are browser-wide);
|
|
487
|
+
if an observation reads a tab the participant opened later, the lane records one warning saying
|
|
488
|
+
so, because that tab laid out at the window width. A bundle without a `fidelity` block is a
|
|
489
|
+
responsive-viewport study whatever its preset is called.
|
|
490
|
+
|
|
472
491
|
**Desktop browser choice.** Hosted computer-use lanes and shared-world actor seats use the
|
|
473
492
|
route's historical opener unless you set `execution.desktop.browser` to `chrome`, `chromium`,
|
|
474
493
|
or `firefox`. A concrete value means "launch this browser or fail"; it never silently
|
|
@@ -18,8 +18,36 @@ export interface ChromeCdpProbeArgs {
|
|
|
18
18
|
* funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
|
|
19
19
|
*/
|
|
20
20
|
prefer?: ChromeCdpPagePreference;
|
|
21
|
-
/**
|
|
22
|
-
|
|
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 over a browser-level
|
|
25
|
+
* socket, then stay attached until killed (how a lane keeps them for its whole life) and attach
|
|
26
|
+
* to every page target Chrome opens later, sending it the same overrides and a reload the moment
|
|
27
|
+
* it exists, never pausing it (#623). "fidelity": read
|
|
28
|
+
* back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
|
|
29
|
+
*/
|
|
30
|
+
mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
|
|
31
|
+
/** For "emulate": what to apply. */
|
|
32
|
+
emulation?: ChromeMobileEmulationRequest;
|
|
33
|
+
}
|
|
34
|
+
/** Mobile emulation request (#221): the CDP Emulation domain applied to one page target. */
|
|
35
|
+
export interface ChromeMobileEmulationRequest {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
deviceScaleFactor: number;
|
|
39
|
+
touch: boolean;
|
|
40
|
+
userAgent: string;
|
|
41
|
+
platform?: string;
|
|
42
|
+
}
|
|
43
|
+
/** What the page reports after emulation: the proof, never copied from the request. */
|
|
44
|
+
export interface ChromeFidelityRead {
|
|
45
|
+
userAgent: string;
|
|
46
|
+
devicePixelRatio: number;
|
|
47
|
+
innerWidth: number;
|
|
48
|
+
innerHeight: number;
|
|
49
|
+
maxTouchPoints: number;
|
|
50
|
+
coarsePointer: boolean;
|
|
23
51
|
}
|
|
24
52
|
/** The probe's stdout, before the caller narrows it. */
|
|
25
53
|
export interface ChromeCdpProbeResult {
|
|
@@ -41,6 +69,10 @@ export interface ChromeCdpProbeResult {
|
|
|
41
69
|
height: number;
|
|
42
70
|
deviceScaleFactor: number;
|
|
43
71
|
};
|
|
72
|
+
/** "emulate": the CDP methods that returned without error, in order. */
|
|
73
|
+
applied?: string[];
|
|
74
|
+
/** "fidelity": the read-back. */
|
|
75
|
+
fidelity?: ChromeFidelityRead;
|
|
44
76
|
}
|
|
45
77
|
/**
|
|
46
78
|
* The probe itself. Kept as one string so the shipped command is exactly what the tests execute
|
package/dist/chrome-cdp-probe.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* a loopback read.
|
|
29
29
|
*/
|
|
30
30
|
export const CHROME_CDP_PROBE_PY = String.raw `
|
|
31
|
-
import base64, json, os, re, socket, struct, sys, urllib.request
|
|
31
|
+
import base64, json, os, re, socket, struct, sys, time, urllib.request
|
|
32
32
|
|
|
33
33
|
def resolve_port(args):
|
|
34
34
|
port = args.get("cdpPort")
|
|
@@ -73,35 +73,46 @@ def select_page(pages, args):
|
|
|
73
73
|
return match
|
|
74
74
|
return http_pages[0] if len(http_pages) == 1 else None
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
sock.
|
|
76
|
+
class Ws:
|
|
77
|
+
"""One DevTools WebSocket: hand-rolled client frames, JSON in and out; events that arrive while
|
|
78
|
+
a reply is awaited are kept in .events for the caller."""
|
|
79
|
+
def __init__(self, ws_url, timeout=1.5):
|
|
80
|
+
match = re.match(r"^ws://([^/:]+):(\d+)(/.*)$", str(ws_url or ""))
|
|
81
|
+
if not match:
|
|
82
|
+
raise ValueError("not a ws url")
|
|
83
|
+
host, port, path = match.group(1), int(match.group(2)), match.group(3)
|
|
84
|
+
self.sock = socket.create_connection((host, port), timeout=timeout)
|
|
85
|
+
self.sock.settimeout(timeout)
|
|
85
86
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
86
|
-
|
|
87
|
+
self.sock.sendall((
|
|
87
88
|
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
88
89
|
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, host, port, key)
|
|
89
|
-
)
|
|
90
|
-
sock.sendall(handshake.encode("ascii"))
|
|
90
|
+
).encode("ascii"))
|
|
91
91
|
buffer = b""
|
|
92
92
|
while b"\r\n\r\n" not in buffer:
|
|
93
|
-
chunk = sock.recv(4096)
|
|
93
|
+
chunk = self.sock.recv(4096)
|
|
94
94
|
if not chunk:
|
|
95
|
-
|
|
95
|
+
raise EOFError("closed during the handshake")
|
|
96
96
|
buffer += chunk
|
|
97
|
-
head, buffer = buffer.split(b"\r\n\r\n", 1)
|
|
97
|
+
head, self.buffer = buffer.split(b"\r\n\r\n", 1)
|
|
98
98
|
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
raise EOFError("handshake refused")
|
|
100
|
+
self.next_id = 0
|
|
101
|
+
self.events = []
|
|
102
|
+
|
|
103
|
+
def need(self, count):
|
|
104
|
+
while len(self.buffer) < count:
|
|
105
|
+
chunk = self.sock.recv(65536)
|
|
106
|
+
if not chunk:
|
|
107
|
+
raise EOFError("socket closed")
|
|
108
|
+
self.buffer += chunk
|
|
109
|
+
|
|
110
|
+
def send(self, method, params=None, session_id=None):
|
|
111
|
+
self.next_id += 1
|
|
112
|
+
message = {"id": self.next_id, "method": method, "params": params or {}}
|
|
113
|
+
if session_id:
|
|
114
|
+
message["sessionId"] = session_id
|
|
115
|
+
payload = json.dumps(message).encode("utf-8")
|
|
105
116
|
mask = os.urandom(4)
|
|
106
117
|
frame = bytearray([0x81])
|
|
107
118
|
size = len(payload)
|
|
@@ -114,36 +125,30 @@ def evaluate(ws_url, expression, timeout=1.5):
|
|
|
114
125
|
frame.append(0x80 | 127)
|
|
115
126
|
frame += struct.pack(">Q", size)
|
|
116
127
|
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
|
|
128
|
+
self.sock.sendall(bytes(frame))
|
|
129
|
+
return self.next_id
|
|
127
130
|
|
|
131
|
+
def recv(self):
|
|
132
|
+
"""The next JSON message (a reply or an event), or None once the socket closes."""
|
|
128
133
|
message = b""
|
|
129
134
|
while True:
|
|
130
|
-
need(2)
|
|
131
|
-
first, second =
|
|
135
|
+
self.need(2)
|
|
136
|
+
first, second = self.buffer[0], self.buffer[1]
|
|
132
137
|
fin, opcode = first & 0x80, first & 0x0F
|
|
133
138
|
masked, length, offset = second & 0x80, second & 0x7F, 2
|
|
134
139
|
if length == 126:
|
|
135
|
-
need(4)
|
|
136
|
-
length, offset = struct.unpack(">H",
|
|
140
|
+
self.need(4)
|
|
141
|
+
length, offset = struct.unpack(">H", self.buffer[2:4])[0], 4
|
|
137
142
|
elif length == 127:
|
|
138
|
-
need(10)
|
|
139
|
-
length, offset = struct.unpack(">Q",
|
|
143
|
+
self.need(10)
|
|
144
|
+
length, offset = struct.unpack(">Q", self.buffer[2:10])[0], 10
|
|
140
145
|
frame_mask = b""
|
|
141
146
|
if masked:
|
|
142
|
-
need(offset + 4)
|
|
143
|
-
frame_mask, offset =
|
|
144
|
-
need(offset + length)
|
|
145
|
-
data =
|
|
146
|
-
|
|
147
|
+
self.need(offset + 4)
|
|
148
|
+
frame_mask, offset = self.buffer[offset:offset + 4], offset + 4
|
|
149
|
+
self.need(offset + length)
|
|
150
|
+
data = self.buffer[offset:offset + length]
|
|
151
|
+
self.buffer = self.buffer[offset + length:]
|
|
147
152
|
if masked:
|
|
148
153
|
data = bytes(byte ^ frame_mask[index % 4] for index, byte in enumerate(data))
|
|
149
154
|
if opcode == 8:
|
|
@@ -157,18 +162,47 @@ def evaluate(ws_url, expression, timeout=1.5):
|
|
|
157
162
|
except Exception:
|
|
158
163
|
return None
|
|
159
164
|
message = b""
|
|
160
|
-
if isinstance(reply, dict)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
if isinstance(reply, dict):
|
|
166
|
+
return reply
|
|
167
|
+
|
|
168
|
+
def call(self, method, params=None, session_id=None):
|
|
169
|
+
"""Send one command and wait for its reply; events seen on the way are queued."""
|
|
170
|
+
wanted = self.send(method, params, session_id)
|
|
171
|
+
while True:
|
|
172
|
+
reply = self.recv()
|
|
173
|
+
if reply is None:
|
|
174
|
+
return None
|
|
175
|
+
if reply.get("id") == wanted:
|
|
176
|
+
return reply
|
|
177
|
+
if "method" in reply:
|
|
178
|
+
self.events.append(reply)
|
|
179
|
+
|
|
180
|
+
def close(self):
|
|
181
|
+
try:
|
|
182
|
+
self.sock.close()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def ws_session(ws_url, messages, timeout=1.5):
|
|
187
|
+
"""Send CDP messages over one page socket, in order, and return their replies (None on failure)."""
|
|
188
|
+
try:
|
|
189
|
+
ws = Ws(ws_url, timeout)
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
return [ws.call(message["method"], message.get("params")) for message in messages]
|
|
164
194
|
except Exception:
|
|
165
195
|
return None
|
|
166
196
|
finally:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
ws.close()
|
|
198
|
+
|
|
199
|
+
def evaluate(ws_url, expression, timeout=1.5):
|
|
200
|
+
replies = ws_session(ws_url, [{"method": "Runtime.evaluate", "params": {"returnByValue": True, "expression": expression}}], timeout)
|
|
201
|
+
if not replies or not isinstance(replies[0], dict):
|
|
202
|
+
return None
|
|
203
|
+
result = replies[0].get("result") or {}
|
|
204
|
+
inner = result.get("result") if isinstance(result, dict) else None
|
|
205
|
+
return inner.get("value") if isinstance(inner, dict) else None
|
|
172
206
|
|
|
173
207
|
STATE_EXPRESSION = (
|
|
174
208
|
"({ url: location.href, title: document.title, "
|
|
@@ -179,6 +213,162 @@ GEOMETRY_EXPRESSION = (
|
|
|
179
213
|
"({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, "
|
|
180
214
|
"viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })"
|
|
181
215
|
)
|
|
216
|
+
FIDELITY_EXPRESSION = (
|
|
217
|
+
"({ userAgent: navigator.userAgent, devicePixelRatio: window.devicePixelRatio, "
|
|
218
|
+
"innerWidth: window.innerWidth, innerHeight: window.innerHeight, "
|
|
219
|
+
"maxTouchPoints: navigator.maxTouchPoints || 0, "
|
|
220
|
+
"coarsePointer: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) })"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def emulation_messages(request, reload):
|
|
224
|
+
"""The Emulation domain for one page session; reload only where scripts already ran at load."""
|
|
225
|
+
width = int(request.get("width") or 0)
|
|
226
|
+
height = int(request.get("height") or 0)
|
|
227
|
+
scale = float(request.get("deviceScaleFactor") or 1)
|
|
228
|
+
user_agent = str(request.get("userAgent") or "")
|
|
229
|
+
platform = str(request.get("platform") or "")
|
|
230
|
+
messages = [
|
|
231
|
+
{"method": "Emulation.setDeviceMetricsOverride", "params": {
|
|
232
|
+
"width": width, "height": height, "deviceScaleFactor": scale, "mobile": True,
|
|
233
|
+
"screenWidth": width, "screenHeight": height}},
|
|
234
|
+
]
|
|
235
|
+
if request.get("touch"):
|
|
236
|
+
messages.append({"method": "Emulation.setTouchEmulationEnabled", "params": {"enabled": True, "maxTouchPoints": 5}})
|
|
237
|
+
messages.append({"method": "Emulation.setEmitTouchEventsForMouse", "params": {"enabled": True, "configuration": "mobile"}})
|
|
238
|
+
if user_agent:
|
|
239
|
+
params = {"userAgent": user_agent}
|
|
240
|
+
if platform:
|
|
241
|
+
params["platform"] = platform
|
|
242
|
+
messages.append({"method": "Emulation.setUserAgentOverride", "params": params})
|
|
243
|
+
if reload:
|
|
244
|
+
messages.append({"method": "Page.reload", "params": {}})
|
|
245
|
+
return messages
|
|
246
|
+
|
|
247
|
+
def applied_of(messages, replies):
|
|
248
|
+
applied = []
|
|
249
|
+
for message, reply in zip(messages, replies):
|
|
250
|
+
if isinstance(reply, dict) and "error" not in reply:
|
|
251
|
+
applied.append(message["method"])
|
|
252
|
+
else:
|
|
253
|
+
detail = (reply or {}).get("error", {}).get("message") if isinstance(reply, dict) else "no reply"
|
|
254
|
+
return applied, "%s failed: %s" % (message["method"], detail)
|
|
255
|
+
return applied, None
|
|
256
|
+
|
|
257
|
+
def emulate(ws_url, request):
|
|
258
|
+
"""One-shot: apply the Emulation domain to the page and reload it. The session-scoped overrides
|
|
259
|
+
lapse when this socket closes; the lane uses hold() instead."""
|
|
260
|
+
messages = emulation_messages(request, reload=True)
|
|
261
|
+
replies = ws_session(ws_url, messages, timeout=5)
|
|
262
|
+
if replies is None:
|
|
263
|
+
return None, "the page socket could not be opened"
|
|
264
|
+
return applied_of(messages, replies)
|
|
265
|
+
|
|
266
|
+
def apply_over(ws, request, session_id, reload):
|
|
267
|
+
messages = emulation_messages(request, reload)
|
|
268
|
+
return applied_of(messages, [ws.call(m["method"], m.get("params"), session_id) for m in messages])
|
|
269
|
+
|
|
270
|
+
def browser_ws_url(port):
|
|
271
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
272
|
+
with opener.open("http://127.0.0.1:%d/json/version" % port, timeout=2) as response:
|
|
273
|
+
info = json.loads(response.read().decode("utf-8"))
|
|
274
|
+
return str(info.get("webSocketDebuggerUrl") or "")
|
|
275
|
+
|
|
276
|
+
def hold(port, page, request):
|
|
277
|
+
"""Emulate the launch page and then every page target Chrome opens later, for as long as this
|
|
278
|
+
process lives (#221, #623). One browser-level socket with flattened sessions: the launch page is
|
|
279
|
+
attached by id and reloaded so scripts that read the UA at load see it; a target that appears
|
|
280
|
+
later is attached the moment it exists, sent the same overrides, and reloaded once after its
|
|
281
|
+
first real navigation commits (touch emulation reaches a document only when it loads under it).
|
|
282
|
+
Two things this loop must never do (measured on a real desktop, 2026-09-04): pause new targets
|
|
283
|
+
(waitForDebuggerOnStart) and wait for their replies. A popup a participant taps open shares its
|
|
284
|
+
opener's renderer, answers Emulation commands only once it runs, and a loop blocked on that
|
|
285
|
+
reply never resumed the tab (it loaded forever) and never reached the next one. Overrides are
|
|
286
|
+
sent without waiting; replies, errors included, are logged as they arrive.
|
|
287
|
+
Prints one JSON line per attached target; the first line is the announce the lane reads."""
|
|
288
|
+
launch_id = str(page.get("id") or "")
|
|
289
|
+
try:
|
|
290
|
+
ws = Ws(browser_ws_url(port), timeout=5)
|
|
291
|
+
except Exception as error:
|
|
292
|
+
print(json.dumps({"unavailable": "the browser socket could not be opened (%s)" % type(error).__name__}), flush=True)
|
|
293
|
+
return
|
|
294
|
+
attach = ws.call("Target.attachToTarget", {"targetId": launch_id, "flatten": True})
|
|
295
|
+
session_id = ((attach or {}).get("result") or {}).get("sessionId") if isinstance(attach, dict) else None
|
|
296
|
+
if not session_id:
|
|
297
|
+
print(json.dumps({"unavailable": "Target.attachToTarget failed for the launch page"}), flush=True)
|
|
298
|
+
return
|
|
299
|
+
covered = {launch_id}
|
|
300
|
+
applied, failure = apply_over(ws, request, session_id, reload=True)
|
|
301
|
+
print(json.dumps({"applied": applied, "held": failure is None, "targetId": launch_id, **({"unavailable": failure} if failure else {})}), flush=True)
|
|
302
|
+
if failure:
|
|
303
|
+
return
|
|
304
|
+
auto = ws.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
|
|
305
|
+
if not isinstance(auto, dict) or "error" in auto:
|
|
306
|
+
print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; later tabs are not emulated"}), flush=True)
|
|
307
|
+
ws.sock.settimeout(None)
|
|
308
|
+
pending = {}
|
|
309
|
+
# Sessions of later targets that still owe one reload: touch emulation reaches a document only
|
|
310
|
+
# when it loads under the override (measured: a popup's first document reported
|
|
311
|
+
# maxTouchPoints 0 with the viewport already 414 px), so each later tab is reloaded once after
|
|
312
|
+
# its first real navigation commits, or at once when it had already committed at attach time.
|
|
313
|
+
reload_owed = {}
|
|
314
|
+
while True:
|
|
315
|
+
try:
|
|
316
|
+
message = ws.events.pop(0) if ws.events else ws.recv()
|
|
317
|
+
except Exception as error:
|
|
318
|
+
print(json.dumps({"holderExit": "%s: %s" % (type(error).__name__, error)}), flush=True)
|
|
319
|
+
return
|
|
320
|
+
if message is None:
|
|
321
|
+
print(json.dumps({"holderExit": "browser socket closed"}), flush=True)
|
|
322
|
+
return
|
|
323
|
+
reply_id = message.get("id")
|
|
324
|
+
session = message.get("sessionId")
|
|
325
|
+
if reply_id in pending:
|
|
326
|
+
method = pending.pop(reply_id)
|
|
327
|
+
if "error" in message:
|
|
328
|
+
print(json.dumps({"replyError": method, "message": str((message.get("error") or {}).get("message"))}), flush=True)
|
|
329
|
+
elif method == "href" and session in reload_owed:
|
|
330
|
+
# The navigation may have committed before Page.enable could report it; the page's
|
|
331
|
+
# own location says so, and the reload it is owed goes out now (once: pop guards it).
|
|
332
|
+
href = str((((message.get("result") or {}).get("result") or {}).get("value")) or "")
|
|
333
|
+
if href and not href.startswith("about:"):
|
|
334
|
+
target_id = reload_owed.pop(session)
|
|
335
|
+
pending[ws.send("Page.reload", {}, session)] = "Page.reload"
|
|
336
|
+
print(json.dumps({"reloaded": target_id, "url": href, "by": "href"}), flush=True)
|
|
337
|
+
continue
|
|
338
|
+
method = message.get("method")
|
|
339
|
+
params = message.get("params") or {}
|
|
340
|
+
if method == "Page.frameNavigated" and session in reload_owed:
|
|
341
|
+
frame = params.get("frame") or {}
|
|
342
|
+
url = str(frame.get("url") or "")
|
|
343
|
+
if not frame.get("parentId") and url and not url.startswith("about:"):
|
|
344
|
+
target_id = reload_owed.pop(session)
|
|
345
|
+
pending[ws.send("Page.reload", {}, session)] = "Page.reload"
|
|
346
|
+
print(json.dumps({"reloaded": target_id, "url": url}), flush=True)
|
|
347
|
+
continue
|
|
348
|
+
if method != "Target.attachedToTarget":
|
|
349
|
+
continue
|
|
350
|
+
info = params.get("targetInfo") or {}
|
|
351
|
+
new_session = params.get("sessionId")
|
|
352
|
+
target_id = str(info.get("targetId") or "")
|
|
353
|
+
if info.get("type") == "page" and target_id and target_id not in covered:
|
|
354
|
+
covered.add(target_id)
|
|
355
|
+
messages = emulation_messages(request, reload=False)
|
|
356
|
+
for m in messages:
|
|
357
|
+
pending[ws.send(m["method"], m.get("params"), new_session)] = m["method"]
|
|
358
|
+
pending[ws.send("Page.enable", {}, new_session)] = "Page.enable"
|
|
359
|
+
url = str(info.get("url") or "")
|
|
360
|
+
if url and not url.startswith("about:"):
|
|
361
|
+
pending[ws.send("Page.reload", {}, new_session)] = "Page.reload"
|
|
362
|
+
print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages] + ["Page.reload"], "url": url}), flush=True)
|
|
363
|
+
else:
|
|
364
|
+
reload_owed[new_session] = target_id
|
|
365
|
+
# Two ways to learn the first real navigation committed, whichever answers first:
|
|
366
|
+
# Page.frameNavigated (if Page.enable landed before the commit) or the page's own
|
|
367
|
+
# location.href (if it did not).
|
|
368
|
+
pending[ws.send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True}, new_session)] = "href"
|
|
369
|
+
print(json.dumps({"attached": target_id, "sent": [m["method"] for m in messages], "reloadAfterNavigation": True}), flush=True)
|
|
370
|
+
if params.get("waitingForDebugger"):
|
|
371
|
+
ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
|
|
182
372
|
|
|
183
373
|
def main():
|
|
184
374
|
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
@@ -196,7 +386,30 @@ def main():
|
|
|
196
386
|
if page is None:
|
|
197
387
|
print(json.dumps({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)}))
|
|
198
388
|
return
|
|
199
|
-
|
|
389
|
+
# Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
|
|
390
|
+
# page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
|
|
391
|
+
ws_url = page.get("webSocketDebuggerUrl") or (
|
|
392
|
+
"ws://127.0.0.1:%d/devtools/page/%s" % (port, page.get("id")) if page.get("id") else None
|
|
393
|
+
)
|
|
394
|
+
if mode == "emulate":
|
|
395
|
+
applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
|
|
396
|
+
if failure is not None:
|
|
397
|
+
print(json.dumps({"unavailable": failure, "applied": applied or []}))
|
|
398
|
+
return
|
|
399
|
+
print(json.dumps({"applied": applied, "targetId": str(page.get("id") or "")}))
|
|
400
|
+
return
|
|
401
|
+
if mode == "hold":
|
|
402
|
+
# Prints its announce line, then stays attached (and attaches to every later page target)
|
|
403
|
+
# until killed.
|
|
404
|
+
hold(port, page, args.get("emulation") or {})
|
|
405
|
+
return
|
|
406
|
+
if mode == "fidelity":
|
|
407
|
+
result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
|
|
408
|
+
if not isinstance(result, dict):
|
|
409
|
+
print(json.dumps({"unavailable": "Runtime.evaluate over the page socket returned nothing"}))
|
|
410
|
+
return
|
|
411
|
+
print(json.dumps({"fidelity": result, "targetId": str(page.get("id") or "")}))
|
|
412
|
+
return
|
|
200
413
|
if mode == "geometry":
|
|
201
414
|
result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
|
|
202
415
|
if not isinstance(result, dict):
|
|
@@ -215,7 +428,7 @@ def main():
|
|
|
215
428
|
title = result["title"] if isinstance(result.get("title"), str) else title
|
|
216
429
|
text = result["text"] if isinstance(result.get("text"), str) else ""
|
|
217
430
|
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}))
|
|
431
|
+
print(json.dumps({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")}))
|
|
219
432
|
|
|
220
433
|
main()
|
|
221
434
|
`;
|
|
@@ -233,6 +446,8 @@ export function chromeCdpProbeCommand(args) {
|
|
|
233
446
|
payload.targetId = args.targetId;
|
|
234
447
|
if (args.prefer !== undefined)
|
|
235
448
|
payload.prefer = args.prefer;
|
|
449
|
+
if (args.emulation !== undefined)
|
|
450
|
+
payload.emulation = args.emulation;
|
|
236
451
|
return `python3 -c ${shellSingleQuote(CHROME_CDP_PROBE_PY)} ${shellSingleQuote(JSON.stringify(payload))}`;
|
|
237
452
|
}
|
|
238
453
|
/**
|
|
@@ -254,8 +469,12 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
254
469
|
if (!parsed || typeof parsed !== "object")
|
|
255
470
|
return { unavailable: "probe output was not an object" };
|
|
256
471
|
const record = parsed;
|
|
257
|
-
|
|
258
|
-
|
|
472
|
+
const applied = Array.isArray(record.applied)
|
|
473
|
+
? record.applied.filter((item) => typeof item === "string")
|
|
474
|
+
: undefined;
|
|
475
|
+
if (typeof record.unavailable === "string") {
|
|
476
|
+
return { unavailable: record.unavailable, ...(applied === undefined ? {} : { applied }) };
|
|
477
|
+
}
|
|
259
478
|
const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
260
479
|
const box = (value, keys) => {
|
|
261
480
|
if (!value || typeof value !== "object")
|
|
@@ -274,7 +493,23 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
274
493
|
const viewport = box(record.viewport, ["width", "height", "deviceScaleFactor"]);
|
|
275
494
|
const cdpPort = numberOr(record.cdpPort);
|
|
276
495
|
const scrollY = numberOr(record.scrollY);
|
|
496
|
+
const fidelity = (() => {
|
|
497
|
+
const raw = record.fidelity;
|
|
498
|
+
if (!raw || typeof raw !== "object")
|
|
499
|
+
return undefined;
|
|
500
|
+
const source = raw;
|
|
501
|
+
const dpr = numberOr(source.devicePixelRatio);
|
|
502
|
+
const innerWidth = numberOr(source.innerWidth);
|
|
503
|
+
const innerHeight = numberOr(source.innerHeight);
|
|
504
|
+
const maxTouchPoints = numberOr(source.maxTouchPoints);
|
|
505
|
+
if (typeof source.userAgent !== "string" || dpr === undefined || innerWidth === undefined || innerHeight === undefined || maxTouchPoints === undefined) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return { userAgent: source.userAgent, devicePixelRatio: dpr, innerWidth, innerHeight, maxTouchPoints, coarsePointer: source.coarsePointer === true };
|
|
509
|
+
})();
|
|
277
510
|
return {
|
|
511
|
+
...(applied === undefined ? {} : { applied }),
|
|
512
|
+
...(fidelity === undefined ? {} : { fidelity }),
|
|
278
513
|
...(cdpPort === undefined ? {} : { cdpPort }),
|
|
279
514
|
...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
|
|
280
515
|
...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chrome-cdp-probe.js","sourceRoot":"","sources":["../src/chrome-cdp-probe.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,EAAE;AACF,iGAAiG;AACjG,8FAA8F;AAC9F,8FAA8F;AAC9F,kGAAkG;AAClG,0FAA0F;AAC1F,kGAAkG;AAClG,gGAAgG;AAChG,mGAAmG;AACnG,oDAAoD;AACpD,EAAE;AACF,kGAAkG;AAClG,4FAA4F;AAC5F,EAAE;AACF,8EAA8E;AAC9E,kGAAkG;AAClG,gGAAgG;AAChG,iEAAiE;
|
|
1
|
+
{"version":3,"file":"chrome-cdp-probe.js","sourceRoot":"","sources":["../src/chrome-cdp-probe.ts"],"names":[],"mappings":"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"}
|
package/dist/cua-actor-lab.d.ts
CHANGED
|
@@ -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";
|
|
@@ -684,7 +685,26 @@ export interface ChromeCdpEndpoint {
|
|
|
684
685
|
* The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
|
|
685
686
|
* url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
|
|
686
687
|
*/
|
|
687
|
-
export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void
|
|
688
|
+
export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void,
|
|
689
|
+
/**
|
|
690
|
+
* Mobile emulation on later tabs (#623): the holder attaches to every page target Chrome opens
|
|
691
|
+
* after the launch page, so a tab the participant opens later should lay out at the phone width
|
|
692
|
+
* too. The first observation on each new target reads that page's OWN report; a target that
|
|
693
|
+
* reports the requested width is recorded through `onCovered`, and one that does not (or cannot
|
|
694
|
+
* be read) fires `onDrift` once, so a phone-labelled lane that spent part of its session at
|
|
695
|
+
* desktop layout says so with the number the page gave.
|
|
696
|
+
*/
|
|
697
|
+
drift?: {
|
|
698
|
+
emulatedTargetId: string;
|
|
699
|
+
expectedWidth: number;
|
|
700
|
+
expectTouch?: boolean;
|
|
701
|
+
onDrift: (reason: string) => void;
|
|
702
|
+
onCovered?: (targetId: string, read: {
|
|
703
|
+
innerWidth: number;
|
|
704
|
+
devicePixelRatio: number;
|
|
705
|
+
maxTouchPoints: number;
|
|
706
|
+
}) => void;
|
|
707
|
+
}): () => Promise<{
|
|
688
708
|
url?: string;
|
|
689
709
|
title?: string;
|
|
690
710
|
text?: string;
|
|
@@ -700,6 +720,20 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
|
|
|
700
720
|
export declare function makeChromeDesktopGeometryObserver(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId?: string, onUnavailable?: (reason: string) => void): () => Promise<(Pick<RunDesktopGeometry, "browserWindow" | "viewport"> & {
|
|
701
721
|
targetId?: string;
|
|
702
722
|
}) | undefined>;
|
|
723
|
+
/** The user agent a mobile-emulated lane presents unless the lab sets its own. */
|
|
724
|
+
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";
|
|
725
|
+
/**
|
|
726
|
+
* Apply mobile emulation (#221) to the lane's launch page and read back what the page reports.
|
|
727
|
+
* Fails CLOSED: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
728
|
+
* the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
|
|
729
|
+
* (the emulation was applied; only the proof is missing).
|
|
730
|
+
*/
|
|
731
|
+
export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, targetId: string | undefined, request: ChromeMobileEmulationRequest): Promise<{
|
|
732
|
+
fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
|
|
733
|
+
warnings: string[];
|
|
734
|
+
targetId?: string;
|
|
735
|
+
holderName: string;
|
|
736
|
+
}>;
|
|
703
737
|
/** Shared hosted-browser geometry capture used by per-lane and sequential shared-world routes. */
|
|
704
738
|
export declare function captureDesktopBrowserGeometry(args: {
|
|
705
739
|
desktop: E2BDesktopSandbox;
|