humanish 0.136.0 → 0.137.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/actors/computer-use/actor.d.ts +2 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/observation.js +2 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -2
- package/dist/actors/computer-use/loop/session.js +8 -12
- package/dist/actors/computer-use/loop/types.d.ts +14 -1
- package/dist/evidence/browser-network.d.ts +108 -0
- package/dist/evidence/browser-network.js +516 -0
- package/dist/evidence/trace-page.d.ts +31 -1
- package/dist/evidence/trace-page.js +68 -2
- package/dist/observer-app.html +4 -4
- package/dist/routes/computer-use/bundle.js +1 -0
- package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
- package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
- package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
- package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
- package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
- package/dist/routes/computer-use/fanout-records.js +10 -0
- package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
- package/dist/routes/computer-use/participant-model.js +2 -1
- package/dist/routes/computer-use/plan.js +2 -1
- package/dist/routes/computer-use/single-bundle.d.ts +3 -0
- package/dist/routes/computer-use/single-bundle.js +4 -0
- package/dist/routes/computer-use/types.d.ts +7 -0
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/shared-world/plan.js +3 -2
- package/dist/routes/terminal/plan.js +2 -2
- package/dist/run/live-items.d.ts +20 -2
- package/dist/run/live-items.js +23 -14
- package/dist/run/streams.d.ts +7 -0
- package/dist/study/config.js +4 -1
- package/dist/study/keys.d.ts +3 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/parse/comms.js +1 -20
- package/dist/study/parse/execution.js +30 -1
- package/dist/study/parse/values.d.ts +5 -0
- package/dist/study/parse/values.js +22 -0
- package/dist/study/plan-base.js +6 -1
- package/dist/study/plan-types.d.ts +1 -1
- package/dist/study/study-fields.d.ts +7 -1
- package/dist/study/study-fields.js +10 -0
- package/dist/study/types.d.ts +11 -0
- package/dist/study/validation.d.ts +8 -0
- package/dist/study/validation.js +31 -2
- package/dist/substrates/detached.d.ts +5 -0
- package/dist/substrates/detached.js +9 -0
- package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
- package/dist/substrates/e2b/cdp-network-capture.js +194 -0
- package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
- package/dist/substrates/e2b/cdp-probe.js +100 -36
- package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
- package/dist/substrates/e2b/desktop-cdp.js +87 -5
- package/dist/verify/network.d.ts +10 -0
- package/dist/verify/network.js +162 -0
- package/dist/verify/pages.js +1 -16
- package/dist/verify/verify.js +8 -2
- package/docs/contracts/run-bundle.md +86 -1
- package/package.json +6 -6
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// What the in-sandbox holder keeps from a participant's browser traffic. The holder (cdp-probe.ts,
|
|
2
|
+
// `mode: "hold"`) already owns the one long-lived DevTools socket on a hosted desktop; with a
|
|
3
|
+
// network request it also enables the Network domain and appends one short JSON line per kept
|
|
4
|
+
// event to a file the host reads at teardown.
|
|
5
|
+
//
|
|
6
|
+
// The CDP events carry headers, cookies, post data, initiator stacks and full URLs. A line copies
|
|
7
|
+
// none of them: only the request id, the two clocks, method, resource type, status, cache flags,
|
|
8
|
+
// error text, and an address for an allowed origin, cut at the first `?` or `#` with user info
|
|
9
|
+
// removed. A request to any other origin gets a line with no address, method or type, so the host
|
|
10
|
+
// can count it, and nothing more is written for it. A method or resource type that is not plain
|
|
11
|
+
// letters is written as `Other`, and error text outside Chrome's `net::ERR_*` form as
|
|
12
|
+
// `net::ERR_OTHER`, so no free text reaches the file.
|
|
13
|
+
/** The segment a path the holder cut ends with: the marker tracePage ends a cut path with. */
|
|
14
|
+
const PATH_CUT = "[TRUNCATED]";
|
|
15
|
+
/**
|
|
16
|
+
* The capture half of the holder, spliced into CHROME_CDP_PROBE_PY so the shipped script stays one
|
|
17
|
+
* string. Line kinds: `start` (Network answered on the launch page), `request`, `response`,
|
|
18
|
+
* `servedFromCache`, `finished`, `failed`, `backForward` (a main-frame restore from the
|
|
19
|
+
* back-forward cache, which sends no request), `cap` (the file reached 32 MiB and writing stopped)
|
|
20
|
+
* and `exit` (the holder stopped; written in a finally). Ids are `<session>:<requestId>`, the
|
|
21
|
+
* session numbered in attach order from 0 for the launch page, because request ids repeat across
|
|
22
|
+
* renderers. `wallTime` is seconds since the epoch on the sandbox clock; `timestamp` is CDP's
|
|
23
|
+
* monotonic clock, for durations. A path longer than 4,096 characters ends at its last whole
|
|
24
|
+
* segment within them, then `/[TRUNCATED]`: the host redacts each segment, and a token cut in two
|
|
25
|
+
* can fall below the length its rules withhold.
|
|
26
|
+
*/
|
|
27
|
+
export const NETWORK_CAPTURE_PY = String.raw `
|
|
28
|
+
NETWORK_ENABLE = {"maxTotalBufferSize": 0, "maxResourceBufferSize": 0, "maxPostDataSize": 0}
|
|
29
|
+
NETWORK_EVENTS_CAP = 32 * 1024 * 1024
|
|
30
|
+
# Kept free under the cap so the cap and exit lines always fit.
|
|
31
|
+
NETWORK_TAIL_RESERVE = 512
|
|
32
|
+
NETWORK_PATH_LIMIT = 4096
|
|
33
|
+
WEB_ADDRESS = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://(?:[^/?#]*@)?([^/?#]*)([^?#]*)")
|
|
34
|
+
NETWORK_WORD = re.compile(r"^[A-Za-z]{1,32}$")
|
|
35
|
+
NETWORK_ERROR = re.compile(r"^net::ERR_[A-Z0-9_]{1,64}$")
|
|
36
|
+
|
|
37
|
+
def allowed_address(url, origins):
|
|
38
|
+
"""origin + path for an allowed origin, without user info, query or fragment; else None."""
|
|
39
|
+
match = WEB_ADDRESS.match(str(url or ""))
|
|
40
|
+
if not match:
|
|
41
|
+
return None
|
|
42
|
+
origin = ("%s://%s" % (match.group(1), match.group(2))).lower()
|
|
43
|
+
if origin not in origins:
|
|
44
|
+
return None
|
|
45
|
+
path = match.group(3) or "/"
|
|
46
|
+
if len(path) > NETWORK_PATH_LIMIT:
|
|
47
|
+
path = path[:path.rfind("/", 0, NETWORK_PATH_LIMIT + 1)] + "/${PATH_CUT}"
|
|
48
|
+
return origin + path
|
|
49
|
+
|
|
50
|
+
def network_word(value, fallback):
|
|
51
|
+
text = str(value or "")
|
|
52
|
+
return text if NETWORK_WORD.match(text) else fallback
|
|
53
|
+
|
|
54
|
+
def clock(value):
|
|
55
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
56
|
+
|
|
57
|
+
def response_fields(response):
|
|
58
|
+
fields = {}
|
|
59
|
+
if not isinstance(response, dict):
|
|
60
|
+
return fields
|
|
61
|
+
status = clock(response.get("status"))
|
|
62
|
+
if status is not None and 100 <= status <= 599:
|
|
63
|
+
fields["status"] = int(status)
|
|
64
|
+
for flag in ("fromDiskCache", "fromPrefetchCache", "fromServiceWorker"):
|
|
65
|
+
if response.get(flag) is True:
|
|
66
|
+
fields[flag] = True
|
|
67
|
+
return fields
|
|
68
|
+
|
|
69
|
+
class NetworkLog:
|
|
70
|
+
"""The events file of one holder. Writes go straight to the file, one line per write, so a
|
|
71
|
+
read while the holder runs sees whole lines except possibly the last."""
|
|
72
|
+
def __init__(self, request):
|
|
73
|
+
self.path = str(request.get("eventsPath") or "/tmp/humanish-network/events.ndjson")
|
|
74
|
+
self.origins = set(str(origin).rstrip("/").lower() for origin in request.get("origins") or [] if isinstance(origin, str))
|
|
75
|
+
folder = os.path.dirname(self.path)
|
|
76
|
+
if folder:
|
|
77
|
+
os.makedirs(folder, exist_ok=True)
|
|
78
|
+
self.handle = open(self.path, "ab", buffering=0)
|
|
79
|
+
self.written = os.fstat(self.handle.fileno()).st_size
|
|
80
|
+
self.capped = False
|
|
81
|
+
self.sessions = {}
|
|
82
|
+
# Ids whose current hop has an allowed address; only those get lines after the request.
|
|
83
|
+
self.open = set()
|
|
84
|
+
self.faults = 0
|
|
85
|
+
|
|
86
|
+
def session(self, session_id):
|
|
87
|
+
if session_id not in self.sessions:
|
|
88
|
+
self.sessions[session_id] = len(self.sessions)
|
|
89
|
+
return self.sessions[session_id]
|
|
90
|
+
|
|
91
|
+
def write(self, line, tail=False):
|
|
92
|
+
if self.capped and not tail:
|
|
93
|
+
return
|
|
94
|
+
data = (json.dumps({key: value for key, value in line.items() if value is not None}, separators=(",", ":")) + "\n").encode("utf-8")
|
|
95
|
+
room = NETWORK_EVENTS_CAP - (0 if tail else NETWORK_TAIL_RESERVE)
|
|
96
|
+
if self.written + len(data) > room:
|
|
97
|
+
if not tail:
|
|
98
|
+
self.capped = True
|
|
99
|
+
self.write({"e": "cap", "wallTime": time.time()}, tail=True)
|
|
100
|
+
return
|
|
101
|
+
self.handle.write(data)
|
|
102
|
+
self.written += len(data)
|
|
103
|
+
|
|
104
|
+
def event(self, method, params, session):
|
|
105
|
+
"""Record one CDP event. A fault is counted, never raised: emulation must keep running."""
|
|
106
|
+
try:
|
|
107
|
+
self.record(method, params, session)
|
|
108
|
+
except Exception:
|
|
109
|
+
self.faults += 1
|
|
110
|
+
|
|
111
|
+
def record(self, method, params, session):
|
|
112
|
+
# Past the cap nothing more is written, so nothing more is tracked.
|
|
113
|
+
if self.capped:
|
|
114
|
+
return
|
|
115
|
+
if method == "Page.frameNavigated":
|
|
116
|
+
frame = params.get("frame") or {}
|
|
117
|
+
if params.get("type") == "BackForwardCacheRestore" and not frame.get("parentId"):
|
|
118
|
+
self.write({"e": "backForward", "wallTime": time.time(), "url": allowed_address(frame.get("url"), self.origins)})
|
|
119
|
+
return
|
|
120
|
+
if not str(method).startswith("Network."):
|
|
121
|
+
return
|
|
122
|
+
kind = method[len("Network."):]
|
|
123
|
+
request_id = "%d:%s" % (self.session(session), str(params.get("requestId") or "")[:64])
|
|
124
|
+
timestamp = clock(params.get("timestamp"))
|
|
125
|
+
if kind == "requestWillBeSent":
|
|
126
|
+
request = params.get("request") or {}
|
|
127
|
+
line = {"e": "request", "id": request_id, "wallTime": clock(params.get("wallTime")), "timestamp": timestamp}
|
|
128
|
+
if "redirectResponse" in params:
|
|
129
|
+
line["redirectResponse"] = response_fields(params.get("redirectResponse"))
|
|
130
|
+
address = allowed_address(request.get("url"), self.origins)
|
|
131
|
+
if address is None:
|
|
132
|
+
self.open.discard(request_id)
|
|
133
|
+
else:
|
|
134
|
+
self.open.add(request_id)
|
|
135
|
+
line["url"] = address
|
|
136
|
+
line["method"] = network_word(request.get("method"), "Other")
|
|
137
|
+
line["type"] = network_word(params.get("type"), "Other")
|
|
138
|
+
self.write(line)
|
|
139
|
+
elif request_id not in self.open:
|
|
140
|
+
return
|
|
141
|
+
elif kind == "responseReceived":
|
|
142
|
+
line = {"e": "response", "id": request_id, "timestamp": timestamp}
|
|
143
|
+
line.update(response_fields(params.get("response")))
|
|
144
|
+
self.write(line)
|
|
145
|
+
elif kind == "requestServedFromCache":
|
|
146
|
+
self.write({"e": "servedFromCache", "id": request_id})
|
|
147
|
+
elif kind == "loadingFinished":
|
|
148
|
+
self.open.discard(request_id)
|
|
149
|
+
self.write({"e": "finished", "id": request_id, "timestamp": timestamp})
|
|
150
|
+
elif kind == "loadingFailed":
|
|
151
|
+
self.open.discard(request_id)
|
|
152
|
+
error = str(params.get("errorText") or "")
|
|
153
|
+
self.write({"e": "failed", "id": request_id, "timestamp": timestamp, "errorText": error if NETWORK_ERROR.match(error) else "net::ERR_OTHER"})
|
|
154
|
+
|
|
155
|
+
def close(self, reason):
|
|
156
|
+
line = {"e": "exit", "reason": str(reason)[:200]}
|
|
157
|
+
if self.faults:
|
|
158
|
+
line["faults"] = self.faults
|
|
159
|
+
try:
|
|
160
|
+
self.write(line, tail=True)
|
|
161
|
+
self.handle.close()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def start_capture(ws, session_id, network):
|
|
166
|
+
"""Network on the launch session, answered before any Emulation message goes out, so a mobile
|
|
167
|
+
participant's launch reload is captured. Returns the log, or None, and the announce field."""
|
|
168
|
+
try:
|
|
169
|
+
log = NetworkLog(network)
|
|
170
|
+
except Exception as error:
|
|
171
|
+
return None, {"unavailable": "the events file could not be opened (%s)" % type(error).__name__}
|
|
172
|
+
# Read before the call, so a request whose events queued behind the reply started after it.
|
|
173
|
+
started = time.time()
|
|
174
|
+
try:
|
|
175
|
+
reply = ws.call("Network.enable", NETWORK_ENABLE, session_id)
|
|
176
|
+
except Exception as error:
|
|
177
|
+
reply = {"error": {"message": type(error).__name__}}
|
|
178
|
+
if not isinstance(reply, dict) or "error" in reply:
|
|
179
|
+
detail = str(((reply or {}).get("error") or {}).get("message")) if isinstance(reply, dict) else "no reply"
|
|
180
|
+
log.close("Network.enable failed: %s" % detail)
|
|
181
|
+
return None, {"unavailable": "Network.enable failed on the launch page: %s" % detail}
|
|
182
|
+
try:
|
|
183
|
+
log.session(session_id)
|
|
184
|
+
log.write({"e": "start", "wallTime": started})
|
|
185
|
+
except Exception as error:
|
|
186
|
+
log.close("the start line could not be written: %s" % type(error).__name__)
|
|
187
|
+
return None, {"unavailable": "the events file could not be written (%s)" % type(error).__name__}
|
|
188
|
+
# Page events report back-forward restores; without them only those lines are missing.
|
|
189
|
+
try:
|
|
190
|
+
ws.call("Page.enable", {}, session_id)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
return log, {"startedAt": started}
|
|
194
|
+
`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
|
|
1
2
|
/** Which target to attribute when the endpoint lists several pages. */
|
|
2
3
|
export type ChromeCdpPagePreference = "pinned" | "active";
|
|
3
4
|
export interface ChromeCdpProbeArgs {
|
|
@@ -23,15 +24,17 @@ export interface ChromeCdpProbeArgs {
|
|
|
23
24
|
/**
|
|
24
25
|
* "state": url/title/text/scrollY. "geometry": outer window + CSS viewport. "port": resolution
|
|
25
26
|
* only. "emulate": apply mobile emulation to the selected page and exit (the overrides that
|
|
26
|
-
* are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold":
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* are session-scoped, UA / touch / DPR, lapse when the socket closes). "hold": stay attached over
|
|
28
|
+
* a browser-level socket until killed and attach to every page target Chrome opens later, never
|
|
29
|
+
* pausing it; it applies `emulation` (how a participant keeps the overrides for its whole life,
|
|
30
|
+
* with a reload on each page) and `network` capture, either or both. "fidelity": read
|
|
30
31
|
* back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
|
|
31
32
|
*/
|
|
32
33
|
mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
|
|
33
|
-
/** For "emulate": what to apply. */
|
|
34
|
+
/** For "emulate" and "hold": what to apply. */
|
|
34
35
|
emulation?: ChromeMobileEmulationRequest;
|
|
36
|
+
/** For "hold": capture each held page's requests to a sandbox file. */
|
|
37
|
+
network?: ChromeNetworkCaptureRequest;
|
|
35
38
|
}
|
|
36
39
|
/** Mobile emulation request: the CDP Emulation domain applied to one page target. */
|
|
37
40
|
export interface ChromeMobileEmulationRequest {
|
|
@@ -75,6 +78,18 @@ export interface ChromeCdpProbeResult {
|
|
|
75
78
|
applied?: string[];
|
|
76
79
|
/** "fidelity": the read-back. */
|
|
77
80
|
fidelity?: ChromeFidelityRead;
|
|
81
|
+
/** "hold" with `network`: when capture started on the launch page, or why it could not. */
|
|
82
|
+
network?: {
|
|
83
|
+
startedAt: number;
|
|
84
|
+
} | {
|
|
85
|
+
unavailable: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Seconds since the epoch on the sandbox clock when the probe printed, on every answer including
|
|
89
|
+
* an unavailable one. It is the clock CDP `wallTime` uses, so a turn mark compares with request
|
|
90
|
+
* times without host clock skew.
|
|
91
|
+
*/
|
|
92
|
+
now?: number;
|
|
78
93
|
}
|
|
79
94
|
/**
|
|
80
95
|
* The probe itself. Kept as one string so the shipped command is exactly what the tests execute
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// of swallowing an exit code; the TypeScript side turns that into a participant warning that names the
|
|
16
16
|
// consequence ("url/text criteria will read as never measured").
|
|
17
17
|
import { shellQuote } from "../shell.js";
|
|
18
|
+
import { NETWORK_CAPTURE_PY } from "./cdp-network-capture.js";
|
|
18
19
|
/**
|
|
19
20
|
* The probe itself. Kept as one string so the shipped command is exactly what the tests execute
|
|
20
21
|
* (tests/substrates/e2b/cdp-probe.test.ts runs it under the real python3 against a real
|
|
@@ -272,17 +273,20 @@ def browser_ws_url(port):
|
|
|
272
273
|
info = json.loads(response.read().decode("utf-8"))
|
|
273
274
|
return str(info.get("webSocketDebuggerUrl") or "")
|
|
274
275
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
${NETWORK_CAPTURE_PY}
|
|
277
|
+
def hold(port, page, request, network):
|
|
278
|
+
"""Hold the launch page and every page target Chrome opens later, for as long as this process
|
|
279
|
+
lives, over one browser-level socket with flattened sessions. With an emulation request, the
|
|
280
|
+
launch page is attached by id and reloaded so scripts that read the UA at load see it; a target
|
|
281
|
+
that appears later is attached the moment it exists, sent the same overrides, and reloaded once
|
|
282
|
+
after its first real navigation commits (touch emulation reaches a document only when it loads
|
|
283
|
+
under it). Without one, nothing is emulated and nothing reloads. With a network request, every
|
|
284
|
+
held page also reports its traffic to the events file (NETWORK_CAPTURE_PY).
|
|
281
285
|
Two things this loop must never do (measured on a real desktop, 2026-09-04): pause new targets
|
|
282
286
|
(waitForDebuggerOnStart) and wait for their replies. A popup a participant taps open shares its
|
|
283
287
|
opener's renderer, answers Emulation commands only once it runs, and a loop blocked on that
|
|
284
|
-
reply never resumed the tab (it loaded forever) and never reached the next one.
|
|
285
|
-
sent without waiting; replies, errors included, are logged as they arrive.
|
|
288
|
+
reply never resumed the tab (it loaded forever) and never reached the next one. Commands to a
|
|
289
|
+
later target are sent without waiting; replies, errors included, are logged as they arrive.
|
|
286
290
|
Prints one JSON line per attached target; the first line is the announce the lane reads."""
|
|
287
291
|
launch_id = str(page.get("id") or "")
|
|
288
292
|
try:
|
|
@@ -295,15 +299,36 @@ def hold(port, page, request):
|
|
|
295
299
|
if not session_id:
|
|
296
300
|
print(json.dumps({"unavailable": "Target.attachToTarget failed for the launch page"}), flush=True)
|
|
297
301
|
return
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
log, capture = start_capture(ws, session_id, network) if network is not None else (None, None)
|
|
303
|
+
reason = "the holder stopped"
|
|
304
|
+
try:
|
|
305
|
+
applied, failure = apply_over(ws, request, session_id, reload=True) if request else ([], None)
|
|
306
|
+
held = failure is None and (bool(request) or log is not None)
|
|
307
|
+
announce = {"applied": applied, "held": held, "targetId": launch_id}
|
|
308
|
+
if capture is not None:
|
|
309
|
+
announce["network"] = capture
|
|
310
|
+
if failure:
|
|
311
|
+
announce["unavailable"] = failure
|
|
312
|
+
print(json.dumps(announce), flush=True)
|
|
313
|
+
if not held:
|
|
314
|
+
reason = failure or "nothing to hold"
|
|
315
|
+
return
|
|
316
|
+
reason = follow_targets(ws, launch_id, request, log)
|
|
317
|
+
except Exception as error:
|
|
318
|
+
reason = "%s: %s" % (type(error).__name__, error)
|
|
319
|
+
raise
|
|
320
|
+
finally:
|
|
321
|
+
if log is not None:
|
|
322
|
+
log.close(reason)
|
|
323
|
+
|
|
324
|
+
def follow_targets(ws, launch_id, request, log):
|
|
325
|
+
"""The holder's loop: attach every later page target, log replies and hand each event to the
|
|
326
|
+
capture log. Returns why it ended."""
|
|
303
327
|
auto = ws.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
|
|
304
328
|
if not isinstance(auto, dict) or "error" in auto:
|
|
305
|
-
print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed;
|
|
329
|
+
print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; the holder does not reach later tabs"}), flush=True)
|
|
306
330
|
ws.sock.settimeout(None)
|
|
331
|
+
covered = {launch_id}
|
|
307
332
|
pending = {}
|
|
308
333
|
# Sessions of later targets that still owe one reload: touch emulation reaches a document only
|
|
309
334
|
# when it loads under the override (measured: a popup's first document reported
|
|
@@ -314,11 +339,12 @@ def hold(port, page, request):
|
|
|
314
339
|
try:
|
|
315
340
|
message = ws.events.pop(0) if ws.events else ws.recv()
|
|
316
341
|
except Exception as error:
|
|
317
|
-
|
|
318
|
-
|
|
342
|
+
reason = "%s: %s" % (type(error).__name__, error)
|
|
343
|
+
print(json.dumps({"holderExit": reason}), flush=True)
|
|
344
|
+
return reason
|
|
319
345
|
if message is None:
|
|
320
346
|
print(json.dumps({"holderExit": "browser socket closed"}), flush=True)
|
|
321
|
-
return
|
|
347
|
+
return "browser socket closed"
|
|
322
348
|
reply_id = message.get("id")
|
|
323
349
|
session = message.get("sessionId")
|
|
324
350
|
if reply_id in pending:
|
|
@@ -332,17 +358,19 @@ def hold(port, page, request):
|
|
|
332
358
|
if href and not href.startswith("about:"):
|
|
333
359
|
target_id = reload_owed.pop(session)
|
|
334
360
|
pending[ws.send("Page.reload", {}, session)] = "Page.reload"
|
|
335
|
-
print(json.dumps({"reloaded": target_id, "
|
|
361
|
+
print(json.dumps({"reloaded": target_id, "by": "href"}), flush=True)
|
|
336
362
|
continue
|
|
337
363
|
method = message.get("method")
|
|
338
364
|
params = message.get("params") or {}
|
|
365
|
+
if log is not None and method:
|
|
366
|
+
log.event(method, params, session)
|
|
339
367
|
if method == "Page.frameNavigated" and session in reload_owed:
|
|
340
368
|
frame = params.get("frame") or {}
|
|
341
369
|
url = str(frame.get("url") or "")
|
|
342
370
|
if not frame.get("parentId") and url and not url.startswith("about:"):
|
|
343
371
|
target_id = reload_owed.pop(session)
|
|
344
372
|
pending[ws.send("Page.reload", {}, session)] = "Page.reload"
|
|
345
|
-
print(json.dumps({"reloaded": target_id
|
|
373
|
+
print(json.dumps({"reloaded": target_id}), flush=True)
|
|
346
374
|
continue
|
|
347
375
|
if method != "Target.attachedToTarget":
|
|
348
376
|
continue
|
|
@@ -351,35 +379,50 @@ def hold(port, page, request):
|
|
|
351
379
|
target_id = str(info.get("targetId") or "")
|
|
352
380
|
if info.get("type") == "page" and target_id and target_id not in covered:
|
|
353
381
|
covered.add(target_id)
|
|
354
|
-
messages = emulation_messages(request, reload=False)
|
|
382
|
+
messages = emulation_messages(request, reload=False) if request else []
|
|
355
383
|
for m in messages:
|
|
356
384
|
pending[ws.send(m["method"], m.get("params"), new_session)] = m["method"]
|
|
385
|
+
sent = [m["method"] for m in messages]
|
|
386
|
+
# After the overrides, so a later tab's emulation timing is what it was without capture.
|
|
387
|
+
if log is not None:
|
|
388
|
+
log.session(new_session)
|
|
389
|
+
pending[ws.send("Network.enable", NETWORK_ENABLE, new_session)] = "Network.enable"
|
|
390
|
+
sent.append("Network.enable")
|
|
357
391
|
pending[ws.send("Page.enable", {}, new_session)] = "Page.enable"
|
|
358
392
|
url = str(info.get("url") or "")
|
|
359
|
-
if
|
|
393
|
+
if not request:
|
|
394
|
+
print(json.dumps({"attached": target_id, "sent": sent}), flush=True)
|
|
395
|
+
elif url and not url.startswith("about:"):
|
|
360
396
|
pending[ws.send("Page.reload", {}, new_session)] = "Page.reload"
|
|
361
|
-
print(json.dumps({"attached": target_id, "sent":
|
|
397
|
+
print(json.dumps({"attached": target_id, "sent": sent + ["Page.reload"]}), flush=True)
|
|
362
398
|
else:
|
|
363
399
|
reload_owed[new_session] = target_id
|
|
364
400
|
# Two ways to learn the first real navigation committed, whichever answers first:
|
|
365
401
|
# Page.frameNavigated (if Page.enable landed before the commit) or the page's own
|
|
366
402
|
# location.href (if it did not).
|
|
367
403
|
pending[ws.send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True}, new_session)] = "href"
|
|
368
|
-
print(json.dumps({"attached": target_id, "sent":
|
|
404
|
+
print(json.dumps({"attached": target_id, "sent": sent, "reloadAfterNavigation": True}), flush=True)
|
|
369
405
|
if params.get("waitingForDebugger"):
|
|
370
406
|
ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
|
|
371
407
|
|
|
408
|
+
def emit(answer):
|
|
409
|
+
"""Print one answer line stamped with this sandbox's clock. CDP wallTime uses the same clock, so
|
|
410
|
+
a turn mark taken from it compares with request times without host skew, and the reading needs
|
|
411
|
+
nothing from the page, so a page too busy to answer still leaves a mark."""
|
|
412
|
+
answer["now"] = time.time()
|
|
413
|
+
print(json.dumps(answer))
|
|
414
|
+
|
|
372
415
|
def main():
|
|
373
416
|
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
374
417
|
mode = args.get("mode") or "state"
|
|
375
418
|
port = resolve_port(args)
|
|
376
419
|
if mode == "port":
|
|
377
|
-
|
|
420
|
+
emit({"cdpPort": port})
|
|
378
421
|
return
|
|
379
422
|
try:
|
|
380
423
|
pages = list_pages(port)
|
|
381
424
|
except Exception as error:
|
|
382
|
-
|
|
425
|
+
emit({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)})
|
|
383
426
|
return
|
|
384
427
|
page = select_page(pages, args)
|
|
385
428
|
# A listing with targets but no http page is usually an instant: a reload in flight, a tab
|
|
@@ -396,7 +439,7 @@ def main():
|
|
|
396
439
|
break
|
|
397
440
|
page = select_page(pages, args)
|
|
398
441
|
if page is None:
|
|
399
|
-
|
|
442
|
+
emit({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)})
|
|
400
443
|
return
|
|
401
444
|
# Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
|
|
402
445
|
# page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
|
|
@@ -406,29 +449,29 @@ def main():
|
|
|
406
449
|
if mode == "emulate":
|
|
407
450
|
applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
|
|
408
451
|
if failure is not None:
|
|
409
|
-
|
|
452
|
+
emit({"unavailable": failure, "applied": applied or []})
|
|
410
453
|
return
|
|
411
|
-
|
|
454
|
+
emit({"applied": applied, "targetId": str(page.get("id") or "")})
|
|
412
455
|
return
|
|
413
456
|
if mode == "hold":
|
|
414
457
|
# Prints its announce line, then stays attached (and attaches to every later page target)
|
|
415
458
|
# until killed.
|
|
416
|
-
hold(port, page, args.get("emulation") or
|
|
459
|
+
hold(port, page, args.get("emulation") or None, args.get("network"))
|
|
417
460
|
return
|
|
418
461
|
if mode == "fidelity":
|
|
419
462
|
result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
|
|
420
463
|
if not isinstance(result, dict):
|
|
421
|
-
|
|
464
|
+
emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
|
|
422
465
|
return
|
|
423
|
-
|
|
466
|
+
emit({"fidelity": result, "targetId": str(page.get("id") or "")})
|
|
424
467
|
return
|
|
425
468
|
if mode == "geometry":
|
|
426
469
|
result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
|
|
427
470
|
if not isinstance(result, dict):
|
|
428
|
-
|
|
471
|
+
emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
|
|
429
472
|
return
|
|
430
473
|
result["targetId"] = str(page.get("id") or "")
|
|
431
|
-
|
|
474
|
+
emit(result)
|
|
432
475
|
return
|
|
433
476
|
url = str(page.get("url") or "")
|
|
434
477
|
title = str(page.get("title") or "")
|
|
@@ -440,7 +483,7 @@ def main():
|
|
|
440
483
|
title = result["title"] if isinstance(result.get("title"), str) else title
|
|
441
484
|
text = result["text"] if isinstance(result.get("text"), str) else ""
|
|
442
485
|
scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
|
|
443
|
-
|
|
486
|
+
emit({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")})
|
|
444
487
|
|
|
445
488
|
main()
|
|
446
489
|
`;
|
|
@@ -457,6 +500,8 @@ export function chromeCdpProbeCommand(args) {
|
|
|
457
500
|
payload.prefer = args.prefer;
|
|
458
501
|
if (args.emulation !== undefined)
|
|
459
502
|
payload.emulation = args.emulation;
|
|
503
|
+
if (args.network !== undefined)
|
|
504
|
+
payload.network = args.network;
|
|
460
505
|
return `python3 -c ${shellQuote(CHROME_CDP_PROBE_PY)} ${shellQuote(JSON.stringify(payload))}`;
|
|
461
506
|
}
|
|
462
507
|
/**
|
|
@@ -481,10 +526,17 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
481
526
|
const applied = Array.isArray(record.applied)
|
|
482
527
|
? record.applied.filter((item) => typeof item === "string")
|
|
483
528
|
: undefined;
|
|
529
|
+
const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
530
|
+
const now = numberOr(record.now);
|
|
531
|
+
const network = networkOf(record.network);
|
|
484
532
|
if (typeof record.unavailable === "string") {
|
|
485
|
-
return {
|
|
533
|
+
return {
|
|
534
|
+
unavailable: record.unavailable,
|
|
535
|
+
...(applied === undefined ? {} : { applied }),
|
|
536
|
+
...(network === undefined ? {} : { network }),
|
|
537
|
+
...(now === undefined ? {} : { now }),
|
|
538
|
+
};
|
|
486
539
|
}
|
|
487
|
-
const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
488
540
|
const box = (value, keys) => {
|
|
489
541
|
if (!value || typeof value !== "object")
|
|
490
542
|
return undefined;
|
|
@@ -549,5 +601,17 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
549
601
|
: {}),
|
|
550
602
|
...(browserWindow === undefined ? {} : { browserWindow }),
|
|
551
603
|
...(viewport === undefined ? {} : { viewport }),
|
|
604
|
+
...(network === undefined ? {} : { network }),
|
|
605
|
+
...(now === undefined ? {} : { now }),
|
|
552
606
|
};
|
|
553
607
|
}
|
|
608
|
+
function networkOf(value) {
|
|
609
|
+
if (!value || typeof value !== "object")
|
|
610
|
+
return undefined;
|
|
611
|
+
const source = value;
|
|
612
|
+
if (typeof source.startedAt === "number" && Number.isFinite(source.startedAt))
|
|
613
|
+
return { startedAt: source.startedAt };
|
|
614
|
+
if (typeof source.unavailable === "string")
|
|
615
|
+
return { unavailable: source.unavailable };
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
|
|
2
|
+
import { type ChromeCdpProbeResult, type ChromeMobileEmulationRequest } from "./cdp-probe.js";
|
|
2
3
|
import type { RunDesktopGeometry } from "../../run/streams.js";
|
|
3
4
|
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
4
5
|
/**
|
|
@@ -53,6 +54,12 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
|
|
|
53
54
|
/** Called once, with the reason, on the first probe that could not read the page. */
|
|
54
55
|
onUnavailable?: (reason: string) => void;
|
|
55
56
|
drift?: ChromeEmulationDrift | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Called with undefined when a probe starts, then with the sandbox clock the probe printed
|
|
59
|
+
* (ChromeCdpProbeResult.now), so the caller holds the time of the latest read and never a
|
|
60
|
+
* stale one from a probe that failed.
|
|
61
|
+
*/
|
|
62
|
+
onClock?: (now: number | undefined) => void;
|
|
56
63
|
}): () => Promise<{
|
|
57
64
|
url?: string;
|
|
58
65
|
title?: string;
|
|
@@ -65,13 +72,36 @@ export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhon
|
|
|
65
72
|
* Apply mobile emulation to the participant's launch page and read back what the page reports.
|
|
66
73
|
* Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
67
74
|
* the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
|
|
68
|
-
* (the emulation was applied; only the proof is missing).
|
|
75
|
+
* (the emulation was applied; only the proof is missing). With `network`, the same holder also
|
|
76
|
+
* captures requests, and a capture that could not start is reported in `network` without failing
|
|
77
|
+
* the emulation.
|
|
69
78
|
*/
|
|
70
79
|
export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, request: ChromeMobileEmulationRequest, options?: {
|
|
71
80
|
targetId?: string | undefined;
|
|
81
|
+
network?: ChromeNetworkCaptureRequest | undefined;
|
|
72
82
|
}): Promise<{
|
|
73
83
|
fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
|
|
74
84
|
warnings: string[];
|
|
75
85
|
targetId?: string;
|
|
76
86
|
holderName: string;
|
|
87
|
+
/** The holder's capture status, when `network` was passed and the holder said. */
|
|
88
|
+
network?: ChromeCdpProbeResult["network"];
|
|
77
89
|
}>;
|
|
90
|
+
/**
|
|
91
|
+
* Start a holder that records the launch page's requests, and every later tab's, to
|
|
92
|
+
* `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
|
|
93
|
+
* the request to the holder it starts. Sends no Emulation command and no reload. Returns the
|
|
94
|
+
* holder's process name, which readNetworkCaptureStart reads.
|
|
95
|
+
*/
|
|
96
|
+
export declare function startNetworkCapture(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, network: ChromeNetworkCaptureRequest): Promise<string>;
|
|
97
|
+
/**
|
|
98
|
+
* What the capture holder announced: when capture started on the launch page, on the sandbox
|
|
99
|
+
* clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
|
|
100
|
+
* `desktop.wait`, and reports a holder that printed nothing as unavailable.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readNetworkCaptureStart(desktop: E2BDesktopSandbox, requestTimeoutMs: number, holderName: string): Promise<NonNullable<ChromeCdpProbeResult["network"]>>;
|
|
103
|
+
/**
|
|
104
|
+
* The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
|
|
105
|
+
* Throws when the sandbox cannot stream files or the file cannot be read.
|
|
106
|
+
*/
|
|
107
|
+
export declare function readNetworkEvents(desktop: E2BDesktopSandbox, eventsPath: string, requestTimeoutMs: number): Promise<string>;
|