prism-mcp-server 20.3.0 → 20.3.2
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 +75 -6
- package/dist/config.js +4 -0
- package/dist/scholar/freeSearch.js +237 -34
- package/dist/scholar/webScholar.js +30 -4
- package/docs/prism-browser.md +86 -2
- package/package.json +1 -1
- package/scripts/dev/browse.py +1729 -622
package/scripts/dev/browse.py
CHANGED
|
@@ -1,40 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
browse.py —
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Layer 1: playwright-stealth
|
|
10
|
-
|
|
11
|
-
Layer
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Layer
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
3
|
+
browse.py — Local Playwright browser runner for agent-driven testing
|
|
4
|
+
====================================================================
|
|
5
|
+
Structured CLI over Python Playwright for repeatable local acceptance checks,
|
|
6
|
+
debugging, and DOM/visual verification. Runs entirely on localhost.
|
|
7
|
+
|
|
8
|
+
FINGERPRINT / COMPATIBILITY PATCHES:
|
|
9
|
+
Layer 1: playwright-stealth 2.x evasions (webdriver, plugins, permissions,
|
|
10
|
+
navigator.userAgentData, Sec-CH-UA, WebGL vendor/renderer)
|
|
11
|
+
Layer 2: CDP Emulation.setUserAgentOverride with full userAgentMetadata —
|
|
12
|
+
the only reliable way to keep the UA string, navigator.userAgentData,
|
|
13
|
+
and the Sec-CH-UA request headers mutually consistent
|
|
14
|
+
Layer 3: Supplementary JS init script (chrome.runtime/csi/loadTimes, WebGL,
|
|
15
|
+
navigator.connection, outer window metrics)
|
|
16
|
+
Layer 4: Chromium launch args (automation flags, rendering determinism)
|
|
17
|
+
Layer 5: Persistent profiles — cookie jars survive restarts
|
|
18
|
+
|
|
19
|
+
These are best-effort test aids. They are NOT a guarantee against bot
|
|
20
|
+
detection and NOT authorization to bypass access controls, CAPTCHAs, or a
|
|
21
|
+
site's terms. Layer application is verified at startup and reported; a
|
|
22
|
+
requested layer that cannot be applied fails loudly instead of degrading
|
|
23
|
+
silently.
|
|
24
|
+
|
|
25
|
+
SECURITY:
|
|
26
|
+
- FileVault (FDE) check, fail-closed (override with --skip-fv-check)
|
|
18
27
|
- Isolated persistent browser profiles (~/.browser_data/<profile>/)
|
|
19
|
-
- Audit logging (
|
|
20
|
-
- --
|
|
21
|
-
|
|
28
|
+
- Audit logging (actions + redacted targets, never page content)
|
|
29
|
+
- --local-only network isolation: HTTP route blocking, service workers
|
|
30
|
+
blocked, and WebSocket/EventSource/WebRTC/sendBeacon hardening
|
|
31
|
+
- --cleanup for ephemeral screenshots, --sanitize to mask PHI patterns
|
|
32
|
+
|
|
33
|
+
DEBUGGING:
|
|
34
|
+
Console errors, uncaught page exceptions, failed requests, and blocked
|
|
35
|
+
socket attempts are captured and attached to command output. An uncaught
|
|
36
|
+
page exception fails a pipe run.
|
|
22
37
|
|
|
23
38
|
MODES:
|
|
24
|
-
Single command: browse.py open
|
|
25
|
-
Interactive: browse.py repl
|
|
26
|
-
Pipe/batch:
|
|
39
|
+
Single command: browse.py open http://127.0.0.1:3000
|
|
40
|
+
Interactive: browse.py repl
|
|
41
|
+
Pipe/batch: printf 'open ...\\nassert-text #app Ready\\n' | browse.py pipe
|
|
27
42
|
"""
|
|
28
43
|
|
|
29
44
|
import argparse
|
|
30
45
|
import datetime
|
|
31
46
|
import hashlib
|
|
32
|
-
import io
|
|
33
47
|
import json
|
|
34
48
|
import os
|
|
35
49
|
import random
|
|
36
50
|
import re
|
|
37
51
|
import select
|
|
52
|
+
import shlex
|
|
53
|
+
import shutil
|
|
38
54
|
import signal
|
|
39
55
|
import subprocess
|
|
40
56
|
import sys
|
|
@@ -52,8 +68,13 @@ DEFAULT_PROFILE = "default"
|
|
|
52
68
|
DEFAULT_TIMEOUT = 30000
|
|
53
69
|
DEFAULT_VIEWPORT = (1440, 900)
|
|
54
70
|
REPL_IDLE_TIMEOUT = 600 # 10 minutes — auto-close to prevent zombie Chromium
|
|
71
|
+
PIPE_IDLE_TIMEOUT = 600 # stdin held open with no commands — same protection
|
|
55
72
|
MAX_INIT_SCRIPT_BYTES = 256 * 1024
|
|
56
73
|
PROFILE_NAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
|
74
|
+
MIN_SCREENSHOT_BYTES = 1024
|
|
75
|
+
AUDIT_LOG_MAX_BYTES = 8 * 1024 * 1024
|
|
76
|
+
AUDIT_LOG_KEEP = 3
|
|
77
|
+
DIAGNOSTIC_LIMIT = 50
|
|
57
78
|
STEALTH_AVAILABLE = False
|
|
58
79
|
|
|
59
80
|
try:
|
|
@@ -62,7 +83,10 @@ try:
|
|
|
62
83
|
except ImportError:
|
|
63
84
|
pass
|
|
64
85
|
|
|
65
|
-
# PHI sanitization patterns
|
|
86
|
+
# PHI sanitization patterns.
|
|
87
|
+
# Deliberately broad: in this codebase a false positive (a redacted order ID)
|
|
88
|
+
# is preferable to a leaked identifier. Numeric assertions must therefore not
|
|
89
|
+
# rely on --sanitize output.
|
|
66
90
|
PHI_PATTERNS = [
|
|
67
91
|
(re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[SSN-REDACTED]'),
|
|
68
92
|
(re.compile(r'\b\d{9}\b'), '[SSN-REDACTED]'),
|
|
@@ -72,6 +96,14 @@ PHI_PATTERNS = [
|
|
|
72
96
|
]
|
|
73
97
|
|
|
74
98
|
|
|
99
|
+
class StealthConfigurationError(RuntimeError):
|
|
100
|
+
"""A requested fingerprint layer could not be applied."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AssertionFailure(Exception):
|
|
104
|
+
"""An assert-* command evaluated to false. Distinct from an error."""
|
|
105
|
+
|
|
106
|
+
|
|
75
107
|
def validate_profile_name(profile: str) -> str:
|
|
76
108
|
"""Keep persistent profiles inside BROWSER_DATA_DIR."""
|
|
77
109
|
if not PROFILE_NAME_PATTERN.fullmatch(profile):
|
|
@@ -100,6 +132,9 @@ def is_local_test_url(url: str, allow_internal=False) -> bool:
|
|
|
100
132
|
return True
|
|
101
133
|
if allow_internal and parsed.scheme == 'blob':
|
|
102
134
|
return is_local_test_url(parsed.path, allow_internal=False)
|
|
135
|
+
if parsed.scheme in ('ws', 'wss'):
|
|
136
|
+
hostname = (parsed.hostname or '').lower().rstrip('.')
|
|
137
|
+
return hostname in ('localhost', '127.0.0.1', '::1') or hostname.endswith('.localhost')
|
|
103
138
|
if parsed.scheme not in ('http', 'https'):
|
|
104
139
|
return False
|
|
105
140
|
|
|
@@ -122,7 +157,7 @@ def redact_audit_target(target: str) -> str:
|
|
|
122
157
|
|
|
123
158
|
if parsed.scheme == 'data':
|
|
124
159
|
return 'data:[redacted]'
|
|
125
|
-
if parsed.scheme in ('http', 'https'):
|
|
160
|
+
if parsed.scheme in ('http', 'https', 'ws', 'wss'):
|
|
126
161
|
hostname = parsed.hostname or ''
|
|
127
162
|
if ':' in hostname and not hostname.startswith('['):
|
|
128
163
|
hostname = f'[{hostname}]'
|
|
@@ -133,14 +168,17 @@ def redact_audit_target(target: str) -> str:
|
|
|
133
168
|
port = None
|
|
134
169
|
if port:
|
|
135
170
|
netloc = f'{netloc}:{port}'
|
|
136
|
-
|
|
171
|
+
# The path can carry record identifiers (/patients/123456789/notes),
|
|
172
|
+
# so it is sanitized rather than trusted.
|
|
173
|
+
safe_path = sanitize_phi(parsed.path or '/')
|
|
174
|
+
return urlunsplit((parsed.scheme, netloc, safe_path, '', ''))[:300]
|
|
137
175
|
return sanitize_phi(compact)[:300]
|
|
138
176
|
|
|
139
177
|
|
|
140
178
|
def _sanitize_audit_text(value: str) -> str:
|
|
141
179
|
"""Sanitize arbitrary audit details, including embedded URLs."""
|
|
142
180
|
compact = str(value).replace('\n', ' ').replace('\r', ' ')
|
|
143
|
-
url_pattern = re.compile(r'(?i)(?:https?|data):[^\s|]+')
|
|
181
|
+
url_pattern = re.compile(r'(?i)(?:https?|wss?|data):[^\s|]+')
|
|
144
182
|
compact = url_pattern.sub(lambda match: redact_audit_target(match.group(0)), compact)
|
|
145
183
|
return sanitize_phi(compact)[:300]
|
|
146
184
|
|
|
@@ -162,6 +200,11 @@ def load_local_init_script(script_path: str) -> tuple[str, str]:
|
|
|
162
200
|
source = candidate.read_text(encoding='utf-8')
|
|
163
201
|
except UnicodeDecodeError as exc:
|
|
164
202
|
raise ValueError("Init scripts must be UTF-8 text.") from exc
|
|
203
|
+
if re.search(r'^\s*(?:import|export)\s', source, re.MULTILINE):
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"Init scripts run as classic scripts before page code; ES module "
|
|
206
|
+
"'import'/'export' syntax cannot work. Inline the dependency instead."
|
|
207
|
+
)
|
|
165
208
|
|
|
166
209
|
digest = hashlib.sha256(source.encode('utf-8')).hexdigest()[:12]
|
|
167
210
|
guarded = f"""
|
|
@@ -175,10 +218,10 @@ def load_local_init_script(script_path: str) -> tuple[str, str]:
|
|
|
175
218
|
"""
|
|
176
219
|
return guarded, digest
|
|
177
220
|
|
|
221
|
+
|
|
178
222
|
# ---------------------------------------------------------------------------
|
|
179
|
-
#
|
|
223
|
+
# Fingerprint configuration
|
|
180
224
|
# ---------------------------------------------------------------------------
|
|
181
|
-
# Realistic User-Agent strings for macOS Chrome (rotated per-profile)
|
|
182
225
|
STEALTH_USER_AGENTS = [
|
|
183
226
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
184
227
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
@@ -187,227 +230,296 @@ STEALTH_USER_AGENTS = [
|
|
|
187
230
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36",
|
|
188
231
|
]
|
|
189
232
|
|
|
190
|
-
|
|
233
|
+
WEBGL_VENDOR = "Google Inc. (Apple)"
|
|
234
|
+
WEBGL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)"
|
|
235
|
+
|
|
236
|
+
# Chromium args that reduce the automation fingerprint.
|
|
237
|
+
# Site isolation, phishing detection, and popup blocking are deliberately NOT
|
|
238
|
+
# disabled: these profiles hold live authenticated cookies, and trading away
|
|
239
|
+
# Spectre/UXSS mitigations for a fingerprint delta is the wrong exchange.
|
|
240
|
+
# Each --disable-features / --enable-features switch appears exactly once
|
|
241
|
+
# because Chromium honors only the last occurrence.
|
|
191
242
|
STEALTH_CHROMIUM_ARGS = [
|
|
192
|
-
'--disable-blink-features=AutomationControlled',
|
|
193
|
-
'--disable-features=IsolateOrigins,site-per-process', # Reduce iframe isolation fingerprint
|
|
194
|
-
'--disable-site-isolation-trials',
|
|
195
|
-
'--disable-features=AutomationControlled',
|
|
243
|
+
'--disable-blink-features=AutomationControlled',
|
|
196
244
|
'--no-first-run',
|
|
197
245
|
'--no-default-browser-check',
|
|
198
|
-
'--disable-infobars',
|
|
246
|
+
'--disable-infobars',
|
|
199
247
|
'--disable-background-timer-throttling',
|
|
200
248
|
'--disable-backgrounding-occluded-windows',
|
|
201
249
|
'--disable-renderer-backgrounding',
|
|
202
250
|
'--disable-component-update',
|
|
203
251
|
'--disable-dev-shm-usage',
|
|
204
252
|
'--disable-hang-monitor',
|
|
205
|
-
'--disable-popup-blocking',
|
|
206
253
|
'--disable-prompt-on-repost',
|
|
207
254
|
'--disable-sync',
|
|
208
255
|
'--metrics-recording-only',
|
|
209
256
|
'--no-service-autorun',
|
|
210
257
|
'--password-store=basic',
|
|
211
258
|
'--use-mock-keychain',
|
|
212
|
-
'--enable-features=NetworkService,NetworkServiceInProcess',
|
|
213
259
|
'--force-color-profile=srgb',
|
|
214
260
|
'--disable-domain-reliability',
|
|
215
|
-
'--
|
|
261
|
+
'--force-webrtc-ip-handling-policy=default_public_interface_only',
|
|
216
262
|
'--lang=en-US',
|
|
263
|
+
'--disable-features=OptimizationHints,MediaRouter',
|
|
264
|
+
'--enable-features=NetworkService,NetworkServiceInProcess',
|
|
217
265
|
]
|
|
218
266
|
|
|
219
|
-
#
|
|
220
|
-
#
|
|
267
|
+
# Supplementary JS patches. Anything that playwright-stealth already covers
|
|
268
|
+
# (webdriver, plugins, permissions, userAgentData, Sec-CH-UA, WebGL) is left to
|
|
269
|
+
# it so there is a single owner per surface.
|
|
270
|
+
#
|
|
271
|
+
# NOTE: no Object.getOwnPropertyDescriptor override. The previous iframe
|
|
272
|
+
# evasion replaced that global on every page, which broke legitimate
|
|
273
|
+
# descriptor reads on both HTMLIFrameElement.prototype and ordinary objects
|
|
274
|
+
# carrying a `contentWindow` key, was readable via .toString(), and was
|
|
275
|
+
# bypassed in one line by Object.getOwnPropertyDescriptors. It corrupted the
|
|
276
|
+
# application under test for no detection benefit.
|
|
221
277
|
DEEP_STEALTH_INIT_SCRIPT = """
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (!window.chrome) {
|
|
232
|
-
if (!window.chrome.runtime) {
|
|
278
|
+
(() => {
|
|
279
|
+
const nativeToString = Function.prototype.toString;
|
|
280
|
+
const patched = new WeakMap();
|
|
281
|
+
const cloak = (fn, label) => {
|
|
282
|
+
try { patched.set(fn, `function ${label}() { [native code] }`); } catch (e) {}
|
|
283
|
+
return fn;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (!window.chrome) { window.chrome = {}; }
|
|
287
|
+
if (!window.chrome.runtime) {
|
|
233
288
|
window.chrome.runtime = {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
289
|
+
connect: cloak(function connect() {
|
|
290
|
+
return { onMessage: { addListener() {} }, postMessage() {} };
|
|
291
|
+
}, 'connect'),
|
|
292
|
+
sendMessage: cloak(function sendMessage() {}, 'sendMessage'),
|
|
293
|
+
id: undefined,
|
|
294
|
+
onMessage: { addListener() {}, removeListener() {} },
|
|
295
|
+
onConnect: { addListener() {}, removeListener() {} },
|
|
239
296
|
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!window.chrome.csi) {
|
|
300
|
+
window.chrome.csi = cloak(function csi() {
|
|
301
|
+
return { startE: Date.now(), onloadT: Date.now(), pageT: Math.random() * 1000 + 200, tran: 15 };
|
|
302
|
+
}, 'csi');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!window.chrome.loadTimes) {
|
|
306
|
+
window.chrome.loadTimes = cloak(function loadTimes() {
|
|
307
|
+
const now = Date.now() / 1000;
|
|
308
|
+
return {
|
|
309
|
+
commitLoadTime: now,
|
|
310
|
+
connectionInfo: 'h2',
|
|
311
|
+
finishDocumentLoadTime: now + Math.random(),
|
|
312
|
+
finishLoadTime: now + Math.random(),
|
|
313
|
+
firstPaintAfterLoadTime: 0,
|
|
314
|
+
firstPaintTime: now + Math.random() * 0.5,
|
|
315
|
+
navigationType: 'Other',
|
|
316
|
+
npnNegotiatedProtocol: 'h2',
|
|
317
|
+
requestTime: now - Math.random(),
|
|
318
|
+
startLoadTime: now - Math.random(),
|
|
319
|
+
wasAlternateProtocolAvailable: false,
|
|
320
|
+
wasFetchedViaSpdy: true,
|
|
321
|
+
wasNpnNegotiated: true,
|
|
322
|
+
};
|
|
323
|
+
}, 'loadTimes');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const patchWebGL = (ctor) => {
|
|
327
|
+
if (typeof ctor === 'undefined') return;
|
|
328
|
+
const original = ctor.prototype.getParameter;
|
|
329
|
+
const replacement = function getParameter(parameter) {
|
|
330
|
+
if (parameter === 37445) return '__WEBGL_VENDOR__';
|
|
331
|
+
if (parameter === 37446) return '__WEBGL_RENDERER__';
|
|
332
|
+
return original.call(this, parameter);
|
|
251
333
|
};
|
|
252
|
-
|
|
334
|
+
cloak(replacement, 'getParameter');
|
|
335
|
+
ctor.prototype.getParameter = replacement;
|
|
336
|
+
};
|
|
337
|
+
patchWebGL(typeof WebGLRenderingContext !== 'undefined' ? WebGLRenderingContext : undefined);
|
|
338
|
+
patchWebGL(typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : undefined);
|
|
253
339
|
|
|
254
|
-
|
|
255
|
-
if (!window.chrome.loadTimes) {
|
|
256
|
-
window.chrome.loadTimes = function() {
|
|
257
|
-
return {
|
|
258
|
-
commitLoadTime: Date.now() / 1000,
|
|
259
|
-
connectionInfo: "h2",
|
|
260
|
-
finishDocumentLoadTime: Date.now() / 1000 + Math.random(),
|
|
261
|
-
finishLoadTime: Date.now() / 1000 + Math.random(),
|
|
262
|
-
firstPaintAfterLoadTime: 0,
|
|
263
|
-
firstPaintTime: Date.now() / 1000 + Math.random() * 0.5,
|
|
264
|
-
navigationType: "Other",
|
|
265
|
-
npnNegotiatedProtocol: "h2",
|
|
266
|
-
requestTime: Date.now() / 1000 - Math.random(),
|
|
267
|
-
startLoadTime: Date.now() / 1000 - Math.random(),
|
|
268
|
-
wasAlternateProtocolAvailable: false,
|
|
269
|
-
wasFetchedViaSpdy: true,
|
|
270
|
-
wasNpnNegotiated: true,
|
|
271
|
-
};
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 5. Fix navigator.plugins (headless has 0 plugins — dead giveaway)
|
|
276
|
-
if (navigator.plugins.length === 0) {
|
|
277
|
-
Object.defineProperty(navigator, 'plugins', {
|
|
278
|
-
get: () => {
|
|
279
|
-
const plugins = [
|
|
280
|
-
{ name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" },
|
|
281
|
-
{ name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "" },
|
|
282
|
-
{ name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "" },
|
|
283
|
-
{ name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "" },
|
|
284
|
-
{ name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "" },
|
|
285
|
-
];
|
|
286
|
-
plugins.item = (i) => plugins[i] || null;
|
|
287
|
-
plugins.namedItem = (name) => plugins.find(p => p.name === name) || null;
|
|
288
|
-
plugins.refresh = () => {};
|
|
289
|
-
return plugins;
|
|
290
|
-
},
|
|
291
|
-
configurable: true
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// 6. Fix navigator.mimeTypes
|
|
296
|
-
if (navigator.mimeTypes.length === 0) {
|
|
297
|
-
Object.defineProperty(navigator, 'mimeTypes', {
|
|
298
|
-
get: () => {
|
|
299
|
-
const mimes = [
|
|
300
|
-
{ type: "application/pdf", suffixes: "pdf", description: "Portable Document Format" },
|
|
301
|
-
{ type: "text/pdf", suffixes: "pdf", description: "" },
|
|
302
|
-
];
|
|
303
|
-
mimes.item = (i) => mimes[i] || null;
|
|
304
|
-
mimes.namedItem = (name) => mimes.find(m => m.type === name) || null;
|
|
305
|
-
return mimes;
|
|
306
|
-
},
|
|
307
|
-
configurable: true
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// 7. Fix permissions API (Notification.permission detection)
|
|
312
|
-
const originalQuery = window.navigator.permissions?.query;
|
|
313
|
-
if (originalQuery) {
|
|
314
|
-
window.navigator.permissions.query = (parameters) => (
|
|
315
|
-
parameters.name === 'notifications' ?
|
|
316
|
-
Promise.resolve({ state: Notification.permission }) :
|
|
317
|
-
originalQuery(parameters)
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// 8. Prevent iframe detection (contentWindow detection)
|
|
322
|
-
const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
|
|
323
|
-
Object.getOwnPropertyDescriptor = function(obj, prop) {
|
|
324
|
-
if (prop === 'contentWindow' || prop === 'contentDocument') {
|
|
325
|
-
return undefined;
|
|
326
|
-
}
|
|
327
|
-
return originalGetOwnPropertyDescriptor(obj, prop);
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
// 9. Fix WebGL vendor/renderer (headless shows "Google SwiftShader")
|
|
331
|
-
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
|
332
|
-
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
|
333
|
-
// UNMASKED_VENDOR_WEBGL
|
|
334
|
-
if (parameter === 37445) {
|
|
335
|
-
return 'Google Inc. (Apple)';
|
|
336
|
-
}
|
|
337
|
-
// UNMASKED_RENDERER_WEBGL
|
|
338
|
-
if (parameter === 37446) {
|
|
339
|
-
return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)';
|
|
340
|
-
}
|
|
341
|
-
return getParameter.call(this, parameter);
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
// Also patch WebGL2
|
|
345
|
-
if (typeof WebGL2RenderingContext !== 'undefined') {
|
|
346
|
-
const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
|
|
347
|
-
WebGL2RenderingContext.prototype.getParameter = function(parameter) {
|
|
348
|
-
if (parameter === 37445) return 'Google Inc. (Apple)';
|
|
349
|
-
if (parameter === 37446) return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)';
|
|
350
|
-
return getParameter2.call(this, parameter);
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// 10. Fix navigator.connection (missing in automation)
|
|
355
|
-
if (!navigator.connection) {
|
|
340
|
+
if (!navigator.connection) {
|
|
356
341
|
Object.defineProperty(navigator, 'connection', {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
effectiveType: '4g',
|
|
360
|
-
rtt: 50,
|
|
361
|
-
saveData: false,
|
|
362
|
-
onchange: null,
|
|
363
|
-
}),
|
|
364
|
-
configurable: true
|
|
342
|
+
get: () => ({ downlink: 10, effectiveType: '4g', rtt: 50, saveData: false, onchange: null }),
|
|
343
|
+
configurable: true,
|
|
365
344
|
});
|
|
366
|
-
}
|
|
345
|
+
}
|
|
367
346
|
|
|
368
|
-
|
|
369
|
-
if (window.outerHeight === 0) {
|
|
347
|
+
if (window.outerHeight === 0) {
|
|
370
348
|
Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85 });
|
|
371
|
-
}
|
|
372
|
-
if (window.outerWidth === 0) {
|
|
349
|
+
}
|
|
350
|
+
if (window.outerWidth === 0) {
|
|
373
351
|
Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth });
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Cloak only the functions this script actually replaced. A blanket
|
|
355
|
+
// Function.prototype.toString proxy that special-cases one method leaves
|
|
356
|
+
// every other patch readable, which is worse than not cloaking at all.
|
|
357
|
+
const toStringProxy = new Proxy(nativeToString, {
|
|
358
|
+
apply(target, ctx, args) {
|
|
359
|
+
const spoofed = patched.get(ctx);
|
|
360
|
+
if (spoofed) return spoofed;
|
|
361
|
+
return Reflect.apply(target, ctx, args);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
Function.prototype.toString = toStringProxy;
|
|
366
|
+
patched.set(toStringProxy, 'function toString() { [native code] }');
|
|
367
|
+
} catch (e) {}
|
|
368
|
+
})();
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
# Blocks the egress channels that Playwright's request routing cannot see.
|
|
372
|
+
# Defense in depth for --local-only, not a kernel-level guarantee.
|
|
373
|
+
LOCAL_ONLY_SOCKET_GUARD = """
|
|
374
|
+
(() => {
|
|
375
|
+
const allowed = (raw) => {
|
|
376
|
+
try {
|
|
377
|
+
const url = new URL(raw, location.href);
|
|
378
|
+
if (url.protocol === 'data:' || url.protocol === 'about:' || url.protocol === 'blob:') return true;
|
|
379
|
+
const host = url.hostname.toLowerCase().replace(/\\.$/, '');
|
|
380
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' ||
|
|
381
|
+
host === '[::1]' || host.endsWith('.localhost');
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return false;
|
|
386
384
|
}
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
|
|
385
|
+
};
|
|
386
|
+
const refuse = (channel, target) => {
|
|
387
|
+
try { console.error(`[prism-local-only] blocked ${channel} to ${target}`); } catch (e) {}
|
|
388
|
+
throw new DOMException(`Blocked by --local-only: ${channel} to ${target}`, 'SecurityError');
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const NativeWebSocket = window.WebSocket;
|
|
392
|
+
if (NativeWebSocket) {
|
|
393
|
+
const GuardedWebSocket = function WebSocket(url, protocols) {
|
|
394
|
+
if (!allowed(url)) refuse('WebSocket', String(url));
|
|
395
|
+
return protocols === undefined ? new NativeWebSocket(url) : new NativeWebSocket(url, protocols);
|
|
396
|
+
};
|
|
397
|
+
GuardedWebSocket.prototype = NativeWebSocket.prototype;
|
|
398
|
+
for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
|
|
399
|
+
try { GuardedWebSocket[key] = NativeWebSocket[key]; } catch (e) {}
|
|
400
|
+
}
|
|
401
|
+
try { window.WebSocket = GuardedWebSocket; } catch (e) {}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const NativeEventSource = window.EventSource;
|
|
405
|
+
if (NativeEventSource) {
|
|
406
|
+
const GuardedEventSource = function EventSource(url, config) {
|
|
407
|
+
if (!allowed(url)) refuse('EventSource', String(url));
|
|
408
|
+
return new NativeEventSource(url, config);
|
|
409
|
+
};
|
|
410
|
+
GuardedEventSource.prototype = NativeEventSource.prototype;
|
|
411
|
+
try { window.EventSource = GuardedEventSource; } catch (e) {}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (navigator.sendBeacon) {
|
|
415
|
+
const nativeBeacon = navigator.sendBeacon.bind(navigator);
|
|
390
416
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
417
|
+
Object.defineProperty(navigator, 'sendBeacon', {
|
|
418
|
+
configurable: true,
|
|
419
|
+
value: function sendBeacon(url, data) {
|
|
420
|
+
if (!allowed(url)) {
|
|
421
|
+
try { console.error(`[prism-local-only] blocked sendBeacon to ${url}`); } catch (e) {}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
return nativeBeacon(url, data);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
} catch (e) {}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection']) {
|
|
431
|
+
if (window[name]) {
|
|
432
|
+
try {
|
|
433
|
+
window[name] = function RTCPeerConnection() {
|
|
434
|
+
refuse('RTCPeerConnection', 'any peer');
|
|
435
|
+
};
|
|
436
|
+
} catch (e) {}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
})();
|
|
394
440
|
"""
|
|
395
441
|
|
|
396
442
|
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
# User-agent metadata
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
def chrome_version_parts(user_agent: str) -> tuple[str, str]:
|
|
447
|
+
"""Return (major, full) Chrome version parsed from a UA string."""
|
|
448
|
+
match = re.search(r'Chrome/(\d+)(?:\.(\d+\.\d+\.\d+))?', user_agent)
|
|
449
|
+
if not match:
|
|
450
|
+
raise StealthConfigurationError(f"Cannot parse a Chrome version from UA: {user_agent}")
|
|
451
|
+
major = match.group(1)
|
|
452
|
+
full = f"{major}.{match.group(2)}" if match.group(2) else f"{major}.0.0.0"
|
|
453
|
+
return major, full
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def build_sec_ch_ua(major: str) -> str:
|
|
457
|
+
"""Client-hint brand list matching the advertised Chrome major version."""
|
|
458
|
+
return f'"Chromium";v="{major}", "Google Chrome";v="{major}", "Not_A_Brand";v="24"'
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def mac_platform_version(user_agent: str) -> str:
|
|
462
|
+
"""Derive a macOS platformVersion from a UA string."""
|
|
463
|
+
match = re.search(r'Mac OS X (\d+)[._](\d+)(?:[._](\d+))?', user_agent)
|
|
464
|
+
if not match:
|
|
465
|
+
return "14.5.0"
|
|
466
|
+
major, minor, patch = match.group(1), match.group(2), match.group(3) or '0'
|
|
467
|
+
# Chromium reports Big Sur and later as 11+; the legacy 10_15_7 UA maps to 10.15.7.
|
|
468
|
+
return f"{major}.{minor}.{patch}"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def build_user_agent_metadata(user_agent: str) -> dict:
|
|
472
|
+
"""Full userAgentMetadata for CDP Emulation.setUserAgentOverride."""
|
|
473
|
+
major, full = chrome_version_parts(user_agent)
|
|
474
|
+
brands = [
|
|
475
|
+
{"brand": "Not_A_Brand", "version": "24"},
|
|
476
|
+
{"brand": "Chromium", "version": major},
|
|
477
|
+
{"brand": "Google Chrome", "version": major},
|
|
478
|
+
]
|
|
479
|
+
full_versions = [
|
|
480
|
+
{"brand": "Not_A_Brand", "version": "24.0.0.0"},
|
|
481
|
+
{"brand": "Chromium", "version": full},
|
|
482
|
+
{"brand": "Google Chrome", "version": full},
|
|
483
|
+
]
|
|
484
|
+
return {
|
|
485
|
+
"brands": brands,
|
|
486
|
+
"fullVersionList": full_versions,
|
|
487
|
+
"fullVersion": full,
|
|
488
|
+
"platform": "macOS",
|
|
489
|
+
"platformVersion": mac_platform_version(user_agent),
|
|
490
|
+
"architecture": "arm",
|
|
491
|
+
"model": "",
|
|
492
|
+
"mobile": False,
|
|
493
|
+
"bitness": "64",
|
|
494
|
+
"wow64": False,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
397
498
|
# ---------------------------------------------------------------------------
|
|
398
499
|
# Security checks
|
|
399
500
|
# ---------------------------------------------------------------------------
|
|
400
|
-
def check_filevault():
|
|
401
|
-
"""Verify FileVault (Full Disk Encryption) is enabled."""
|
|
501
|
+
def check_filevault() -> bool:
|
|
502
|
+
"""Verify FileVault (Full Disk Encryption) is enabled. Fails closed."""
|
|
503
|
+
if sys.platform != 'darwin':
|
|
504
|
+
print(
|
|
505
|
+
"⛔ Cannot verify full-disk encryption on this platform. "
|
|
506
|
+
"Pass --skip-fv-check to proceed deliberately.",
|
|
507
|
+
file=sys.stderr,
|
|
508
|
+
)
|
|
509
|
+
return False
|
|
402
510
|
try:
|
|
403
511
|
result = subprocess.run(['fdesetup', 'status'], capture_output=True, text=True, timeout=5)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
512
|
+
except Exception as exc:
|
|
513
|
+
print(
|
|
514
|
+
f"⛔ Cannot verify disk encryption ({type(exc).__name__}). "
|
|
515
|
+
"Pass --skip-fv-check to proceed deliberately.",
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
407
518
|
return False
|
|
408
|
-
|
|
409
|
-
print("⚠️ Cannot verify disk encryption.", file=sys.stderr)
|
|
519
|
+
if 'FileVault is On' in result.stdout:
|
|
410
520
|
return True
|
|
521
|
+
print("⛔ FileVault is OFF. Pass --skip-fv-check to proceed deliberately.", file=sys.stderr)
|
|
522
|
+
return False
|
|
411
523
|
|
|
412
524
|
|
|
413
525
|
def sanitize_phi(text: str) -> str:
|
|
@@ -417,30 +529,59 @@ def sanitize_phi(text: str) -> str:
|
|
|
417
529
|
return text
|
|
418
530
|
|
|
419
531
|
|
|
420
|
-
def secure_delete(filepath: str):
|
|
421
|
-
"""
|
|
532
|
+
def secure_delete(filepath: str) -> dict:
|
|
533
|
+
"""
|
|
534
|
+
Best-effort wipe. On APFS, copy-on-write means an in-place overwrite does
|
|
535
|
+
not necessarily clear the original physical blocks, so this reports what it
|
|
536
|
+
actually guarantees rather than claiming secure erasure.
|
|
537
|
+
"""
|
|
422
538
|
path = Path(filepath)
|
|
423
539
|
if not path.exists():
|
|
424
|
-
return
|
|
540
|
+
return {"path": str(path), "removed": False, "reason": "missing"}
|
|
541
|
+
overwritten = False
|
|
425
542
|
try:
|
|
426
543
|
size = path.stat().st_size
|
|
427
|
-
with open(path, 'wb') as
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
os.fsync(
|
|
544
|
+
with open(path, 'wb') as handle:
|
|
545
|
+
handle.write(os.urandom(size))
|
|
546
|
+
handle.flush()
|
|
547
|
+
os.fsync(handle.fileno())
|
|
548
|
+
overwritten = True
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
print(f"⚠️ Overwrite before delete failed: {exc}", file=sys.stderr)
|
|
551
|
+
try:
|
|
431
552
|
path.unlink()
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
553
|
+
removed = True
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
print(f"⚠️ Delete failed: {exc}", file=sys.stderr)
|
|
556
|
+
removed = False
|
|
557
|
+
return {
|
|
558
|
+
"path": str(path),
|
|
559
|
+
"removed": removed,
|
|
560
|
+
"overwritten": overwritten,
|
|
561
|
+
"guarantee": "unlinked; APFS copy-on-write may retain prior blocks",
|
|
562
|
+
}
|
|
438
563
|
|
|
439
564
|
|
|
440
565
|
# ---------------------------------------------------------------------------
|
|
441
566
|
# Audit logging
|
|
442
567
|
# ---------------------------------------------------------------------------
|
|
443
|
-
def
|
|
568
|
+
def _rotate_audit_log() -> None:
|
|
569
|
+
"""Keep the audit log bounded so it stays reviewable."""
|
|
570
|
+
try:
|
|
571
|
+
if not AUDIT_LOG_PATH.exists() or AUDIT_LOG_PATH.stat().st_size < AUDIT_LOG_MAX_BYTES:
|
|
572
|
+
return
|
|
573
|
+
for index in range(AUDIT_LOG_KEEP - 1, 0, -1):
|
|
574
|
+
older = AUDIT_LOG_PATH.with_suffix(f'.log.{index}')
|
|
575
|
+
newer = AUDIT_LOG_PATH.with_suffix(f'.log.{index + 1}')
|
|
576
|
+
if older.exists():
|
|
577
|
+
older.replace(newer)
|
|
578
|
+
AUDIT_LOG_PATH.replace(AUDIT_LOG_PATH.with_suffix('.log.1'))
|
|
579
|
+
AUDIT_LOG_PATH.touch(mode=0o600)
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _ensure_audit_log_permissions() -> None:
|
|
444
585
|
"""Create audit log with strict permissions (chmod 600) so other processes can't read it."""
|
|
445
586
|
AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
446
587
|
try:
|
|
@@ -456,85 +597,188 @@ def _ensure_audit_log_permissions():
|
|
|
456
597
|
os.chmod(AUDIT_LOG_PATH, 0o600)
|
|
457
598
|
except Exception:
|
|
458
599
|
pass
|
|
600
|
+
_rotate_audit_log()
|
|
459
601
|
|
|
460
602
|
|
|
461
|
-
def audit_log(action: str, target: str = "", details: str = ""):
|
|
462
|
-
"""Write audit log entry. Records WHAT and WHERE, never content."""
|
|
603
|
+
def audit_log(action: str, target: str = "", details: str = "") -> None:
|
|
604
|
+
"""Write audit log entry. Records WHAT and WHERE, never page content."""
|
|
463
605
|
_ensure_audit_log_permissions()
|
|
464
606
|
ts = datetime.datetime.now().isoformat()
|
|
465
607
|
safe_target = redact_audit_target(target)
|
|
466
608
|
safe_details = _sanitize_audit_text(details) if details else ""
|
|
467
609
|
try:
|
|
468
|
-
with open(AUDIT_LOG_PATH, 'a', encoding='utf-8') as
|
|
469
|
-
|
|
610
|
+
with open(AUDIT_LOG_PATH, 'a', encoding='utf-8') as handle:
|
|
611
|
+
handle.write(f"{ts} | {action} | {safe_target} | {safe_details}\n")
|
|
470
612
|
except Exception:
|
|
471
613
|
pass
|
|
472
614
|
|
|
473
615
|
|
|
474
616
|
# ---------------------------------------------------------------------------
|
|
475
|
-
# Behavioral
|
|
617
|
+
# Behavioral helpers
|
|
476
618
|
# ---------------------------------------------------------------------------
|
|
477
|
-
def human_delay(min_ms=50, max_ms=200):
|
|
478
|
-
"""Random human-like delay between actions."""
|
|
619
|
+
def human_delay(min_ms=50, max_ms=200, fast=False):
|
|
620
|
+
"""Random human-like delay between actions. Skipped in fast mode."""
|
|
621
|
+
if fast:
|
|
622
|
+
return
|
|
479
623
|
time.sleep(random.uniform(min_ms / 1000, max_ms / 1000))
|
|
480
624
|
|
|
481
625
|
|
|
482
|
-
def human_type(page, text, selector=None):
|
|
483
|
-
"""Type
|
|
626
|
+
def human_type(page, text, selector=None, fast=False):
|
|
627
|
+
"""Type with variable delays, or fill instantly in fast mode."""
|
|
628
|
+
if fast:
|
|
629
|
+
if selector:
|
|
630
|
+
page.fill(selector, text)
|
|
631
|
+
else:
|
|
632
|
+
page.keyboard.insert_text(text)
|
|
633
|
+
return
|
|
484
634
|
if selector:
|
|
485
635
|
page.click(selector)
|
|
486
636
|
human_delay(100, 300)
|
|
487
|
-
|
|
488
637
|
for char in text:
|
|
489
638
|
page.keyboard.type(char, delay=random.randint(30, 120))
|
|
490
|
-
# Occasional longer pause (like thinking)
|
|
491
639
|
if random.random() < 0.05:
|
|
492
640
|
human_delay(200, 500)
|
|
493
641
|
|
|
494
642
|
|
|
495
|
-
def human_scroll(page, direction="down", steps=3):
|
|
496
|
-
"""Scroll
|
|
643
|
+
def human_scroll(page, direction="down", steps=3, fast=False):
|
|
644
|
+
"""Scroll with variable increments."""
|
|
497
645
|
for _ in range(steps):
|
|
498
646
|
delta = random.randint(150, 400) * (1 if direction == "down" else -1)
|
|
499
647
|
page.mouse.wheel(0, delta)
|
|
500
|
-
human_delay(100, 400)
|
|
648
|
+
human_delay(100, 400, fast=fast)
|
|
649
|
+
|
|
501
650
|
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
# Diagnostics
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
class Diagnostics:
|
|
655
|
+
"""
|
|
656
|
+
Collects the failure signals a headless run would otherwise discard:
|
|
657
|
+
console errors, uncaught exceptions, failed requests, blocked sockets.
|
|
658
|
+
"""
|
|
502
659
|
|
|
503
|
-
def
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
660
|
+
def __init__(self, limit: int = DIAGNOSTIC_LIMIT, sanitize: bool = False):
|
|
661
|
+
self.limit = limit
|
|
662
|
+
self.sanitize = sanitize
|
|
663
|
+
self.console_errors: list[str] = []
|
|
664
|
+
self.page_errors: list[str] = []
|
|
665
|
+
self.failed_requests: list[str] = []
|
|
666
|
+
self.blocked_requests: list[str] = []
|
|
667
|
+
# drain() clears the per-command buckets, so page errors are also kept
|
|
668
|
+
# cumulatively: an end-of-run assertion must still be able to show them.
|
|
669
|
+
self.all_page_errors: list[str] = []
|
|
670
|
+
self.saw_page_error = False
|
|
671
|
+
self._attached: set[int] = set()
|
|
672
|
+
|
|
673
|
+
def _clean(self, value: str) -> str:
|
|
674
|
+
text = str(value).replace('\n', ' ')[:500]
|
|
675
|
+
return sanitize_phi(text) if self.sanitize else text
|
|
676
|
+
|
|
677
|
+
def _append(self, bucket: list[str], value: str) -> None:
|
|
678
|
+
if len(bucket) < self.limit:
|
|
679
|
+
bucket.append(self._clean(value))
|
|
680
|
+
|
|
681
|
+
def attach(self, page) -> None:
|
|
682
|
+
if id(page) in self._attached:
|
|
683
|
+
return
|
|
684
|
+
self._attached.add(id(page))
|
|
685
|
+
|
|
686
|
+
def on_console(message):
|
|
687
|
+
if message.type in ('error', 'warning'):
|
|
688
|
+
self._append(self.console_errors, f"{message.type}: {message.text}")
|
|
689
|
+
|
|
690
|
+
def on_page_error(error):
|
|
691
|
+
self.saw_page_error = True
|
|
692
|
+
self._append(self.page_errors, str(error))
|
|
693
|
+
self._append(self.all_page_errors, str(error))
|
|
694
|
+
audit_log("page_error", page.url, type(error).__name__)
|
|
695
|
+
|
|
696
|
+
def on_request_failed(request):
|
|
697
|
+
failure = request.failure or 'unknown'
|
|
698
|
+
self._append(self.failed_requests, f"{request.method} {request.url} — {failure}")
|
|
699
|
+
|
|
700
|
+
def on_websocket(socket):
|
|
701
|
+
if not is_local_test_url(socket.url):
|
|
702
|
+
self._append(self.blocked_requests, f"WEBSOCKET {socket.url}")
|
|
703
|
+
audit_log("websocket_attempt", socket.url, "policy=local-only")
|
|
704
|
+
|
|
705
|
+
page.on('console', on_console)
|
|
706
|
+
page.on('pageerror', on_page_error)
|
|
707
|
+
page.on('requestfailed', on_request_failed)
|
|
708
|
+
page.on('websocket', on_websocket)
|
|
709
|
+
|
|
710
|
+
def note_blocked(self, description: str) -> None:
|
|
711
|
+
self._append(self.blocked_requests, description)
|
|
712
|
+
|
|
713
|
+
def drain(self) -> dict:
|
|
714
|
+
"""Return signals accumulated since the last drain, then clear them."""
|
|
715
|
+
payload = {}
|
|
716
|
+
for key, bucket in (
|
|
717
|
+
('console_errors', self.console_errors),
|
|
718
|
+
('page_errors', self.page_errors),
|
|
719
|
+
('failed_requests', self.failed_requests),
|
|
720
|
+
('blocked_requests', self.blocked_requests),
|
|
721
|
+
):
|
|
722
|
+
if bucket:
|
|
723
|
+
payload[key] = list(bucket)
|
|
724
|
+
bucket.clear()
|
|
725
|
+
return payload
|
|
509
726
|
|
|
510
727
|
|
|
511
728
|
# ---------------------------------------------------------------------------
|
|
512
|
-
# Browser
|
|
729
|
+
# Browser session
|
|
513
730
|
# ---------------------------------------------------------------------------
|
|
514
731
|
class StealthBrowserSession:
|
|
515
|
-
"""Manages a
|
|
732
|
+
"""Manages a local Playwright browser session."""
|
|
516
733
|
|
|
517
734
|
def __init__(self, profile=DEFAULT_PROFILE, headless=False,
|
|
518
735
|
timeout=DEFAULT_TIMEOUT, viewport=DEFAULT_VIEWPORT,
|
|
519
736
|
stealth_level="full", local_only=False,
|
|
520
|
-
init_script_paths=None
|
|
737
|
+
init_script_paths=None, fast=False, sanitize=False,
|
|
738
|
+
allow_degraded_stealth=False, ephemeral_profile=False,
|
|
739
|
+
storage_state_path=None, trace_path=None, video_dir=None,
|
|
740
|
+
har_path=None, grant_permissions=None, geolocation=None,
|
|
741
|
+
allow_http_error=False):
|
|
521
742
|
self.profile = validate_profile_name(profile)
|
|
522
743
|
self.headless = headless
|
|
523
744
|
self.timeout = timeout
|
|
524
745
|
self.viewport = viewport
|
|
525
746
|
self.stealth_level = stealth_level # "full", "light", "none"
|
|
526
747
|
self.local_only = local_only
|
|
748
|
+
self.fast = fast
|
|
749
|
+
self.sanitize = sanitize
|
|
750
|
+
self.allow_degraded_stealth = allow_degraded_stealth
|
|
751
|
+
self.ephemeral_profile = ephemeral_profile
|
|
752
|
+
self.storage_state_path = storage_state_path
|
|
753
|
+
self.trace_path = trace_path
|
|
754
|
+
self.video_dir = video_dir
|
|
755
|
+
self.har_path = har_path
|
|
756
|
+
self.grant_permissions = list(grant_permissions or [])
|
|
757
|
+
self.geolocation = geolocation
|
|
758
|
+
self.allow_http_error = allow_http_error
|
|
759
|
+
self.stealth_degraded: list[str] = []
|
|
760
|
+
self.fingerprint: dict = {}
|
|
761
|
+
self._fingerprint_reverified = False
|
|
762
|
+
self.screenshot_counter = 0
|
|
763
|
+
self.diagnostics = Diagnostics(sanitize=sanitize)
|
|
764
|
+
self._temp_profile_dir = None
|
|
527
765
|
self.profile_dir = BROWSER_DATA_DIR / self.profile
|
|
528
766
|
self._playwright = None
|
|
529
767
|
self._context = None
|
|
530
768
|
self._page = None
|
|
769
|
+
self._pages: list = []
|
|
770
|
+
self._tracing_active = False
|
|
771
|
+
|
|
531
772
|
script_paths = list(init_script_paths or [])
|
|
532
773
|
if script_paths and not self.local_only:
|
|
533
774
|
raise ValueError("Custom init scripts require --local-only.")
|
|
534
775
|
self._init_scripts = [load_local_init_script(path) for path in script_paths]
|
|
535
|
-
|
|
776
|
+
|
|
536
777
|
ua_idx = stable_profile_index(self.profile, len(STEALTH_USER_AGENTS))
|
|
537
778
|
self._user_agent = STEALTH_USER_AGENTS[ua_idx]
|
|
779
|
+
self._ua_major, self._ua_full = chrome_version_parts(self._user_agent)
|
|
780
|
+
self._sec_ch_ua = build_sec_ch_ua(self._ua_major)
|
|
781
|
+
self._ua_metadata = build_user_agent_metadata(self._user_agent)
|
|
538
782
|
|
|
539
783
|
def __enter__(self):
|
|
540
784
|
self.start()
|
|
@@ -543,141 +787,384 @@ class StealthBrowserSession:
|
|
|
543
787
|
def __exit__(self, *args):
|
|
544
788
|
self.stop()
|
|
545
789
|
|
|
790
|
+
# -- lifecycle ---------------------------------------------------------
|
|
546
791
|
def start(self):
|
|
547
792
|
from playwright.sync_api import sync_playwright
|
|
548
|
-
self.profile_dir.mkdir(parents=True, exist_ok=True)
|
|
549
|
-
self._playwright = sync_playwright().start()
|
|
550
793
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
794
|
+
if self.ephemeral_profile:
|
|
795
|
+
self._temp_profile_dir = tempfile.mkdtemp(prefix='prism-browser-ephemeral-')
|
|
796
|
+
self.profile_dir = Path(self._temp_profile_dir)
|
|
797
|
+
self.profile_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
798
|
+
try:
|
|
799
|
+
os.chmod(self.profile_dir, 0o700)
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
self._playwright = sync_playwright().start()
|
|
554
804
|
|
|
555
|
-
# Build launch args
|
|
556
805
|
launch_args = list(STEALTH_CHROMIUM_ARGS) if self.stealth_level != "none" else [
|
|
557
806
|
'--no-first-run', '--no-default-browser-check'
|
|
558
807
|
]
|
|
559
808
|
|
|
560
|
-
|
|
561
|
-
self._context = self._playwright.chromium.launch_persistent_context(
|
|
809
|
+
launch_kwargs = dict(
|
|
562
810
|
user_data_dir=str(self.profile_dir),
|
|
563
811
|
headless=self.headless,
|
|
564
812
|
viewport={'width': self.viewport[0], 'height': self.viewport[1]},
|
|
565
813
|
user_agent=self._user_agent,
|
|
566
814
|
locale='en-US',
|
|
567
815
|
timezone_id='America/New_York',
|
|
568
|
-
geolocation={'latitude': 40.7128, 'longitude': -74.0060}, # NYC
|
|
569
|
-
permissions=['geolocation'],
|
|
570
816
|
color_scheme='light',
|
|
571
817
|
args=launch_args,
|
|
572
|
-
ignore_default_args=['--enable-automation'],
|
|
818
|
+
ignore_default_args=['--enable-automation'],
|
|
819
|
+
# Service-worker requests bypass context routing, which would
|
|
820
|
+
# punch a hole straight through --local-only.
|
|
821
|
+
service_workers='block' if self.local_only else 'allow',
|
|
573
822
|
)
|
|
823
|
+
if self.grant_permissions:
|
|
824
|
+
launch_kwargs['permissions'] = self.grant_permissions
|
|
825
|
+
if self.geolocation:
|
|
826
|
+
launch_kwargs['geolocation'] = {
|
|
827
|
+
'latitude': self.geolocation[0],
|
|
828
|
+
'longitude': self.geolocation[1],
|
|
829
|
+
}
|
|
830
|
+
if self.video_dir:
|
|
831
|
+
launch_kwargs['record_video_dir'] = str(self.video_dir)
|
|
832
|
+
if self.har_path:
|
|
833
|
+
launch_kwargs['record_har_path'] = str(self.har_path)
|
|
834
|
+
|
|
835
|
+
self._context = self._playwright.chromium.launch_persistent_context(**launch_kwargs)
|
|
574
836
|
self._context.set_default_timeout(self.timeout)
|
|
575
837
|
|
|
576
|
-
|
|
838
|
+
if self.trace_path:
|
|
839
|
+
self._context.tracing.start(screenshots=True, snapshots=True, sources=False)
|
|
840
|
+
self._tracing_active = True
|
|
841
|
+
|
|
842
|
+
if self.local_only:
|
|
843
|
+
self._context.add_init_script(LOCAL_ONLY_SOCKET_GUARD)
|
|
844
|
+
|
|
577
845
|
if self.stealth_level != "none":
|
|
578
846
|
self._apply_stealth()
|
|
579
|
-
|
|
580
|
-
self._apply_local_only_guard()
|
|
847
|
+
self._install_request_policy()
|
|
581
848
|
|
|
582
849
|
for source, digest in self._init_scripts:
|
|
583
850
|
self._context.add_init_script(source)
|
|
584
851
|
audit_log("init_script", f"sha256={digest}", "scope=local-only")
|
|
585
852
|
|
|
586
|
-
# Get or create page
|
|
587
853
|
if self._context.pages:
|
|
588
854
|
self._page = self._context.pages[0]
|
|
589
855
|
else:
|
|
590
856
|
self._page = self._context.new_page()
|
|
857
|
+
self._pages = [self._page]
|
|
858
|
+
self._register_page(self._page)
|
|
859
|
+
self._context.on('page', self._on_new_page)
|
|
860
|
+
|
|
861
|
+
if self.storage_state_path:
|
|
862
|
+
self._load_storage_state(self.storage_state_path)
|
|
863
|
+
|
|
864
|
+
self._verify_init_scripts()
|
|
865
|
+
# navigator.userAgentData is not exposed on about:blank, so the startup
|
|
866
|
+
# probe can only check UA/platform/webdriver. The brand list is
|
|
867
|
+
# re-checked on the first real navigation, where the answer is
|
|
868
|
+
# meaningful, via reverify_fingerprint().
|
|
869
|
+
self.fingerprint = self._verify_fingerprint(stage="startup")
|
|
870
|
+
|
|
871
|
+
if self.stealth_degraded and not self.allow_degraded_stealth:
|
|
872
|
+
if self.stealth_level == "full":
|
|
873
|
+
detail = '; '.join(self.stealth_degraded)
|
|
874
|
+
raise StealthConfigurationError(
|
|
875
|
+
f"--stealth full could not be applied: {detail}. "
|
|
876
|
+
"Fix the runtime, or run with --stealth light / "
|
|
877
|
+
"--allow-degraded-stealth to proceed deliberately."
|
|
878
|
+
)
|
|
879
|
+
for problem in self.stealth_degraded:
|
|
880
|
+
print(f"⚠️ stealth degraded: {problem}", file=sys.stderr)
|
|
591
881
|
|
|
592
|
-
audit_log(
|
|
593
|
-
|
|
882
|
+
audit_log(
|
|
883
|
+
"session_start", f"profile={self.profile}",
|
|
884
|
+
f"stealth={self.stealth_level},headless={self.headless},"
|
|
885
|
+
f"local_only={self.local_only},degraded={len(self.stealth_degraded)}",
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def _on_new_page(self, page):
|
|
889
|
+
# Event callbacks run on the sync API's dispatcher: issuing a protocol
|
|
890
|
+
# call here (a CDP session, a title() read) risks reentrancy. Record
|
|
891
|
+
# only, and let _ensure_pages_registered() do the protocol work.
|
|
892
|
+
self._pages.append(page)
|
|
893
|
+
audit_log("page_opened", page.url, f"count={len(self._pages)}")
|
|
894
|
+
|
|
895
|
+
def _pump_events(self):
|
|
896
|
+
"""
|
|
897
|
+
Let playwright-python deliver queued events.
|
|
898
|
+
|
|
899
|
+
The sync API dispatches events only while a protocol call is in flight,
|
|
900
|
+
so a popup opened by the previous command stays invisible until the
|
|
901
|
+
next round trip. time.sleep() does not pump anything.
|
|
902
|
+
"""
|
|
903
|
+
try:
|
|
904
|
+
self._context.cookies()
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
def _ensure_pages_registered(self):
|
|
909
|
+
"""Attach diagnostics and the UA override to any page we have not seen."""
|
|
910
|
+
try:
|
|
911
|
+
known = list(self._context.pages)
|
|
912
|
+
except Exception:
|
|
913
|
+
return
|
|
914
|
+
for page in known:
|
|
915
|
+
if page.is_closed():
|
|
916
|
+
continue
|
|
917
|
+
if page not in self._pages:
|
|
918
|
+
self._pages.append(page)
|
|
919
|
+
self._register_page(page)
|
|
920
|
+
|
|
921
|
+
def _register_page(self, page):
|
|
922
|
+
self.diagnostics.attach(page)
|
|
923
|
+
if self.stealth_level != "none":
|
|
924
|
+
self._apply_cdp_user_agent(page)
|
|
925
|
+
|
|
926
|
+
def _apply_cdp_user_agent(self, page):
|
|
927
|
+
"""
|
|
928
|
+
Keep the UA string, navigator.userAgentData, and the Sec-CH-UA request
|
|
929
|
+
headers consistent. Playwright's route.continue_(headers=...) cannot do
|
|
930
|
+
this: Chromium re-adds client hints after interception, so a header
|
|
931
|
+
rewrite there leaves 'HeadlessChrome' on the wire.
|
|
932
|
+
"""
|
|
933
|
+
try:
|
|
934
|
+
cdp = self._context.new_cdp_session(page)
|
|
935
|
+
cdp.send('Emulation.setUserAgentOverride', {
|
|
936
|
+
'userAgent': self._user_agent,
|
|
937
|
+
'acceptLanguage': 'en-US,en;q=0.9',
|
|
938
|
+
# This sets navigator.platform, which on real macOS Chrome is
|
|
939
|
+
# 'MacIntel'. The 'macOS' spelling belongs to
|
|
940
|
+
# userAgentMetadata.platform (navigator.userAgentData.platform).
|
|
941
|
+
'platform': 'MacIntel',
|
|
942
|
+
'userAgentMetadata': self._ua_metadata,
|
|
943
|
+
})
|
|
944
|
+
except Exception as exc:
|
|
945
|
+
problem = f"CDP user-agent override failed ({type(exc).__name__}: {exc})"
|
|
946
|
+
if problem not in self.stealth_degraded:
|
|
947
|
+
self.stealth_degraded.append(problem)
|
|
948
|
+
audit_log("stealth", "cdp_ua_override", f"error={exc}")
|
|
594
949
|
|
|
595
950
|
def _apply_stealth(self):
|
|
596
|
-
"""Apply
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
# Configure specific evasions
|
|
602
|
-
navigator_webdriver=True,
|
|
603
|
-
navigator_plugins=True,
|
|
604
|
-
navigator_permissions=True,
|
|
605
|
-
navigator_languages_override=("en-US", "en"),
|
|
606
|
-
webgl_vendor="Google Inc. (Apple)",
|
|
607
|
-
webgl_renderer="ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)",
|
|
608
|
-
init_scripts_only=True, # Required for persistent context
|
|
951
|
+
"""Apply fingerprint layers, recording any that could not be applied."""
|
|
952
|
+
if self.stealth_level == "full":
|
|
953
|
+
if not STEALTH_AVAILABLE:
|
|
954
|
+
self.stealth_degraded.append(
|
|
955
|
+
"playwright-stealth is not installed (pip3 install playwright-stealth)"
|
|
609
956
|
)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
957
|
+
else:
|
|
958
|
+
try:
|
|
959
|
+
stealth = Stealth(
|
|
960
|
+
navigator_webdriver=True,
|
|
961
|
+
navigator_plugins=True,
|
|
962
|
+
navigator_permissions=True,
|
|
963
|
+
navigator_user_agent=True,
|
|
964
|
+
navigator_user_agent_data=True,
|
|
965
|
+
navigator_user_agent_override=self._user_agent,
|
|
966
|
+
navigator_languages_override=("en-US", "en"),
|
|
967
|
+
# The library default is 'Win32', which would contradict
|
|
968
|
+
# a macOS user agent on every page.
|
|
969
|
+
navigator_platform_override="MacIntel",
|
|
970
|
+
sec_ch_ua=True,
|
|
971
|
+
sec_ch_ua_override=self._sec_ch_ua,
|
|
972
|
+
webgl_vendor_override=WEBGL_VENDOR,
|
|
973
|
+
webgl_renderer_override=WEBGL_RENDERER,
|
|
974
|
+
init_scripts_only=True, # required for persistent context
|
|
975
|
+
)
|
|
976
|
+
stealth.apply_stealth_sync(self._context)
|
|
977
|
+
audit_log("stealth", "playwright-stealth", "applied_v2")
|
|
978
|
+
except Exception as exc:
|
|
979
|
+
self.stealth_degraded.append(
|
|
980
|
+
f"playwright-stealth could not be applied ({type(exc).__name__}: {exc})"
|
|
981
|
+
)
|
|
982
|
+
audit_log("stealth", "playwright-stealth", f"error={exc}")
|
|
983
|
+
|
|
984
|
+
script = (
|
|
985
|
+
DEEP_STEALTH_INIT_SCRIPT
|
|
986
|
+
.replace('__WEBGL_VENDOR__', WEBGL_VENDOR)
|
|
987
|
+
.replace('__WEBGL_RENDERER__', WEBGL_RENDERER)
|
|
988
|
+
)
|
|
616
989
|
try:
|
|
617
|
-
self._context.add_init_script(
|
|
990
|
+
self._context.add_init_script(script)
|
|
618
991
|
audit_log("stealth", "deep_init_script", "injected")
|
|
619
|
-
except Exception as
|
|
620
|
-
|
|
992
|
+
except Exception as exc:
|
|
993
|
+
self.stealth_degraded.append(f"supplementary init script failed ({exc})")
|
|
994
|
+
audit_log("stealth", "deep_init_script", f"error={exc}")
|
|
621
995
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
996
|
+
def _install_request_policy(self):
|
|
997
|
+
"""
|
|
998
|
+
Enforce --local-only on routable requests, and set only the headers a
|
|
999
|
+
real Chrome would send. sec-ch-ua is handled by CDP, not here.
|
|
1000
|
+
"""
|
|
1001
|
+
def handler(route, request):
|
|
625
1002
|
if self.local_only and not is_local_test_url(request.url, allow_internal=True):
|
|
626
1003
|
audit_log("request_blocked", request.url, "policy=local-only")
|
|
1004
|
+
self.diagnostics.note_blocked(f"{request.method} {request.url}")
|
|
627
1005
|
route.abort("blockedbyclient")
|
|
628
1006
|
return
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1007
|
+
if self.stealth_level == "none":
|
|
1008
|
+
route.continue_()
|
|
1009
|
+
return
|
|
1010
|
+
headers = dict(request.headers)
|
|
1011
|
+
# Real Chrome sends Upgrade-Insecure-Requests only on navigation
|
|
1012
|
+
# requests, never on subresource fetches. Setting it everywhere is
|
|
1013
|
+
# itself a bot signal.
|
|
1014
|
+
try:
|
|
1015
|
+
is_navigation = request.is_navigation_request()
|
|
1016
|
+
except Exception:
|
|
1017
|
+
is_navigation = False
|
|
1018
|
+
if is_navigation:
|
|
1019
|
+
headers['upgrade-insecure-requests'] = '1'
|
|
1020
|
+
else:
|
|
1021
|
+
headers.pop('upgrade-insecure-requests', None)
|
|
642
1022
|
route.continue_(headers=headers)
|
|
643
1023
|
|
|
644
1024
|
try:
|
|
645
|
-
self._context.route("**/*",
|
|
646
|
-
audit_log("
|
|
647
|
-
except Exception as
|
|
648
|
-
audit_log("
|
|
1025
|
+
self._context.route("**/*", handler)
|
|
1026
|
+
audit_log("request_policy", "routing", f"local_only={self.local_only}")
|
|
1027
|
+
except Exception as exc:
|
|
1028
|
+
audit_log("request_policy", "routing", f"error={exc}")
|
|
649
1029
|
if self.local_only:
|
|
650
|
-
raise RuntimeError("Cannot enforce --local-only network isolation.") from
|
|
1030
|
+
raise RuntimeError("Cannot enforce --local-only network isolation.") from exc
|
|
651
1031
|
|
|
652
|
-
def
|
|
653
|
-
"""
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1032
|
+
def _verify_init_scripts(self):
|
|
1033
|
+
"""
|
|
1034
|
+
Prove the preload scripts parse. add_init_script failures are otherwise
|
|
1035
|
+
invisible: a syntax error produces no output at all.
|
|
1036
|
+
"""
|
|
1037
|
+
if not self._init_scripts:
|
|
1038
|
+
return
|
|
1039
|
+
for source, digest in self._init_scripts:
|
|
1040
|
+
try:
|
|
1041
|
+
self._page.evaluate(f"() => {{ {source} }}")
|
|
1042
|
+
except Exception as exc:
|
|
1043
|
+
raise ValueError(
|
|
1044
|
+
f"Init script sha256={digest} failed to evaluate: {exc}"
|
|
1045
|
+
) from exc
|
|
660
1046
|
|
|
661
|
-
|
|
1047
|
+
def reverify_fingerprint(self):
|
|
1048
|
+
"""
|
|
1049
|
+
Re-check the fingerprint on a real page. Raises for --stealth full when
|
|
1050
|
+
a leak appears that about:blank could not reveal.
|
|
1051
|
+
"""
|
|
1052
|
+
if self._fingerprint_reverified or self.stealth_level == "none":
|
|
1053
|
+
return
|
|
1054
|
+
self._fingerprint_reverified = True
|
|
1055
|
+
before = len(self.stealth_degraded)
|
|
1056
|
+
self.fingerprint = self._verify_fingerprint(stage="navigated")
|
|
1057
|
+
new_problems = self.stealth_degraded[before:]
|
|
1058
|
+
if not new_problems or self.allow_degraded_stealth:
|
|
1059
|
+
return
|
|
1060
|
+
detail = '; '.join(new_problems)
|
|
1061
|
+
if self.stealth_level == "full":
|
|
1062
|
+
raise StealthConfigurationError(
|
|
1063
|
+
f"--stealth full is leaking on a real page: {detail}. "
|
|
1064
|
+
"Fix the runtime, or run with --allow-degraded-stealth to proceed deliberately."
|
|
1065
|
+
)
|
|
1066
|
+
for problem in new_problems:
|
|
1067
|
+
print(f"⚠️ stealth degraded: {problem}", file=sys.stderr)
|
|
1068
|
+
|
|
1069
|
+
def _verify_fingerprint(self, stage="startup") -> dict:
|
|
1070
|
+
"""
|
|
1071
|
+
Actually compare the advertised identity against what the page sees.
|
|
1072
|
+
The previous implementation only wrote 'consistent=true' to the audit
|
|
1073
|
+
log without checking anything.
|
|
1074
|
+
"""
|
|
1075
|
+
if self.stealth_level == "none":
|
|
1076
|
+
return {"checked": False, "reason": "stealth=none"}
|
|
1077
|
+
probe = """() => ({
|
|
1078
|
+
ua: navigator.userAgent,
|
|
1079
|
+
platform: navigator.platform,
|
|
1080
|
+
webdriver: navigator.webdriver === undefined ? 'undefined' : String(navigator.webdriver),
|
|
1081
|
+
brands: navigator.userAgentData
|
|
1082
|
+
? navigator.userAgentData.brands.map(b => b.brand + '/' + b.version).join(',')
|
|
1083
|
+
: 'absent',
|
|
1084
|
+
uaDataPlatform: navigator.userAgentData ? navigator.userAgentData.platform : 'absent',
|
|
1085
|
+
})"""
|
|
1086
|
+
try:
|
|
1087
|
+
observed = self._page.evaluate(probe)
|
|
1088
|
+
except Exception as exc:
|
|
1089
|
+
self.stealth_degraded.append(f"fingerprint probe failed ({exc})")
|
|
1090
|
+
return {"checked": False, "reason": str(exc)}
|
|
1091
|
+
|
|
1092
|
+
report = {
|
|
1093
|
+
"checked": True,
|
|
1094
|
+
"stage": stage,
|
|
1095
|
+
"expected_chrome_major": self._ua_major,
|
|
1096
|
+
"user_agent_major": (re.search(r'Chrome/(\d+)', observed.get('ua', '')) or [None, None])[1]
|
|
1097
|
+
if re.search(r'Chrome/(\d+)', observed.get('ua', '')) else None,
|
|
1098
|
+
"brands": observed.get('brands'),
|
|
1099
|
+
"platform": observed.get('platform'),
|
|
1100
|
+
"ua_data_platform": observed.get('uaDataPlatform'),
|
|
1101
|
+
"webdriver": observed.get('webdriver'),
|
|
1102
|
+
}
|
|
662
1103
|
|
|
663
|
-
|
|
1104
|
+
brands = observed.get('brands') or ''
|
|
1105
|
+
if 'Headless' in brands or 'Headless' in observed.get('ua', ''):
|
|
1106
|
+
self.stealth_degraded.append(
|
|
1107
|
+
f"headless identity is still advertised (brands={brands or 'n/a'})"
|
|
1108
|
+
)
|
|
1109
|
+
elif brands != 'absent':
|
|
1110
|
+
versions = re.findall(r'/(\d+)', brands)
|
|
1111
|
+
if versions and self._ua_major not in versions:
|
|
1112
|
+
self.stealth_degraded.append(
|
|
1113
|
+
f"userAgentData brands {brands} disagree with UA major {self._ua_major}"
|
|
1114
|
+
)
|
|
1115
|
+
if report["user_agent_major"] and report["user_agent_major"] != self._ua_major:
|
|
1116
|
+
self.stealth_degraded.append(
|
|
1117
|
+
f"navigator.userAgent major {report['user_agent_major']} != configured {self._ua_major}"
|
|
1118
|
+
)
|
|
1119
|
+
if observed.get('platform') not in ('MacIntel', None):
|
|
1120
|
+
self.stealth_degraded.append(
|
|
1121
|
+
f"navigator.platform is {observed.get('platform')}, expected MacIntel for a macOS UA"
|
|
1122
|
+
)
|
|
1123
|
+
if observed.get('webdriver') not in ('undefined', 'false'):
|
|
1124
|
+
self.stealth_degraded.append(f"navigator.webdriver is {observed.get('webdriver')}")
|
|
1125
|
+
|
|
1126
|
+
if brands == 'absent':
|
|
1127
|
+
report["note"] = (
|
|
1128
|
+
"navigator.userAgentData is not exposed here; the brand list is "
|
|
1129
|
+
"re-checked after the first navigation"
|
|
1130
|
+
)
|
|
1131
|
+
report["consistent"] = not self.stealth_degraded
|
|
1132
|
+
audit_log(
|
|
1133
|
+
"fingerprint_check", "ua_webgl",
|
|
1134
|
+
f"stage={stage},consistent={report['consistent']},"
|
|
1135
|
+
f"brands={brands},platform={observed.get('platform')}",
|
|
1136
|
+
)
|
|
1137
|
+
return report
|
|
1138
|
+
|
|
1139
|
+
def _load_storage_state(self, path):
|
|
664
1140
|
"""
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
E.g., UA says 'Mac OS X' but WebGL says 'Google SwiftShader' = flagged.
|
|
1141
|
+
Seed cookies and origin storage so authenticated runs can be hermetic.
|
|
1142
|
+
launch_persistent_context does not accept storage_state directly.
|
|
668
1143
|
"""
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1144
|
+
state = json.loads(Path(path).read_text(encoding='utf-8'))
|
|
1145
|
+
cookies = state.get('cookies') or []
|
|
1146
|
+
if cookies:
|
|
1147
|
+
self._context.add_cookies(cookies)
|
|
1148
|
+
origins = state.get('origins') or []
|
|
1149
|
+
if origins:
|
|
1150
|
+
self._context.add_init_script(
|
|
1151
|
+
"(() => { const seed = " + json.dumps(origins) + ";"
|
|
1152
|
+
" const match = seed.find(o => o.origin === location.origin);"
|
|
1153
|
+
" if (!match) return;"
|
|
1154
|
+
" for (const item of (match.localStorage || [])) {"
|
|
1155
|
+
" try { localStorage.setItem(item.name, item.value); } catch (e) {}"
|
|
1156
|
+
" }"
|
|
1157
|
+
"})();"
|
|
1158
|
+
)
|
|
1159
|
+
audit_log("storage_state_loaded", str(path),
|
|
1160
|
+
f"cookies={len(cookies)},origins={len(origins)}")
|
|
679
1161
|
|
|
680
1162
|
def stop(self):
|
|
1163
|
+
if self._tracing_active and self._context:
|
|
1164
|
+
try:
|
|
1165
|
+
self._context.tracing.stop(path=str(self.trace_path))
|
|
1166
|
+
except Exception as exc:
|
|
1167
|
+
print(f"⚠️ Trace write failed: {exc}", file=sys.stderr)
|
|
681
1168
|
if self._context:
|
|
682
1169
|
try:
|
|
683
1170
|
self._context.close()
|
|
@@ -688,8 +1175,11 @@ class StealthBrowserSession:
|
|
|
688
1175
|
self._playwright.stop()
|
|
689
1176
|
except Exception:
|
|
690
1177
|
pass
|
|
1178
|
+
if self._temp_profile_dir:
|
|
1179
|
+
shutil.rmtree(self._temp_profile_dir, ignore_errors=True)
|
|
691
1180
|
audit_log("session_stop", f"profile={self.profile}")
|
|
692
1181
|
|
|
1182
|
+
# -- accessors ---------------------------------------------------------
|
|
693
1183
|
@property
|
|
694
1184
|
def page(self):
|
|
695
1185
|
return self._page
|
|
@@ -698,16 +1188,46 @@ class StealthBrowserSession:
|
|
|
698
1188
|
def context(self):
|
|
699
1189
|
return self._context
|
|
700
1190
|
|
|
1191
|
+
@property
|
|
1192
|
+
def pages(self):
|
|
1193
|
+
# Playwright's own context.pages is the source of truth; our list only
|
|
1194
|
+
# preserves discovery order.
|
|
1195
|
+
self._pump_events()
|
|
1196
|
+
self._ensure_pages_registered()
|
|
1197
|
+
return [p for p in self._pages if not p.is_closed()]
|
|
1198
|
+
|
|
1199
|
+
def switch_page(self, index: int):
|
|
1200
|
+
live = self.pages
|
|
1201
|
+
if index < 0 or index >= len(live):
|
|
1202
|
+
raise ValueError(f"No page at index {index} (open pages: {len(live)}).")
|
|
1203
|
+
self._page = live[index]
|
|
1204
|
+
return self._page
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
# ---------------------------------------------------------------------------
|
|
1208
|
+
# Result helpers
|
|
1209
|
+
# ---------------------------------------------------------------------------
|
|
1210
|
+
def with_diagnostics(session, result: dict) -> dict:
|
|
1211
|
+
"""Attach captured failure signals to a command result."""
|
|
1212
|
+
if not isinstance(result, dict):
|
|
1213
|
+
return result
|
|
1214
|
+
signals = session.diagnostics.drain()
|
|
1215
|
+
if signals:
|
|
1216
|
+
result = {**result, "diagnostics": signals}
|
|
1217
|
+
return result
|
|
1218
|
+
|
|
701
1219
|
|
|
702
1220
|
# ---------------------------------------------------------------------------
|
|
703
1221
|
# Commands
|
|
704
1222
|
# ---------------------------------------------------------------------------
|
|
705
1223
|
def cmd_open(session, url):
|
|
706
|
-
"""Navigate to URL."""
|
|
1224
|
+
"""Navigate to URL. Fails on HTTP >= 400 unless allow_http_error is set."""
|
|
1225
|
+
if not url:
|
|
1226
|
+
raise ValueError("Usage: open <url>")
|
|
707
1227
|
if session.local_only and not is_local_test_url(url):
|
|
708
1228
|
raise ValueError("--local-only permits only loopback HTTP(S), data:, and about: URLs.")
|
|
709
1229
|
|
|
710
|
-
session.page.goto(url, wait_until='domcontentloaded')
|
|
1230
|
+
response = session.page.goto(url, wait_until='domcontentloaded')
|
|
711
1231
|
try:
|
|
712
1232
|
session.page.wait_for_load_state('networkidle', timeout=15000)
|
|
713
1233
|
except Exception as exc:
|
|
@@ -716,25 +1236,71 @@ def cmd_open(session, url):
|
|
|
716
1236
|
raise
|
|
717
1237
|
title = session.page.title()
|
|
718
1238
|
current_url = session.page.url
|
|
719
|
-
|
|
720
|
-
|
|
1239
|
+
status = response.status if response is not None else None
|
|
1240
|
+
audit_log("open", url, f"status={status},title_chars={len(title)}")
|
|
1241
|
+
session.reverify_fingerprint()
|
|
1242
|
+
|
|
1243
|
+
result = {"status": "ok", "url": current_url, "title": title, "http_status": status}
|
|
1244
|
+
if status is not None and status >= 400 and not session.allow_http_error:
|
|
1245
|
+
result["status"] = "failed"
|
|
1246
|
+
result["message"] = (
|
|
1247
|
+
f"HTTP {status} for {current_url}. Pass --allow-http-error to treat this as success."
|
|
1248
|
+
)
|
|
1249
|
+
return result
|
|
721
1250
|
|
|
722
1251
|
|
|
723
|
-
def cmd_screenshot(session, output=None, cleanup=False):
|
|
724
|
-
"""
|
|
1252
|
+
def cmd_screenshot(session, output=None, cleanup=False, full_page=True, selector=None):
|
|
1253
|
+
"""Capture a screenshot and validate that it is not an empty frame."""
|
|
1254
|
+
session.screenshot_counter += 1
|
|
725
1255
|
if not output:
|
|
726
|
-
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%
|
|
1256
|
+
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
|
|
1257
|
+
name = f"screenshot_{ts}_{session.screenshot_counter:03d}.png"
|
|
727
1258
|
if cleanup:
|
|
728
|
-
# APFS
|
|
729
|
-
# Use /tmp (may be RAM-backed) for ephemeral screenshots.
|
|
1259
|
+
# APFS copy-on-write: prefer a temp location for ephemeral captures.
|
|
730
1260
|
output = os.path.join(tempfile.gettempdir(), f"browse_ss_{ts}.png")
|
|
731
1261
|
else:
|
|
732
|
-
output =
|
|
1262
|
+
output = name
|
|
733
1263
|
path = Path(output).resolve()
|
|
734
|
-
|
|
1264
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1265
|
+
|
|
1266
|
+
if selector:
|
|
1267
|
+
element = session.page.wait_for_selector(selector, state='visible')
|
|
1268
|
+
element.screenshot(path=str(path))
|
|
1269
|
+
else:
|
|
1270
|
+
session.page.screenshot(path=str(path), full_page=full_page)
|
|
1271
|
+
|
|
735
1272
|
size = path.stat().st_size
|
|
736
1273
|
audit_log("screenshot", str(path), f"size={size},ephemeral={cleanup}")
|
|
737
|
-
result = {
|
|
1274
|
+
result = {
|
|
1275
|
+
"status": "ok",
|
|
1276
|
+
"path": str(path),
|
|
1277
|
+
"size_bytes": size,
|
|
1278
|
+
"full_page": bool(full_page and not selector),
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
# A blank or error frame must not report success: a screenshot is evidence
|
|
1282
|
+
# only if something actually rendered.
|
|
1283
|
+
warnings = []
|
|
1284
|
+
if size < MIN_SCREENSHOT_BYTES:
|
|
1285
|
+
result["status"] = "failed"
|
|
1286
|
+
warnings.append(f"image is {size} bytes, below the {MIN_SCREENSHOT_BYTES}-byte floor")
|
|
1287
|
+
try:
|
|
1288
|
+
content = session.page.evaluate(
|
|
1289
|
+
"""() => {
|
|
1290
|
+
const body = document.body;
|
|
1291
|
+
const text = body ? body.innerText.trim().length : 0;
|
|
1292
|
+
const visuals = document.querySelectorAll('img,svg,canvas,video,input,button').length;
|
|
1293
|
+
return { text, visuals, url: location.href };
|
|
1294
|
+
}"""
|
|
1295
|
+
)
|
|
1296
|
+
if content['text'] == 0 and content['visuals'] == 0:
|
|
1297
|
+
result["status"] = "failed"
|
|
1298
|
+
warnings.append(f"page has no text or visual elements ({content['url']})")
|
|
1299
|
+
except Exception as exc:
|
|
1300
|
+
warnings.append(f"content probe failed: {exc}")
|
|
1301
|
+
if warnings:
|
|
1302
|
+
result["warnings"] = warnings
|
|
1303
|
+
result["message"] = "; ".join(warnings)
|
|
738
1304
|
if cleanup:
|
|
739
1305
|
result["cleanup_path"] = str(path)
|
|
740
1306
|
result["ephemeral"] = True
|
|
@@ -744,11 +1310,8 @@ def cmd_screenshot(session, output=None, cleanup=False):
|
|
|
744
1310
|
def cmd_read_dom(session, selector=None, sanitize=False):
|
|
745
1311
|
"""Read DOM text content."""
|
|
746
1312
|
if selector:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
text = el.inner_text() if el else ""
|
|
750
|
-
except Exception as e:
|
|
751
|
-
return {"status": "error", "message": str(e)}
|
|
1313
|
+
element = session.page.wait_for_selector(selector, timeout=10000)
|
|
1314
|
+
text = element.inner_text() if element else ""
|
|
752
1315
|
else:
|
|
753
1316
|
text = session.page.inner_text('body')
|
|
754
1317
|
if sanitize:
|
|
@@ -770,19 +1333,19 @@ def cmd_read_page(session, sanitize=False):
|
|
|
770
1333
|
|
|
771
1334
|
|
|
772
1335
|
def cmd_click(session, selector):
|
|
773
|
-
"""Click
|
|
774
|
-
|
|
1336
|
+
"""Click an element."""
|
|
1337
|
+
if not selector:
|
|
1338
|
+
raise ValueError("Usage: click <selector>")
|
|
1339
|
+
human_delay(100, 300, fast=session.fast)
|
|
775
1340
|
session.page.click(selector)
|
|
776
1341
|
audit_log("click", selector)
|
|
777
1342
|
return {"status": "ok", "action": "click", "selector": selector}
|
|
778
1343
|
|
|
779
1344
|
|
|
780
|
-
def cmd_type_text(session, selector, text, human=
|
|
781
|
-
"""Type text into element."""
|
|
782
|
-
if human
|
|
783
|
-
|
|
784
|
-
else:
|
|
785
|
-
session.page.fill(selector, text)
|
|
1345
|
+
def cmd_type_text(session, selector, text, human=None):
|
|
1346
|
+
"""Type text into an element."""
|
|
1347
|
+
use_human = (not session.fast) if human is None else human
|
|
1348
|
+
human_type(session.page, text, selector, fast=not use_human)
|
|
786
1349
|
audit_log("type", selector, f"chars={len(text)}")
|
|
787
1350
|
return {"status": "ok", "action": "type", "selector": selector, "chars": len(text)}
|
|
788
1351
|
|
|
@@ -797,36 +1360,254 @@ def cmd_press(session, key):
|
|
|
797
1360
|
|
|
798
1361
|
|
|
799
1362
|
def cmd_scroll(session, direction="down", amount=3):
|
|
800
|
-
"""Scroll page
|
|
801
|
-
human_scroll(session.page, direction, amount)
|
|
1363
|
+
"""Scroll the page."""
|
|
1364
|
+
human_scroll(session.page, direction, amount, fast=session.fast)
|
|
802
1365
|
audit_log("scroll", direction, f"steps={amount}")
|
|
803
1366
|
return {"status": "ok", "action": "scroll", "direction": direction}
|
|
804
1367
|
|
|
805
1368
|
|
|
806
1369
|
def cmd_wait_for(session, selector, timeout=None):
|
|
807
|
-
"""Wait for element
|
|
1370
|
+
"""Wait for an element to become visible."""
|
|
1371
|
+
if not selector:
|
|
1372
|
+
raise ValueError("Usage: wait-for <selector>")
|
|
808
1373
|
t = timeout or session.timeout
|
|
809
1374
|
try:
|
|
810
|
-
|
|
811
|
-
preview =
|
|
1375
|
+
element = session.page.wait_for_selector(selector, timeout=t, state='visible')
|
|
1376
|
+
preview = element.inner_text()[:100] if element else ""
|
|
812
1377
|
audit_log("wait_for", selector, "found=true")
|
|
813
1378
|
return {"status": "ok", "found": True, "preview": preview}
|
|
814
|
-
except Exception as
|
|
815
|
-
audit_log("wait_for", selector,
|
|
816
|
-
return {"status": "timeout", "found": False, "error": str(
|
|
1379
|
+
except Exception as exc:
|
|
1380
|
+
audit_log("wait_for", selector, "found=false")
|
|
1381
|
+
return {"status": "timeout", "found": False, "error": str(exc)}
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _json_safe(value):
|
|
1385
|
+
"""Return the value if JSON-serializable, else a marker."""
|
|
1386
|
+
try:
|
|
1387
|
+
json.dumps(value)
|
|
1388
|
+
return value, True
|
|
1389
|
+
except (TypeError, ValueError):
|
|
1390
|
+
return str(value), False
|
|
817
1391
|
|
|
818
1392
|
|
|
819
1393
|
def cmd_eval(session, js_code, sanitize=False):
|
|
820
|
-
"""
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1394
|
+
"""
|
|
1395
|
+
Evaluate JavaScript and return the native value, JSON-encoded by the
|
|
1396
|
+
caller. The previous implementation returned str(result), which produced
|
|
1397
|
+
Python reprs ("None", "False", "{'a': 1}") that no JSON consumer can parse.
|
|
1398
|
+
"""
|
|
1399
|
+
if not js_code:
|
|
1400
|
+
raise ValueError("Usage: eval <js>")
|
|
1401
|
+
raw = session.page.evaluate(js_code)
|
|
1402
|
+
value, serializable = _json_safe(raw)
|
|
1403
|
+
if sanitize and isinstance(value, str):
|
|
1404
|
+
value = sanitize_phi(value)
|
|
1405
|
+
digest = hashlib.sha256(js_code.encode('utf-8')).hexdigest()[:12]
|
|
1406
|
+
audit_log("eval", f"sha256={digest}", f"source={js_code[:120]}")
|
|
1407
|
+
return {
|
|
1408
|
+
"status": "ok",
|
|
1409
|
+
"result": value,
|
|
1410
|
+
"type": type(raw).__name__,
|
|
1411
|
+
"serializable": serializable,
|
|
1412
|
+
}
|
|
827
1413
|
|
|
828
1414
|
|
|
829
|
-
#
|
|
1415
|
+
# -- assertions ---------------------------------------------------------------
|
|
1416
|
+
def _assertion(passed: bool, detail: dict) -> dict:
|
|
1417
|
+
payload = {"status": "ok" if passed else "failed", "passed": passed, **detail}
|
|
1418
|
+
if not passed:
|
|
1419
|
+
payload["message"] = detail.get("message") or "assertion failed"
|
|
1420
|
+
return payload
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def cmd_assert_text(session, selector, expected, timeout=None):
|
|
1424
|
+
"""Assert that an element's text contains the expected substring."""
|
|
1425
|
+
if not selector or expected is None:
|
|
1426
|
+
raise ValueError('Usage: assert-text <selector> <expected>')
|
|
1427
|
+
element = session.page.wait_for_selector(selector, timeout=timeout or session.timeout)
|
|
1428
|
+
actual = element.inner_text() if element else ""
|
|
1429
|
+
passed = expected in actual
|
|
1430
|
+
audit_log("assert_text", selector, f"passed={passed}")
|
|
1431
|
+
return _assertion(passed, {
|
|
1432
|
+
"assertion": "text-contains",
|
|
1433
|
+
"selector": selector,
|
|
1434
|
+
"expected": expected,
|
|
1435
|
+
"actual": (sanitize_phi(actual) if session.sanitize else actual)[:400],
|
|
1436
|
+
"message": None if passed else f"{selector!r} text does not contain {expected!r}",
|
|
1437
|
+
})
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def cmd_assert_visible(session, selector, expect_visible=True, timeout=None):
|
|
1441
|
+
"""Assert that an element is (or is not) visible."""
|
|
1442
|
+
if not selector:
|
|
1443
|
+
raise ValueError('Usage: assert-visible <selector>')
|
|
1444
|
+
state = 'visible' if expect_visible else 'hidden'
|
|
1445
|
+
try:
|
|
1446
|
+
session.page.wait_for_selector(selector, timeout=timeout or session.timeout, state=state)
|
|
1447
|
+
passed = True
|
|
1448
|
+
error = None
|
|
1449
|
+
except Exception as exc:
|
|
1450
|
+
passed = False
|
|
1451
|
+
error = str(exc).split('\n')[0]
|
|
1452
|
+
audit_log("assert_visible", selector, f"expect={state},passed={passed}")
|
|
1453
|
+
return _assertion(passed, {
|
|
1454
|
+
"assertion": state,
|
|
1455
|
+
"selector": selector,
|
|
1456
|
+
"message": None if passed else f"{selector!r} is not {state}: {error}",
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def cmd_assert_count(session, selector, expected):
|
|
1461
|
+
"""Assert an exact match count for a selector."""
|
|
1462
|
+
if not selector or expected is None:
|
|
1463
|
+
raise ValueError('Usage: assert-count <selector> <n>')
|
|
1464
|
+
expected_n = int(expected)
|
|
1465
|
+
actual = len(session.page.query_selector_all(selector))
|
|
1466
|
+
passed = actual == expected_n
|
|
1467
|
+
audit_log("assert_count", selector, f"expected={expected_n},actual={actual}")
|
|
1468
|
+
return _assertion(passed, {
|
|
1469
|
+
"assertion": "count",
|
|
1470
|
+
"selector": selector,
|
|
1471
|
+
"expected": expected_n,
|
|
1472
|
+
"actual": actual,
|
|
1473
|
+
"message": None if passed else f"expected {expected_n} match(es) for {selector!r}, found {actual}",
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def cmd_assert_url(session, expected):
|
|
1478
|
+
"""Assert that the current URL contains the expected substring."""
|
|
1479
|
+
if not expected:
|
|
1480
|
+
raise ValueError('Usage: assert-url <substring>')
|
|
1481
|
+
actual = session.page.url
|
|
1482
|
+
passed = expected in actual
|
|
1483
|
+
audit_log("assert_url", actual, f"passed={passed}")
|
|
1484
|
+
return _assertion(passed, {
|
|
1485
|
+
"assertion": "url-contains",
|
|
1486
|
+
"expected": expected,
|
|
1487
|
+
"actual": actual,
|
|
1488
|
+
"message": None if passed else f"URL {actual!r} does not contain {expected!r}",
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def cmd_assert_title(session, expected):
|
|
1493
|
+
"""Assert that the document title contains the expected substring."""
|
|
1494
|
+
if not expected:
|
|
1495
|
+
raise ValueError('Usage: assert-title <substring>')
|
|
1496
|
+
actual = session.page.title()
|
|
1497
|
+
passed = expected in actual
|
|
1498
|
+
audit_log("assert_title", "title", f"passed={passed}")
|
|
1499
|
+
return _assertion(passed, {
|
|
1500
|
+
"assertion": "title-contains",
|
|
1501
|
+
"expected": expected,
|
|
1502
|
+
"actual": actual,
|
|
1503
|
+
"message": None if passed else f"title {actual!r} does not contain {expected!r}",
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def cmd_assert_eval(session, js_code):
|
|
1508
|
+
"""Assert that a JavaScript expression is truthy."""
|
|
1509
|
+
if not js_code:
|
|
1510
|
+
raise ValueError('Usage: assert-eval <js>')
|
|
1511
|
+
raw = session.page.evaluate(js_code)
|
|
1512
|
+
value, _ = _json_safe(raw)
|
|
1513
|
+
passed = bool(raw)
|
|
1514
|
+
digest = hashlib.sha256(js_code.encode('utf-8')).hexdigest()[:12]
|
|
1515
|
+
audit_log("assert_eval", f"sha256={digest}", f"passed={passed},source={js_code[:120]}")
|
|
1516
|
+
return _assertion(passed, {
|
|
1517
|
+
"assertion": "eval-truthy",
|
|
1518
|
+
"result": value,
|
|
1519
|
+
"message": None if passed else f"expression is falsy (result={value!r})",
|
|
1520
|
+
})
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def cmd_assert_no_page_errors(session):
|
|
1524
|
+
"""Assert that no uncaught page exception occurred in this session."""
|
|
1525
|
+
passed = not session.diagnostics.saw_page_error
|
|
1526
|
+
errors = list(session.diagnostics.all_page_errors)
|
|
1527
|
+
return _assertion(passed, {
|
|
1528
|
+
"assertion": "no-page-errors",
|
|
1529
|
+
"page_errors": errors,
|
|
1530
|
+
"message": None if passed else f"{len(errors) or 'some'} uncaught page error(s) occurred",
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
# -- page management ----------------------------------------------------------
|
|
1535
|
+
def cmd_pages(session):
|
|
1536
|
+
"""List open pages so popups and OAuth windows are reachable."""
|
|
1537
|
+
live = session.pages
|
|
1538
|
+
current = session.page
|
|
1539
|
+
listing = []
|
|
1540
|
+
for index, page in enumerate(live):
|
|
1541
|
+
listing.append({
|
|
1542
|
+
"index": index,
|
|
1543
|
+
"url": page.url,
|
|
1544
|
+
"title": page.title(),
|
|
1545
|
+
"current": page is current,
|
|
1546
|
+
})
|
|
1547
|
+
return {"status": "ok", "action": "pages", "count": len(listing), "pages": listing}
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def cmd_switch_page(session, index):
|
|
1551
|
+
"""Switch the active page. Without this, popups are unreachable."""
|
|
1552
|
+
if index in (None, ""):
|
|
1553
|
+
raise ValueError("Usage: switch-page <index>")
|
|
1554
|
+
page = session.switch_page(int(index))
|
|
1555
|
+
page.bring_to_front()
|
|
1556
|
+
audit_log("switch_page", page.url, f"index={index}")
|
|
1557
|
+
return {"status": "ok", "action": "switch-page", "index": int(index), "url": page.url}
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def cmd_close_page(session, index=None):
|
|
1561
|
+
"""Close a page and fall back to the first remaining one."""
|
|
1562
|
+
live = session.pages
|
|
1563
|
+
target = session.page if index in (None, "") else live[int(index)]
|
|
1564
|
+
if len(live) <= 1:
|
|
1565
|
+
raise ValueError("Refusing to close the last open page.")
|
|
1566
|
+
url = target.url
|
|
1567
|
+
target.close()
|
|
1568
|
+
session.switch_page(0)
|
|
1569
|
+
audit_log("close_page", url)
|
|
1570
|
+
return {"status": "ok", "action": "close-page", "closed": url}
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def cmd_save_storage(session, path):
|
|
1574
|
+
"""Persist cookies and origin storage for hermetic reuse."""
|
|
1575
|
+
if not path:
|
|
1576
|
+
raise ValueError("Usage: save-storage <path>")
|
|
1577
|
+
target = Path(path).expanduser().resolve()
|
|
1578
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1579
|
+
session.context.storage_state(path=str(target))
|
|
1580
|
+
try:
|
|
1581
|
+
os.chmod(target, 0o600)
|
|
1582
|
+
except Exception:
|
|
1583
|
+
pass
|
|
1584
|
+
audit_log("save_storage", str(target))
|
|
1585
|
+
return {"status": "ok", "action": "save-storage", "path": str(target)}
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def cmd_diagnostics(session):
|
|
1589
|
+
"""Report captured console errors, page errors, and failed requests."""
|
|
1590
|
+
signals = session.diagnostics.drain()
|
|
1591
|
+
return {
|
|
1592
|
+
"status": "ok",
|
|
1593
|
+
"action": "diagnostics",
|
|
1594
|
+
"saw_page_error": session.diagnostics.saw_page_error,
|
|
1595
|
+
**({"signals": signals} if signals else {"signals": {}}),
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def cmd_fingerprint(session):
|
|
1600
|
+
"""Report the verified fingerprint state instead of asserting it."""
|
|
1601
|
+
return {
|
|
1602
|
+
"status": "ok",
|
|
1603
|
+
"action": "fingerprint",
|
|
1604
|
+
"stealth_level": session.stealth_level,
|
|
1605
|
+
"degraded": session.stealth_degraded,
|
|
1606
|
+
"report": session.fingerprint,
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
# -- Google Docs --------------------------------------------------------------
|
|
830
1611
|
def cmd_gdoc_read(session, sanitize=False):
|
|
831
1612
|
"""Read Google Doc content using keyboard shortcuts."""
|
|
832
1613
|
page = session.page
|
|
@@ -838,25 +1619,20 @@ def cmd_gdoc_read(session, sanitize=False):
|
|
|
838
1619
|
except Exception:
|
|
839
1620
|
page.wait_for_load_state('networkidle')
|
|
840
1621
|
|
|
841
|
-
|
|
842
|
-
try:
|
|
843
|
-
page.click('.kix-appview-editor', timeout=5000)
|
|
844
|
-
except Exception:
|
|
1622
|
+
for selector in ('.kix-appview-editor', '.kix-page'):
|
|
845
1623
|
try:
|
|
846
|
-
page.click(
|
|
1624
|
+
page.click(selector, timeout=5000)
|
|
1625
|
+
break
|
|
847
1626
|
except Exception:
|
|
848
|
-
|
|
1627
|
+
continue
|
|
849
1628
|
|
|
850
|
-
human_delay(300, 600)
|
|
1629
|
+
human_delay(300, 600, fast=session.fast)
|
|
851
1630
|
mod = 'Meta' if sys.platform == 'darwin' else 'Control'
|
|
852
|
-
|
|
853
|
-
# Select all + copy
|
|
854
1631
|
page.keyboard.press(f'{mod}+a')
|
|
855
|
-
human_delay(200, 400)
|
|
1632
|
+
human_delay(200, 400, fast=session.fast)
|
|
856
1633
|
page.keyboard.press(f'{mod}+c')
|
|
857
|
-
human_delay(300, 600)
|
|
1634
|
+
human_delay(300, 600, fast=session.fast)
|
|
858
1635
|
|
|
859
|
-
# Try clipboard
|
|
860
1636
|
text = None
|
|
861
1637
|
try:
|
|
862
1638
|
text = page.evaluate('''async () => {
|
|
@@ -866,7 +1642,6 @@ def cmd_gdoc_read(session, sanitize=False):
|
|
|
866
1642
|
except Exception:
|
|
867
1643
|
pass
|
|
868
1644
|
|
|
869
|
-
# Fallback: DOM extraction
|
|
870
1645
|
if not text:
|
|
871
1646
|
try:
|
|
872
1647
|
text = page.evaluate('''() => {
|
|
@@ -878,88 +1653,246 @@ def cmd_gdoc_read(session, sanitize=False):
|
|
|
878
1653
|
except Exception:
|
|
879
1654
|
text = page.inner_text('body')
|
|
880
1655
|
|
|
881
|
-
page.keyboard.press('End')
|
|
882
|
-
|
|
1656
|
+
page.keyboard.press('End')
|
|
883
1657
|
if sanitize and text:
|
|
884
1658
|
text = sanitize_phi(text)
|
|
885
|
-
|
|
886
1659
|
audit_log("gdoc_read", page.url, f"chars={len(text) if text else 0}")
|
|
887
1660
|
return {"status": "ok", "text": text or ""}
|
|
888
1661
|
|
|
889
1662
|
|
|
890
1663
|
def cmd_gdoc_type(session, text):
|
|
891
|
-
"""Type text at cursor in Google Doc
|
|
1664
|
+
"""Type text at the cursor in a Google Doc."""
|
|
892
1665
|
page = session.page
|
|
893
1666
|
if 'docs.google.com' not in page.url:
|
|
894
1667
|
return {"status": "error", "message": "Not on a Google Doc page."}
|
|
895
|
-
human_type(page, text)
|
|
1668
|
+
human_type(page, text, fast=session.fast)
|
|
896
1669
|
audit_log("gdoc_type", page.url, f"chars={len(text)}")
|
|
897
1670
|
return {"status": "ok", "action": "gdoc_type", "chars": len(text)}
|
|
898
1671
|
|
|
899
1672
|
|
|
900
1673
|
def cmd_gdoc_find(session, search_text):
|
|
901
|
-
"""Find text in Google Doc
|
|
1674
|
+
"""Find text in a Google Doc."""
|
|
902
1675
|
page = session.page
|
|
903
1676
|
if 'docs.google.com' not in page.url:
|
|
904
1677
|
return {"status": "error", "message": "Not on a Google Doc page."}
|
|
905
1678
|
mod = 'Meta' if sys.platform == 'darwin' else 'Control'
|
|
906
1679
|
page.keyboard.press(f'{mod}+f')
|
|
907
|
-
human_delay(300, 600)
|
|
908
|
-
human_type(page, search_text)
|
|
909
|
-
human_delay(300, 500)
|
|
1680
|
+
human_delay(300, 600, fast=session.fast)
|
|
1681
|
+
human_type(page, search_text, fast=session.fast)
|
|
1682
|
+
human_delay(300, 500, fast=session.fast)
|
|
910
1683
|
page.keyboard.press('Enter')
|
|
911
|
-
human_delay(300, 500)
|
|
1684
|
+
human_delay(300, 500, fast=session.fast)
|
|
912
1685
|
page.keyboard.press('Escape')
|
|
913
|
-
human_delay(200, 400)
|
|
1686
|
+
human_delay(200, 400, fast=session.fast)
|
|
914
1687
|
audit_log("gdoc_find", page.url, f"query_len={len(search_text)}")
|
|
915
1688
|
return {"status": "ok", "action": "gdoc_find"}
|
|
916
1689
|
|
|
917
1690
|
|
|
918
1691
|
def cmd_stealth_test(session):
|
|
919
|
-
"""Run bot
|
|
1692
|
+
"""Run an external bot-detection page and report a verdict."""
|
|
1693
|
+
if session.local_only:
|
|
1694
|
+
raise ValueError(
|
|
1695
|
+
"stealth-test navigates to a public detector and cannot run under --local-only."
|
|
1696
|
+
)
|
|
920
1697
|
page = session.page
|
|
921
|
-
page.goto('https://bot.sannysoft.com/', wait_until='networkidle')
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
const rows = document.querySelectorAll('table tr');
|
|
927
|
-
const
|
|
928
|
-
|
|
1698
|
+
response = page.goto('https://bot.sannysoft.com/', wait_until='networkidle')
|
|
1699
|
+
http_status = response.status if response is not None else None
|
|
1700
|
+
human_delay(2000, 3000, fast=session.fast)
|
|
1701
|
+
|
|
1702
|
+
parsed = page.evaluate('''() => {
|
|
1703
|
+
const rows = Array.from(document.querySelectorAll('table tr'));
|
|
1704
|
+
const tests = [];
|
|
1705
|
+
for (const row of rows) {
|
|
929
1706
|
const cells = row.querySelectorAll('td');
|
|
930
|
-
if (cells.length
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
});
|
|
942
|
-
return results;
|
|
1707
|
+
if (cells.length < 2) continue;
|
|
1708
|
+
const name = cells[0].textContent.trim();
|
|
1709
|
+
const cell = cells[1];
|
|
1710
|
+
const value = cell.textContent.trim();
|
|
1711
|
+
let verdict = 'unknown';
|
|
1712
|
+
if (cell.classList.contains('result-failed')) verdict = 'failed';
|
|
1713
|
+
else if (cell.classList.contains('result-passed')) verdict = 'passed';
|
|
1714
|
+
else if (value) verdict = 'informational';
|
|
1715
|
+
tests.push({ name, value, verdict });
|
|
1716
|
+
}
|
|
1717
|
+
return { rowCount: rows.length, tests };
|
|
943
1718
|
}''')
|
|
944
1719
|
|
|
945
|
-
#
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1720
|
+
# The detector table must actually have loaded, otherwise "no failures"
|
|
1721
|
+
# means "no data" rather than "clean".
|
|
1722
|
+
if parsed['rowCount'] == 0 or not parsed['tests']:
|
|
1723
|
+
return {
|
|
1724
|
+
"status": "error",
|
|
1725
|
+
"action": "stealth-test",
|
|
1726
|
+
"http_status": http_status,
|
|
1727
|
+
"message": "Detector table did not load; results would be meaningless.",
|
|
1728
|
+
}
|
|
949
1729
|
|
|
950
|
-
|
|
1730
|
+
failed = [t for t in parsed['tests'] if t['verdict'] == 'failed']
|
|
1731
|
+
observed = page.evaluate('''() => ({
|
|
1732
|
+
webdriver: navigator.webdriver === undefined ? 'undefined' : String(navigator.webdriver),
|
|
1733
|
+
chrome: !!window.chrome,
|
|
1734
|
+
plugins: navigator.plugins.length,
|
|
1735
|
+
pluginsBrand: Object.prototype.toString.call(navigator.plugins),
|
|
1736
|
+
brands: navigator.userAgentData
|
|
1737
|
+
? navigator.userAgentData.brands.map(b => b.brand + '/' + b.version).join(',')
|
|
1738
|
+
: 'absent',
|
|
1739
|
+
})''')
|
|
1740
|
+
headless_leak = 'Headless' in (observed.get('brands') or '')
|
|
1741
|
+
audit_log("stealth_test", "bot.sannysoft.com",
|
|
1742
|
+
f"failed={len(failed)},headless_leak={headless_leak}")
|
|
951
1743
|
|
|
952
1744
|
return {
|
|
953
|
-
"status": "ok",
|
|
954
|
-
"
|
|
955
|
-
"
|
|
956
|
-
"
|
|
957
|
-
"
|
|
1745
|
+
"status": "ok" if not failed and not headless_leak else "failed",
|
|
1746
|
+
"action": "stealth-test",
|
|
1747
|
+
"http_status": http_status,
|
|
1748
|
+
"tests_evaluated": len(parsed['tests']),
|
|
1749
|
+
"failed_count": len(failed),
|
|
1750
|
+
"failed_tests": [t['name'] for t in failed][:20],
|
|
1751
|
+
"headless_identity_leak": headless_leak,
|
|
1752
|
+
"observed": observed,
|
|
1753
|
+
"message": None if not failed and not headless_leak
|
|
1754
|
+
else f"{len(failed)} detector check(s) failed; headless_leak={headless_leak}",
|
|
958
1755
|
}
|
|
959
1756
|
|
|
960
1757
|
|
|
961
1758
|
# ---------------------------------------------------------------------------
|
|
962
|
-
#
|
|
1759
|
+
# Command dispatch
|
|
1760
|
+
# ---------------------------------------------------------------------------
|
|
1761
|
+
HELP_COMMANDS = [
|
|
1762
|
+
"open <url>", "screenshot [path]", "read-dom [selector]", "read-page",
|
|
1763
|
+
"click <selector>", "type <selector> <text>", "scroll [up|down]",
|
|
1764
|
+
"press <key>", "wait-for <selector>", "wait <seconds>", "eval <js>",
|
|
1765
|
+
"assert-text <selector> <expected>", "assert-visible <selector>",
|
|
1766
|
+
"assert-hidden <selector>", "assert-count <selector> <n>",
|
|
1767
|
+
"assert-url <substring>", "assert-title <substring>", "assert-eval <js>",
|
|
1768
|
+
"assert-no-page-errors",
|
|
1769
|
+
"pages", "switch-page <index>", "close-page [index]",
|
|
1770
|
+
"save-storage <path>", "diagnostics", "fingerprint",
|
|
1771
|
+
"gdoc-read", "gdoc-type <text>", "gdoc-find <text>",
|
|
1772
|
+
"stealth-test", "url", "title", "quit",
|
|
1773
|
+
]
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def split_args(arg: str, count: int) -> list[str]:
|
|
1777
|
+
"""
|
|
1778
|
+
Split a command argument into `count` fields.
|
|
1779
|
+
|
|
1780
|
+
Quoted input is parsed with shlex so selectors and values may contain
|
|
1781
|
+
spaces ('type "div > .cell" "two words"'). Unquoted input keeps the legacy
|
|
1782
|
+
whitespace split so existing callers are unaffected.
|
|
1783
|
+
"""
|
|
1784
|
+
stripped = arg.strip()
|
|
1785
|
+
if stripped[:1] in ('"', "'"):
|
|
1786
|
+
tokens = shlex.split(stripped)
|
|
1787
|
+
if len(tokens) < count:
|
|
1788
|
+
raise ValueError(f"Expected {count} arguments, received {len(tokens)}.")
|
|
1789
|
+
if len(tokens) > count:
|
|
1790
|
+
tokens = tokens[:count - 1] + [' '.join(tokens[count - 1:])]
|
|
1791
|
+
return tokens
|
|
1792
|
+
parts = stripped.split(maxsplit=count - 1)
|
|
1793
|
+
if len(parts) < count:
|
|
1794
|
+
raise ValueError(f"Expected {count} arguments, received {len(parts)}.")
|
|
1795
|
+
return parts
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def dispatch(session, cmd: str, arg: str, sanitize=False, cleanup=False):
|
|
1799
|
+
"""Execute one command line. Raises on usage errors."""
|
|
1800
|
+
if cmd == 'open':
|
|
1801
|
+
return cmd_open(session, arg)
|
|
1802
|
+
if cmd == 'screenshot':
|
|
1803
|
+
return cmd_screenshot(session, arg or None, cleanup=cleanup)
|
|
1804
|
+
if cmd in ('read-dom', 'readdom', 'dom'):
|
|
1805
|
+
return cmd_read_dom(session, arg or None, sanitize)
|
|
1806
|
+
if cmd in ('read-page', 'readpage', 'page'):
|
|
1807
|
+
return cmd_read_page(session, sanitize)
|
|
1808
|
+
if cmd == 'click':
|
|
1809
|
+
return cmd_click(session, arg)
|
|
1810
|
+
if cmd == 'type':
|
|
1811
|
+
selector, text = split_args(arg, 2)
|
|
1812
|
+
return cmd_type_text(session, selector, text)
|
|
1813
|
+
if cmd == 'press':
|
|
1814
|
+
return cmd_press(session, arg)
|
|
1815
|
+
if cmd == 'scroll':
|
|
1816
|
+
return cmd_scroll(session, arg or "down")
|
|
1817
|
+
if cmd in ('wait-for', 'waitfor'):
|
|
1818
|
+
return cmd_wait_for(session, arg)
|
|
1819
|
+
if cmd == 'wait':
|
|
1820
|
+
seconds = float(arg) if arg else 1
|
|
1821
|
+
# Playwright's own wait, not time.sleep: sleeping blocks the sync
|
|
1822
|
+
# dispatcher, so events queued during the pause (new pages, console
|
|
1823
|
+
# errors, page exceptions) would not be delivered.
|
|
1824
|
+
try:
|
|
1825
|
+
session.page.wait_for_timeout(seconds * 1000)
|
|
1826
|
+
except Exception:
|
|
1827
|
+
time.sleep(seconds)
|
|
1828
|
+
return {"status": "ok", "action": "wait"}
|
|
1829
|
+
if cmd == 'eval':
|
|
1830
|
+
return cmd_eval(session, arg, sanitize)
|
|
1831
|
+
if cmd == 'assert-text':
|
|
1832
|
+
selector, expected = split_args(arg, 2)
|
|
1833
|
+
return cmd_assert_text(session, selector, expected)
|
|
1834
|
+
if cmd == 'assert-visible':
|
|
1835
|
+
return cmd_assert_visible(session, arg, expect_visible=True)
|
|
1836
|
+
if cmd == 'assert-hidden':
|
|
1837
|
+
return cmd_assert_visible(session, arg, expect_visible=False)
|
|
1838
|
+
if cmd == 'assert-count':
|
|
1839
|
+
selector, expected = split_args(arg, 2)
|
|
1840
|
+
return cmd_assert_count(session, selector, expected)
|
|
1841
|
+
if cmd == 'assert-url':
|
|
1842
|
+
return cmd_assert_url(session, arg)
|
|
1843
|
+
if cmd == 'assert-title':
|
|
1844
|
+
return cmd_assert_title(session, arg)
|
|
1845
|
+
if cmd == 'assert-eval':
|
|
1846
|
+
return cmd_assert_eval(session, arg)
|
|
1847
|
+
if cmd == 'assert-no-page-errors':
|
|
1848
|
+
return cmd_assert_no_page_errors(session)
|
|
1849
|
+
if cmd == 'pages':
|
|
1850
|
+
return cmd_pages(session)
|
|
1851
|
+
if cmd == 'switch-page':
|
|
1852
|
+
return cmd_switch_page(session, arg)
|
|
1853
|
+
if cmd == 'close-page':
|
|
1854
|
+
return cmd_close_page(session, arg or None)
|
|
1855
|
+
if cmd == 'save-storage':
|
|
1856
|
+
return cmd_save_storage(session, arg)
|
|
1857
|
+
if cmd == 'diagnostics':
|
|
1858
|
+
return cmd_diagnostics(session)
|
|
1859
|
+
if cmd == 'fingerprint':
|
|
1860
|
+
return cmd_fingerprint(session)
|
|
1861
|
+
if cmd in ('gdoc-read', 'gdocread'):
|
|
1862
|
+
return cmd_gdoc_read(session, sanitize)
|
|
1863
|
+
if cmd in ('gdoc-type', 'gdoctype'):
|
|
1864
|
+
return cmd_gdoc_type(session, arg)
|
|
1865
|
+
if cmd in ('gdoc-find', 'gdocfind'):
|
|
1866
|
+
return cmd_gdoc_find(session, arg)
|
|
1867
|
+
if cmd in ('stealth-test', 'stealthtest', 'test'):
|
|
1868
|
+
return cmd_stealth_test(session)
|
|
1869
|
+
if cmd == 'url':
|
|
1870
|
+
return {"status": "ok", "action": "url", "url": session.page.url}
|
|
1871
|
+
if cmd == 'title':
|
|
1872
|
+
return {"status": "ok", "action": "title", "title": session.page.title()}
|
|
1873
|
+
return None
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _join_continuations(lines):
|
|
1877
|
+
"""
|
|
1878
|
+
Allow a trailing backslash to continue a command onto the next line so
|
|
1879
|
+
multi-line JavaScript and multi-line text are expressible in a
|
|
1880
|
+
line-oriented protocol.
|
|
1881
|
+
"""
|
|
1882
|
+
buffer = ""
|
|
1883
|
+
for line in lines:
|
|
1884
|
+
stripped = line.rstrip('\n')
|
|
1885
|
+
if stripped.endswith('\\'):
|
|
1886
|
+
buffer += stripped[:-1] + "\n"
|
|
1887
|
+
continue
|
|
1888
|
+
yield buffer + stripped
|
|
1889
|
+
buffer = ""
|
|
1890
|
+
if buffer:
|
|
1891
|
+
yield buffer
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
# ---------------------------------------------------------------------------
|
|
1895
|
+
# REPL mode
|
|
963
1896
|
# ---------------------------------------------------------------------------
|
|
964
1897
|
class _IdleTimeoutError(Exception):
|
|
965
1898
|
pass
|
|
@@ -970,52 +1903,51 @@ def _alarm_handler(signum, frame):
|
|
|
970
1903
|
|
|
971
1904
|
|
|
972
1905
|
def _read_input_with_timeout(prompt, timeout_sec):
|
|
973
|
-
"""Read input with idle timeout. Uses SIGALRM on Unix."""
|
|
1906
|
+
"""Read input with an idle timeout. Uses SIGALRM on Unix."""
|
|
974
1907
|
if hasattr(signal, 'SIGALRM'):
|
|
975
1908
|
old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
|
|
976
1909
|
signal.alarm(timeout_sec)
|
|
977
1910
|
try:
|
|
978
|
-
|
|
979
|
-
signal.alarm(0) # Cancel alarm
|
|
980
|
-
return line
|
|
1911
|
+
return input(prompt)
|
|
981
1912
|
except _IdleTimeoutError:
|
|
982
|
-
return None
|
|
1913
|
+
return None
|
|
983
1914
|
finally:
|
|
1915
|
+
# Always disarm. Leaving the alarm pending while restoring the
|
|
1916
|
+
# previous handler (SIG_DFL) let a later SIGALRM terminate the
|
|
1917
|
+
# process mid-shutdown and orphan Chromium.
|
|
1918
|
+
signal.alarm(0)
|
|
984
1919
|
signal.signal(signal.SIGALRM, old_handler)
|
|
985
|
-
|
|
986
|
-
# Fallback for systems without SIGALRM (shouldn't happen on Mac)
|
|
987
|
-
return input(prompt)
|
|
1920
|
+
return input(prompt)
|
|
988
1921
|
|
|
989
1922
|
|
|
990
|
-
def run_repl(session, sanitize=False):
|
|
1923
|
+
def run_repl(session, sanitize=False, cleanup=False, fail_fast=False):
|
|
991
1924
|
"""
|
|
992
|
-
Interactive REPL — keeps browser open between commands.
|
|
993
|
-
|
|
994
|
-
Features:
|
|
995
|
-
- 10-min idle timeout: auto-closes browser to prevent zombie Chromium
|
|
996
|
-
- Error resilience: exceptions are caught and returned as JSON, browser stays alive
|
|
997
|
-
- Structured JSON output on stdout for agent parsing
|
|
1925
|
+
Interactive REPL — keeps the browser open between commands.
|
|
1926
|
+
Returns True when every command succeeded.
|
|
998
1927
|
"""
|
|
999
1928
|
print(json.dumps({
|
|
1000
1929
|
"status": "ok",
|
|
1001
1930
|
"action": "repl_start",
|
|
1002
|
-
"profile": session
|
|
1003
|
-
"stealth": session
|
|
1931
|
+
"profile": getattr(session, 'profile', 'unknown'),
|
|
1932
|
+
"stealth": getattr(session, 'stealth_level', 'unknown'),
|
|
1004
1933
|
"stealth_lib": "playwright-stealth-v2" if STEALTH_AVAILABLE else "js-only",
|
|
1934
|
+
"stealth_degraded": getattr(session, 'stealth_degraded', []),
|
|
1005
1935
|
"idle_timeout_sec": REPL_IDLE_TIMEOUT,
|
|
1006
1936
|
}))
|
|
1007
1937
|
sys.stdout.flush()
|
|
1008
1938
|
|
|
1939
|
+
had_error = False
|
|
1940
|
+
pending = ""
|
|
1009
1941
|
while True:
|
|
1010
1942
|
try:
|
|
1011
|
-
line = _read_input_with_timeout("browse> ",
|
|
1943
|
+
line = _read_input_with_timeout("browse> " if not pending else "...> ",
|
|
1944
|
+
REPL_IDLE_TIMEOUT)
|
|
1012
1945
|
except (EOFError, KeyboardInterrupt):
|
|
1013
1946
|
print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "interrupt"}))
|
|
1014
1947
|
sys.stdout.flush()
|
|
1015
1948
|
break
|
|
1016
1949
|
|
|
1017
1950
|
if line is None:
|
|
1018
|
-
# Idle timeout reached — gracefully close
|
|
1019
1951
|
print(json.dumps({
|
|
1020
1952
|
"status": "ok",
|
|
1021
1953
|
"action": "repl_exit",
|
|
@@ -1023,10 +1955,16 @@ def run_repl(session, sanitize=False):
|
|
|
1023
1955
|
"message": f"No input for {REPL_IDLE_TIMEOUT}s. Closing browser to prevent zombie process."
|
|
1024
1956
|
}))
|
|
1025
1957
|
sys.stdout.flush()
|
|
1026
|
-
audit_log("repl_idle_timeout", f"profile={session
|
|
1958
|
+
audit_log("repl_idle_timeout", f"profile={getattr(session, 'profile', '?')}",
|
|
1959
|
+
f"timeout={REPL_IDLE_TIMEOUT}s")
|
|
1027
1960
|
break
|
|
1028
1961
|
|
|
1029
|
-
line = line.
|
|
1962
|
+
line = line.rstrip()
|
|
1963
|
+
if line.endswith('\\'):
|
|
1964
|
+
pending += line[:-1] + "\n"
|
|
1965
|
+
continue
|
|
1966
|
+
line = (pending + line).strip()
|
|
1967
|
+
pending = ""
|
|
1030
1968
|
if not line:
|
|
1031
1969
|
continue
|
|
1032
1970
|
|
|
@@ -1034,85 +1972,87 @@ def run_repl(session, sanitize=False):
|
|
|
1034
1972
|
cmd = parts[0].lower()
|
|
1035
1973
|
arg = parts[1] if len(parts) > 1 else ""
|
|
1036
1974
|
|
|
1037
|
-
|
|
1975
|
+
if cmd in ('quit', 'exit', 'q'):
|
|
1976
|
+
print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "user_quit"}))
|
|
1977
|
+
sys.stdout.flush()
|
|
1978
|
+
break
|
|
1979
|
+
|
|
1038
1980
|
result = None
|
|
1039
1981
|
try:
|
|
1040
|
-
if cmd
|
|
1041
|
-
|
|
1042
|
-
sys.stdout.flush()
|
|
1043
|
-
break
|
|
1044
|
-
elif cmd == 'help':
|
|
1045
|
-
result = {
|
|
1046
|
-
"status": "ok", "action": "help",
|
|
1047
|
-
"commands": [
|
|
1048
|
-
"open <url>", "screenshot [path]", "read-dom [selector]", "read-page",
|
|
1049
|
-
"click <selector>", "type <selector> <text>", "scroll [up|down]",
|
|
1050
|
-
"press <key>", "wait-for <selector>", "eval <js>",
|
|
1051
|
-
"gdoc-read", "gdoc-type <text>", "gdoc-find <text>",
|
|
1052
|
-
"stealth-test", "url", "title", "quit"
|
|
1053
|
-
]
|
|
1054
|
-
}
|
|
1055
|
-
elif cmd == 'open':
|
|
1056
|
-
result = cmd_open(session, arg)
|
|
1057
|
-
elif cmd == 'screenshot':
|
|
1058
|
-
result = cmd_screenshot(session, arg or None)
|
|
1059
|
-
elif cmd in ('read-dom', 'readdom', 'dom'):
|
|
1060
|
-
result = cmd_read_dom(session, arg or None, sanitize)
|
|
1061
|
-
elif cmd in ('read-page', 'readpage', 'page'):
|
|
1062
|
-
result = cmd_read_page(session, sanitize)
|
|
1063
|
-
elif cmd == 'click':
|
|
1064
|
-
result = cmd_click(session, arg)
|
|
1065
|
-
elif cmd == 'type':
|
|
1066
|
-
tparts = arg.split(maxsplit=1)
|
|
1067
|
-
if len(tparts) == 2:
|
|
1068
|
-
result = cmd_type_text(session, tparts[0], tparts[1])
|
|
1069
|
-
else:
|
|
1070
|
-
result = {"status": "error", "action": "type", "message": "Usage: type <selector> <text>"}
|
|
1071
|
-
elif cmd == 'press':
|
|
1072
|
-
result = cmd_press(session, arg)
|
|
1073
|
-
elif cmd == 'scroll':
|
|
1074
|
-
result = cmd_scroll(session, arg or "down")
|
|
1075
|
-
elif cmd in ('wait-for', 'waitfor', 'wait'):
|
|
1076
|
-
result = cmd_wait_for(session, arg)
|
|
1077
|
-
elif cmd == 'eval':
|
|
1078
|
-
result = cmd_eval(session, arg, sanitize)
|
|
1079
|
-
elif cmd in ('gdoc-read', 'gdocread'):
|
|
1080
|
-
result = cmd_gdoc_read(session, sanitize)
|
|
1081
|
-
elif cmd in ('gdoc-type', 'gdoctype'):
|
|
1082
|
-
result = cmd_gdoc_type(session, arg)
|
|
1083
|
-
elif cmd in ('gdoc-find', 'gdocfind'):
|
|
1084
|
-
result = cmd_gdoc_find(session, arg)
|
|
1085
|
-
elif cmd in ('stealth-test', 'stealthtest', 'test'):
|
|
1086
|
-
result = cmd_stealth_test(session)
|
|
1087
|
-
elif cmd == 'url':
|
|
1088
|
-
result = {"status": "ok", "action": "url", "url": session.page.url}
|
|
1089
|
-
elif cmd == 'title':
|
|
1090
|
-
result = {"status": "ok", "action": "title", "title": session.page.title()}
|
|
1982
|
+
if cmd == 'help':
|
|
1983
|
+
result = {"status": "ok", "action": "help", "commands": HELP_COMMANDS}
|
|
1091
1984
|
else:
|
|
1092
|
-
result =
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1985
|
+
result = dispatch(session, cmd, arg, sanitize=sanitize, cleanup=cleanup)
|
|
1986
|
+
if result is None:
|
|
1987
|
+
result = {
|
|
1988
|
+
"status": "error", "action": cmd,
|
|
1989
|
+
"message": f"Unknown command: {cmd}. Type 'help'.",
|
|
1990
|
+
}
|
|
1991
|
+
else:
|
|
1992
|
+
result = with_diagnostics(session, result)
|
|
1993
|
+
except Exception as exc:
|
|
1096
1994
|
result = {
|
|
1097
1995
|
"status": "error",
|
|
1098
1996
|
"action": cmd,
|
|
1099
|
-
"error_type": type(
|
|
1100
|
-
"message": str(
|
|
1997
|
+
"error_type": type(exc).__name__,
|
|
1998
|
+
"message": str(exc),
|
|
1101
1999
|
}
|
|
1102
|
-
audit_log("repl_error", cmd, f"{type(
|
|
2000
|
+
audit_log("repl_error", cmd, f"{type(exc).__name__}: {str(exc)[:150]}")
|
|
1103
2001
|
|
|
1104
2002
|
if result:
|
|
2003
|
+
if result.get("status") != "ok":
|
|
2004
|
+
had_error = True
|
|
1105
2005
|
print(json.dumps(result, indent=2, default=str))
|
|
1106
2006
|
sys.stdout.flush()
|
|
2007
|
+
if had_error and fail_fast:
|
|
2008
|
+
print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "fail_fast"}))
|
|
2009
|
+
sys.stdout.flush()
|
|
2010
|
+
break
|
|
2011
|
+
|
|
2012
|
+
if getattr(session, 'diagnostics', None) and session.diagnostics.saw_page_error:
|
|
2013
|
+
had_error = True
|
|
2014
|
+
return not had_error
|
|
1107
2015
|
|
|
1108
2016
|
|
|
1109
2017
|
# ---------------------------------------------------------------------------
|
|
1110
2018
|
# Pipe/batch mode
|
|
1111
2019
|
# ---------------------------------------------------------------------------
|
|
1112
|
-
def
|
|
1113
|
-
"""
|
|
2020
|
+
def _stdin_lines_with_idle_timeout(timeout_sec):
|
|
2021
|
+
"""
|
|
2022
|
+
Yield stdin lines, giving up if the producer holds the pipe open without
|
|
2023
|
+
sending anything. Falls back to plain iteration for non-selectable streams.
|
|
2024
|
+
"""
|
|
2025
|
+
stream = sys.stdin
|
|
2026
|
+
try:
|
|
2027
|
+
fileno = stream.fileno()
|
|
2028
|
+
except Exception:
|
|
2029
|
+
yield from stream
|
|
2030
|
+
return
|
|
2031
|
+
while True:
|
|
2032
|
+
try:
|
|
2033
|
+
ready, _, _ = select.select([fileno], [], [], timeout_sec)
|
|
2034
|
+
except Exception:
|
|
2035
|
+
yield from stream
|
|
2036
|
+
return
|
|
2037
|
+
if not ready:
|
|
2038
|
+
audit_log("pipe_idle_timeout", "", f"timeout={timeout_sec}s")
|
|
2039
|
+
print(json.dumps({
|
|
2040
|
+
"status": "error",
|
|
2041
|
+
"action": "pipe",
|
|
2042
|
+
"message": f"No input for {timeout_sec}s. Closing browser to prevent zombie process.",
|
|
2043
|
+
}))
|
|
2044
|
+
sys.stdout.flush()
|
|
2045
|
+
return
|
|
2046
|
+
line = stream.readline()
|
|
2047
|
+
if not line:
|
|
2048
|
+
return
|
|
2049
|
+
yield line
|
|
2050
|
+
|
|
2051
|
+
|
|
2052
|
+
def run_pipe(session, sanitize=False, cleanup=False, fail_fast=False, cleanup_files=None):
|
|
2053
|
+
"""Read commands from stdin, one per line. Returns True when all succeeded."""
|
|
1114
2054
|
had_error = False
|
|
1115
|
-
for line in
|
|
2055
|
+
for line in _join_continuations(_stdin_lines_with_idle_timeout(PIPE_IDLE_TIMEOUT)):
|
|
1116
2056
|
line = line.strip()
|
|
1117
2057
|
if not line or line.startswith('#'):
|
|
1118
2058
|
continue
|
|
@@ -1120,85 +2060,144 @@ def run_pipe(session, sanitize=False):
|
|
|
1120
2060
|
cmd = parts[0].lower()
|
|
1121
2061
|
arg = parts[1] if len(parts) > 1 else ""
|
|
1122
2062
|
|
|
1123
|
-
result = None
|
|
1124
2063
|
try:
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
elif cmd == 'screenshot':
|
|
1128
|
-
result = cmd_screenshot(session, arg or None)
|
|
1129
|
-
elif cmd == 'read-dom':
|
|
1130
|
-
result = cmd_read_dom(session, arg or None, sanitize)
|
|
1131
|
-
elif cmd == 'read-page':
|
|
1132
|
-
result = cmd_read_page(session, sanitize)
|
|
1133
|
-
elif cmd == 'click':
|
|
1134
|
-
result = cmd_click(session, arg)
|
|
1135
|
-
elif cmd == 'type':
|
|
1136
|
-
tparts = arg.split(maxsplit=1)
|
|
1137
|
-
if len(tparts) != 2:
|
|
1138
|
-
raise ValueError("Usage: type <selector> <text>")
|
|
1139
|
-
result = cmd_type_text(session, tparts[0], tparts[1])
|
|
1140
|
-
elif cmd == 'press':
|
|
1141
|
-
result = cmd_press(session, arg)
|
|
1142
|
-
elif cmd == 'scroll':
|
|
1143
|
-
result = cmd_scroll(session, arg or "down")
|
|
1144
|
-
elif cmd in ('wait-for', 'waitfor'):
|
|
1145
|
-
result = cmd_wait_for(session, arg)
|
|
1146
|
-
elif cmd == 'eval':
|
|
1147
|
-
result = cmd_eval(session, arg, sanitize)
|
|
1148
|
-
elif cmd == 'gdoc-read':
|
|
1149
|
-
result = cmd_gdoc_read(session, sanitize)
|
|
1150
|
-
elif cmd == 'gdoc-type':
|
|
1151
|
-
result = cmd_gdoc_type(session, arg)
|
|
1152
|
-
elif cmd == 'gdoc-find':
|
|
1153
|
-
result = cmd_gdoc_find(session, arg)
|
|
1154
|
-
elif cmd == 'wait':
|
|
1155
|
-
time.sleep(float(arg) if arg else 1)
|
|
1156
|
-
result = {"status": "ok", "action": "wait"}
|
|
1157
|
-
elif cmd in ('stealth-test', 'stealthtest', 'test'):
|
|
1158
|
-
result = cmd_stealth_test(session)
|
|
1159
|
-
elif cmd == 'url':
|
|
1160
|
-
result = {"status": "ok", "action": "url", "url": session.page.url}
|
|
1161
|
-
elif cmd == 'title':
|
|
1162
|
-
result = {"status": "ok", "action": "title", "title": session.page.title()}
|
|
1163
|
-
else:
|
|
2064
|
+
result = dispatch(session, cmd, arg, sanitize=sanitize, cleanup=cleanup)
|
|
2065
|
+
if result is None:
|
|
1164
2066
|
result = {"status": "error", "action": cmd, "message": f"Unknown command: {cmd}"}
|
|
1165
|
-
|
|
2067
|
+
else:
|
|
2068
|
+
result = with_diagnostics(session, result)
|
|
2069
|
+
except Exception as exc:
|
|
1166
2070
|
result = {
|
|
1167
2071
|
"status": "error",
|
|
1168
2072
|
"action": cmd,
|
|
1169
|
-
"error_type": type(
|
|
1170
|
-
"message": str(
|
|
2073
|
+
"error_type": type(exc).__name__,
|
|
2074
|
+
"message": str(exc),
|
|
1171
2075
|
}
|
|
1172
2076
|
|
|
1173
|
-
if result
|
|
2077
|
+
if result.get("status") != "ok":
|
|
1174
2078
|
had_error = True
|
|
2079
|
+
if cleanup_files is not None and result.get("cleanup_path"):
|
|
2080
|
+
cleanup_files.append(result["cleanup_path"])
|
|
1175
2081
|
|
|
1176
|
-
|
|
1177
|
-
|
|
2082
|
+
print(json.dumps(result, default=str))
|
|
2083
|
+
sys.stdout.flush()
|
|
2084
|
+
|
|
2085
|
+
if had_error and fail_fast:
|
|
2086
|
+
print(json.dumps({
|
|
2087
|
+
"status": "error", "action": "pipe",
|
|
2088
|
+
"message": f"Aborting after failed command: {cmd} (--fail-fast).",
|
|
2089
|
+
}))
|
|
1178
2090
|
sys.stdout.flush()
|
|
2091
|
+
break
|
|
2092
|
+
|
|
2093
|
+
if session.diagnostics.saw_page_error:
|
|
2094
|
+
errors = session.diagnostics.all_page_errors
|
|
2095
|
+
print(json.dumps({
|
|
2096
|
+
"status": "failed",
|
|
2097
|
+
"action": "pipe",
|
|
2098
|
+
"message": "Uncaught page exception(s) occurred during this run.",
|
|
2099
|
+
"page_errors": errors[:10],
|
|
2100
|
+
}))
|
|
2101
|
+
sys.stdout.flush()
|
|
2102
|
+
had_error = True
|
|
1179
2103
|
return not had_error
|
|
1180
2104
|
|
|
1181
2105
|
|
|
2106
|
+
# ---------------------------------------------------------------------------
|
|
2107
|
+
# Profile maintenance
|
|
2108
|
+
# ---------------------------------------------------------------------------
|
|
2109
|
+
def cmd_profiles(prune_older_than=None, apply_changes=False):
|
|
2110
|
+
"""List profiles by size and age; optionally prune stale ones."""
|
|
2111
|
+
if not BROWSER_DATA_DIR.exists():
|
|
2112
|
+
return {"status": "ok", "profiles": [], "total_bytes": 0}
|
|
2113
|
+
now = time.time()
|
|
2114
|
+
entries = []
|
|
2115
|
+
total = 0
|
|
2116
|
+
for child in sorted(BROWSER_DATA_DIR.iterdir()):
|
|
2117
|
+
if not child.is_dir():
|
|
2118
|
+
continue
|
|
2119
|
+
size = sum(f.stat().st_size for f in child.rglob('*') if f.is_file())
|
|
2120
|
+
age_days = (now - child.stat().st_mtime) / 86400
|
|
2121
|
+
total += size
|
|
2122
|
+
entries.append({"profile": child.name, "bytes": size, "age_days": round(age_days, 1)})
|
|
2123
|
+
|
|
2124
|
+
result = {
|
|
2125
|
+
"status": "ok",
|
|
2126
|
+
"action": "profiles",
|
|
2127
|
+
"count": len(entries),
|
|
2128
|
+
"total_bytes": total,
|
|
2129
|
+
"total_human": f"{total / (1024 ** 3):.2f} GiB",
|
|
2130
|
+
"profiles": sorted(entries, key=lambda e: e['bytes'], reverse=True),
|
|
2131
|
+
}
|
|
2132
|
+
if prune_older_than is None:
|
|
2133
|
+
return result
|
|
2134
|
+
|
|
2135
|
+
stale = [e for e in entries if e['age_days'] > prune_older_than]
|
|
2136
|
+
result["stale_count"] = len(stale)
|
|
2137
|
+
result["stale_bytes"] = sum(e['bytes'] for e in stale)
|
|
2138
|
+
result["stale"] = [e['profile'] for e in stale]
|
|
2139
|
+
if not apply_changes:
|
|
2140
|
+
result["dry_run"] = True
|
|
2141
|
+
result["message"] = (
|
|
2142
|
+
f"{len(stale)} profile(s) older than {prune_older_than}d "
|
|
2143
|
+
f"({result['stale_bytes'] / (1024 ** 3):.2f} GiB). Re-run with --yes to delete."
|
|
2144
|
+
)
|
|
2145
|
+
return result
|
|
2146
|
+
|
|
2147
|
+
removed = []
|
|
2148
|
+
for entry in stale:
|
|
2149
|
+
target = BROWSER_DATA_DIR / entry['profile']
|
|
2150
|
+
try:
|
|
2151
|
+
shutil.rmtree(target)
|
|
2152
|
+
removed.append(entry['profile'])
|
|
2153
|
+
audit_log("profile_pruned", f"profile={entry['profile']}", f"bytes={entry['bytes']}")
|
|
2154
|
+
except Exception as exc:
|
|
2155
|
+
print(f"⚠️ Could not remove {target}: {exc}", file=sys.stderr)
|
|
2156
|
+
result["removed"] = removed
|
|
2157
|
+
result["dry_run"] = False
|
|
2158
|
+
return result
|
|
2159
|
+
|
|
2160
|
+
|
|
1182
2161
|
# ---------------------------------------------------------------------------
|
|
1183
2162
|
# CLI
|
|
1184
2163
|
# ---------------------------------------------------------------------------
|
|
1185
2164
|
def build_parser():
|
|
1186
2165
|
p = argparse.ArgumentParser(
|
|
1187
|
-
description='browse.py —
|
|
2166
|
+
description='browse.py — local Playwright browser runner for agent-driven testing',
|
|
1188
2167
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
1189
2168
|
)
|
|
1190
2169
|
p.add_argument('--profile', default=DEFAULT_PROFILE, help='Browser profile name')
|
|
2170
|
+
p.add_argument('--ephemeral-profile', action='store_true',
|
|
2171
|
+
help='Use a throwaway profile directory removed on exit (hermetic runs)')
|
|
2172
|
+
p.add_argument('--storage-state', metavar='PATH',
|
|
2173
|
+
help='Seed cookies/localStorage from a Playwright storage-state JSON file')
|
|
1191
2174
|
p.add_argument('--headless', action='store_true', help='Headless mode')
|
|
1192
|
-
p.add_argument('--cleanup', action='store_true',
|
|
1193
|
-
|
|
2175
|
+
p.add_argument('--cleanup', action='store_true',
|
|
2176
|
+
help='Delete screenshots captured in this run on exit (see --help notes)')
|
|
2177
|
+
p.add_argument('--sanitize', action='store_true', help='Mask PHI patterns in text output')
|
|
1194
2178
|
p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='Timeout (ms)')
|
|
1195
2179
|
p.add_argument('--viewport', default='1440x900', help='Viewport WxH')
|
|
1196
2180
|
p.add_argument('--stealth', choices=['full', 'light', 'none'], default='full',
|
|
1197
|
-
help='
|
|
2181
|
+
help='Fingerprint level: full (stealth lib + CDP + JS), light (CDP + JS), none')
|
|
2182
|
+
p.add_argument('--allow-degraded-stealth', action='store_true',
|
|
2183
|
+
help='Continue when a requested fingerprint layer cannot be applied')
|
|
1198
2184
|
p.add_argument('--local-only', action='store_true',
|
|
1199
|
-
help='Reject non-loopback navigation and
|
|
2185
|
+
help='Reject non-loopback navigation, subrequests, sockets, and service workers')
|
|
1200
2186
|
p.add_argument('--inject', action='append', default=[], metavar='PATH',
|
|
1201
2187
|
help='Inject a UTF-8 .js/.mjs file before local page scripts (repeatable; requires --local-only)')
|
|
2188
|
+
p.add_argument('--fast', action='store_true',
|
|
2189
|
+
help='Skip human-like delays and per-character typing')
|
|
2190
|
+
p.add_argument('--fail-fast', action='store_true',
|
|
2191
|
+
help='Stop a pipe/repl run at the first failed command')
|
|
2192
|
+
p.add_argument('--allow-http-error', action='store_true',
|
|
2193
|
+
help='Treat HTTP >= 400 on open as success')
|
|
2194
|
+
p.add_argument('--trace', metavar='PATH', help='Write a Playwright trace zip')
|
|
2195
|
+
p.add_argument('--video', metavar='DIR', help='Record video into DIR')
|
|
2196
|
+
p.add_argument('--har', metavar='PATH', help='Record a HAR archive')
|
|
2197
|
+
p.add_argument('--grant', action='append', default=[], metavar='PERMISSION',
|
|
2198
|
+
help='Grant a browser permission (repeatable). Nothing is granted by default.')
|
|
2199
|
+
p.add_argument('--geolocation', metavar='LAT,LON',
|
|
2200
|
+
help='Set geolocation coordinates (requires --grant geolocation)')
|
|
1202
2201
|
p.add_argument('--skip-fv-check', action='store_true', help='Skip FileVault check')
|
|
1203
2202
|
|
|
1204
2203
|
sub = p.add_subparsers(dest='command')
|
|
@@ -1211,6 +2210,8 @@ def build_parser():
|
|
|
1211
2210
|
|
|
1212
2211
|
s = sub.add_parser('screenshot', help='Take screenshot')
|
|
1213
2212
|
s.add_argument('--output', '-o')
|
|
2213
|
+
s.add_argument('--element', '-e', help='Capture only this element')
|
|
2214
|
+
s.add_argument('--no-full-page', action='store_true', help='Capture the viewport only')
|
|
1214
2215
|
|
|
1215
2216
|
s = sub.add_parser('read-dom', help='Read DOM text')
|
|
1216
2217
|
s.add_argument('--selector', '-s')
|
|
@@ -1238,6 +2239,31 @@ def build_parser():
|
|
|
1238
2239
|
s = sub.add_parser('eval', help='Evaluate JS')
|
|
1239
2240
|
s.add_argument('js')
|
|
1240
2241
|
|
|
2242
|
+
s = sub.add_parser('assert-text', help='Assert element text contains a value')
|
|
2243
|
+
s.add_argument('selector')
|
|
2244
|
+
s.add_argument('expected')
|
|
2245
|
+
|
|
2246
|
+
s = sub.add_parser('assert-visible', help='Assert element is visible')
|
|
2247
|
+
s.add_argument('selector')
|
|
2248
|
+
|
|
2249
|
+
s = sub.add_parser('assert-hidden', help='Assert element is hidden')
|
|
2250
|
+
s.add_argument('selector')
|
|
2251
|
+
|
|
2252
|
+
s = sub.add_parser('assert-count', help='Assert selector match count')
|
|
2253
|
+
s.add_argument('selector')
|
|
2254
|
+
s.add_argument('expected', type=int)
|
|
2255
|
+
|
|
2256
|
+
s = sub.add_parser('assert-url', help='Assert URL contains a substring')
|
|
2257
|
+
s.add_argument('expected')
|
|
2258
|
+
|
|
2259
|
+
s = sub.add_parser('assert-title', help='Assert title contains a substring')
|
|
2260
|
+
s.add_argument('expected')
|
|
2261
|
+
|
|
2262
|
+
s = sub.add_parser('assert-eval', help='Assert a JS expression is truthy')
|
|
2263
|
+
s.add_argument('js')
|
|
2264
|
+
|
|
2265
|
+
sub.add_parser('fingerprint', help='Report the verified fingerprint state')
|
|
2266
|
+
|
|
1241
2267
|
sub.add_parser('gdoc-read', help='Read Google Doc')
|
|
1242
2268
|
|
|
1243
2269
|
s = sub.add_parser('gdoc-type', help='Type in Google Doc')
|
|
@@ -1248,96 +2274,177 @@ def build_parser():
|
|
|
1248
2274
|
|
|
1249
2275
|
sub.add_parser('stealth-test', help='Run bot detection test')
|
|
1250
2276
|
|
|
2277
|
+
s = sub.add_parser('profiles', help='List or prune persistent profiles')
|
|
2278
|
+
s.add_argument('--prune-older-than', type=float, metavar='DAYS')
|
|
2279
|
+
s.add_argument('--yes', action='store_true', help='Actually delete (default is a dry run)')
|
|
2280
|
+
|
|
1251
2281
|
return p
|
|
1252
2282
|
|
|
1253
2283
|
|
|
2284
|
+
def parse_viewport(value: str) -> tuple[int, int]:
|
|
2285
|
+
"""Parse WxH, failing loudly. A silent fallback tests the wrong breakpoint."""
|
|
2286
|
+
match = re.fullmatch(r'\s*(\d{2,5})\s*[xX]\s*(\d{2,5})\s*', value or '')
|
|
2287
|
+
if not match:
|
|
2288
|
+
raise ValueError(f"Invalid --viewport {value!r}. Expected WxH, for example 1440x900.")
|
|
2289
|
+
return int(match.group(1)), int(match.group(2))
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def parse_geolocation(value: str) -> tuple[float, float]:
|
|
2293
|
+
parts = (value or '').split(',')
|
|
2294
|
+
if len(parts) != 2:
|
|
2295
|
+
raise ValueError(f"Invalid --geolocation {value!r}. Expected LAT,LON.")
|
|
2296
|
+
return float(parts[0]), float(parts[1])
|
|
2297
|
+
|
|
2298
|
+
|
|
1254
2299
|
def main():
|
|
1255
2300
|
parser = build_parser()
|
|
1256
2301
|
args = parser.parse_args()
|
|
1257
2302
|
|
|
1258
2303
|
if not args.command:
|
|
1259
2304
|
parser.print_help()
|
|
1260
|
-
|
|
2305
|
+
return 1
|
|
2306
|
+
|
|
2307
|
+
# Maintenance runs without a browser.
|
|
2308
|
+
if args.command == 'profiles':
|
|
2309
|
+
result = cmd_profiles(args.prune_older_than, args.yes)
|
|
2310
|
+
print(json.dumps(result, indent=2, default=str))
|
|
2311
|
+
return 0
|
|
1261
2312
|
|
|
1262
2313
|
if not args.skip_fv_check and not check_filevault():
|
|
1263
|
-
|
|
2314
|
+
return 1
|
|
1264
2315
|
|
|
1265
2316
|
try:
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
2317
|
+
viewport = parse_viewport(args.viewport)
|
|
2318
|
+
except ValueError as exc:
|
|
2319
|
+
parser.error(str(exc))
|
|
2320
|
+
|
|
2321
|
+
geolocation = None
|
|
2322
|
+
if args.geolocation:
|
|
2323
|
+
try:
|
|
2324
|
+
geolocation = parse_geolocation(args.geolocation)
|
|
2325
|
+
except ValueError as exc:
|
|
2326
|
+
parser.error(str(exc))
|
|
1270
2327
|
|
|
1271
2328
|
if args.inject and not args.local_only:
|
|
1272
2329
|
parser.error('--inject requires --local-only')
|
|
2330
|
+
if args.ephemeral_profile and args.profile != DEFAULT_PROFILE:
|
|
2331
|
+
parser.error('--ephemeral-profile cannot be combined with --profile')
|
|
1273
2332
|
if args.local_only and args.command == 'open' and not is_local_test_url(args.url):
|
|
1274
2333
|
parser.error('--local-only permits only loopback HTTP(S), data:, and about: URLs')
|
|
1275
2334
|
|
|
1276
2335
|
cleanup_files = []
|
|
1277
2336
|
exit_code = 0
|
|
1278
2337
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
2338
|
+
try:
|
|
2339
|
+
session_cm = StealthBrowserSession(
|
|
2340
|
+
profile=args.profile,
|
|
2341
|
+
headless=args.headless,
|
|
2342
|
+
timeout=args.timeout,
|
|
2343
|
+
viewport=viewport,
|
|
2344
|
+
stealth_level=args.stealth,
|
|
2345
|
+
local_only=args.local_only,
|
|
2346
|
+
init_script_paths=args.inject,
|
|
2347
|
+
fast=args.fast,
|
|
2348
|
+
sanitize=args.sanitize,
|
|
2349
|
+
allow_degraded_stealth=args.allow_degraded_stealth,
|
|
2350
|
+
ephemeral_profile=args.ephemeral_profile,
|
|
2351
|
+
storage_state_path=args.storage_state,
|
|
2352
|
+
trace_path=args.trace,
|
|
2353
|
+
video_dir=args.video,
|
|
2354
|
+
har_path=args.har,
|
|
2355
|
+
grant_permissions=args.grant,
|
|
2356
|
+
geolocation=geolocation,
|
|
2357
|
+
allow_http_error=args.allow_http_error,
|
|
2358
|
+
)
|
|
2359
|
+
except (ValueError, StealthConfigurationError) as exc:
|
|
2360
|
+
print(json.dumps({"status": "error", "message": str(exc)}), file=sys.stderr)
|
|
2361
|
+
return 1
|
|
2362
|
+
|
|
2363
|
+
try:
|
|
2364
|
+
with session_cm as session:
|
|
2365
|
+
if args.command == 'repl':
|
|
2366
|
+
if not run_repl(session, args.sanitize, args.cleanup, args.fail_fast):
|
|
2367
|
+
exit_code = 1
|
|
2368
|
+
elif args.command == 'pipe':
|
|
2369
|
+
if not run_pipe(session, args.sanitize, args.cleanup, args.fail_fast, cleanup_files):
|
|
2370
|
+
exit_code = 1
|
|
2371
|
+
else:
|
|
2372
|
+
result = None
|
|
2373
|
+
if args.command == 'open':
|
|
2374
|
+
result = cmd_open(session, args.url)
|
|
2375
|
+
elif args.command == 'screenshot':
|
|
2376
|
+
result = cmd_screenshot(
|
|
2377
|
+
session, args.output, args.cleanup,
|
|
2378
|
+
full_page=not args.no_full_page, selector=args.element,
|
|
2379
|
+
)
|
|
2380
|
+
if result.get('cleanup_path'):
|
|
2381
|
+
cleanup_files.append(result['cleanup_path'])
|
|
2382
|
+
elif args.command == 'read-dom':
|
|
2383
|
+
result = cmd_read_dom(session, getattr(args, 'selector', None), args.sanitize)
|
|
1307
2384
|
print(result['text'])
|
|
1308
2385
|
result = None
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
if result and result.get('text'):
|
|
2386
|
+
elif args.command == 'read-page':
|
|
2387
|
+
result = cmd_read_page(session, args.sanitize)
|
|
1312
2388
|
print(f"URL: {result['url']}\nTitle: {result['title']}\n\n{result['text']}")
|
|
1313
2389
|
result = None
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
2390
|
+
elif args.command == 'click':
|
|
2391
|
+
result = cmd_click(session, args.selector)
|
|
2392
|
+
elif args.command == 'type':
|
|
2393
|
+
result = cmd_type_text(session, args.selector, args.text)
|
|
2394
|
+
elif args.command == 'press':
|
|
2395
|
+
result = cmd_press(session, args.key)
|
|
2396
|
+
elif args.command == 'scroll':
|
|
2397
|
+
result = cmd_scroll(session, args.direction, args.amount)
|
|
2398
|
+
elif args.command == 'wait-for':
|
|
2399
|
+
result = cmd_wait_for(session, args.selector, getattr(args, 'wait_timeout', None))
|
|
2400
|
+
elif args.command == 'eval':
|
|
2401
|
+
result = cmd_eval(session, args.js, args.sanitize)
|
|
2402
|
+
elif args.command == 'assert-text':
|
|
2403
|
+
result = cmd_assert_text(session, args.selector, args.expected)
|
|
2404
|
+
elif args.command == 'assert-visible':
|
|
2405
|
+
result = cmd_assert_visible(session, args.selector, True)
|
|
2406
|
+
elif args.command == 'assert-hidden':
|
|
2407
|
+
result = cmd_assert_visible(session, args.selector, False)
|
|
2408
|
+
elif args.command == 'assert-count':
|
|
2409
|
+
result = cmd_assert_count(session, args.selector, args.expected)
|
|
2410
|
+
elif args.command == 'assert-url':
|
|
2411
|
+
result = cmd_assert_url(session, args.expected)
|
|
2412
|
+
elif args.command == 'assert-title':
|
|
2413
|
+
result = cmd_assert_title(session, args.expected)
|
|
2414
|
+
elif args.command == 'assert-eval':
|
|
2415
|
+
result = cmd_assert_eval(session, args.js)
|
|
2416
|
+
elif args.command == 'fingerprint':
|
|
2417
|
+
result = cmd_fingerprint(session)
|
|
2418
|
+
elif args.command == 'gdoc-read':
|
|
2419
|
+
result = cmd_gdoc_read(session, args.sanitize)
|
|
1329
2420
|
print(result['text'])
|
|
1330
2421
|
result = None
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
2422
|
+
elif args.command == 'gdoc-type':
|
|
2423
|
+
result = cmd_gdoc_type(session, args.text)
|
|
2424
|
+
elif args.command == 'gdoc-find':
|
|
2425
|
+
result = cmd_gdoc_find(session, args.text)
|
|
2426
|
+
elif args.command == 'stealth-test':
|
|
2427
|
+
result = cmd_stealth_test(session)
|
|
2428
|
+
|
|
2429
|
+
if result is not None:
|
|
2430
|
+
result = with_diagnostics(session, result)
|
|
2431
|
+
if result.get('status') != 'ok':
|
|
2432
|
+
exit_code = 1
|
|
2433
|
+
print(json.dumps(result, indent=2, default=str))
|
|
2434
|
+
if session.diagnostics.saw_page_error:
|
|
2435
|
+
exit_code = 1
|
|
2436
|
+
except StealthConfigurationError as exc:
|
|
2437
|
+
print(json.dumps({"status": "error", "error_type": "StealthConfigurationError",
|
|
2438
|
+
"message": str(exc)}), file=sys.stderr)
|
|
2439
|
+
return 1
|
|
2440
|
+
except ValueError as exc:
|
|
2441
|
+
print(json.dumps({"status": "error", "error_type": "ValueError",
|
|
2442
|
+
"message": str(exc)}), file=sys.stderr)
|
|
2443
|
+
return 1
|
|
2444
|
+
|
|
2445
|
+
for path in cleanup_files:
|
|
2446
|
+
outcome = secure_delete(path)
|
|
2447
|
+
print(json.dumps({"status": "ok", "action": "cleanup", **outcome}, default=str))
|
|
1341
2448
|
return exit_code
|
|
1342
2449
|
|
|
1343
2450
|
|