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.
@@ -1,40 +1,56 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- browse.py — HIPAA-Hardened Stealth Browser Automation CLI
4
- ==========================================================
5
- Local Playwright-based browser automation with multi-layer anti-detection.
6
- Zero cloud dependency. Runs entirely on localhost.
7
-
8
- STEALTH LAYERS:
9
- Layer 1: playwright-stealth v2.x JS evasion scripts (webdriver, chrome.runtime, etc.)
10
- Layer 2: Realistic browser fingerprint — User-Agent, viewport, locale, timezone, WebGL
11
- Layer 3: Behavioral stealth human-like mouse, typing delays, scroll jitter
12
- Layer 4: Chromium launch args anti-automation flags, rendering mimicry
13
- Layer 5: Network stealth — real browser headers, TLS fingerprint via Chromium
14
- Layer 6: Persistent profiles cookie jars survive restarts (looks like returning user)
15
-
16
- SECURITY (HIPAA):
17
- - FileVault (FDE) enforcement check
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 (URLs + actions, never PHI content)
20
- - --cleanup flag for secure screenshot wiping
21
- - --sanitize flag to mask PHI patterns (SSN, MRN, phone) in output
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 https://example.com
25
- Interactive: browse.py repl (keeps browser open, type commands)
26
- Pipe/batch: echo "open https://..." | browse.py pipe
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
- return urlunsplit((parsed.scheme, netloc, parsed.path or '/', '', ''))[:300]
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
- # Stealth Configuration — the core anti-detection engine
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
- # Chromium args that reduce automation fingerprint
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', # Kill navigator.webdriver
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', # Remove "Chrome is controlled" bar
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
- '--disable-client-side-phishing-detection',
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
- # Deep stealth: JavaScript to inject BEFORE any page scripts run
220
- # These patches survive beyond what playwright-stealth provides
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
- // === LAYER 2: Deep Fingerprint Evasion ===
223
-
224
- // 1. Override navigator.webdriver (belt-and-suspenders with playwright-stealth)
225
- Object.defineProperty(navigator, 'webdriver', {
226
- get: () => undefined,
227
- configurable: true
228
- });
229
-
230
- // 2. Mock chrome.runtime to look like a real browser
231
- if (!window.chrome) { 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
- connect: function() { return { onMessage: { addListener: function(){} }, postMessage: function(){} }; },
235
- sendMessage: function() {},
236
- id: undefined, // Real Chrome has undefined id in non-extension context
237
- onMessage: { addListener: function(){}, removeListener: function(){} },
238
- onConnect: { addListener: function(){}, removeListener: function(){} },
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
- // 3. Fix chrome.csi (Chrome Session Information)
243
- if (!window.chrome.csi) {
244
- window.chrome.csi = function() {
245
- return {
246
- startE: Date.now(),
247
- onloadT: Date.now(),
248
- pageT: Math.random() * 1000 + 200,
249
- tran: 15
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
- // 4. Fix chrome.loadTimes
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
- get: () => ({
358
- downlink: 10,
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
- // 11. Fix window.outerHeight/outerWidth (headless often has 0)
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
- // 12. Prevent toString() detection of overridden functions
377
- // Bot detectors call .toString() on navigator methods to check for "[native code]"
378
- const nativeToString = Function.prototype.toString;
379
- const nativeFunctions = new Map();
380
- const handler = {
381
- apply: function(target, ctx, args) {
382
- if (ctx === navigator.permissions.query) {
383
- return 'function query() { [native code] }';
384
- }
385
- return nativeToString.apply(ctx, args);
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
- // Only wrap if Proxy is available
389
- if (typeof Proxy !== 'undefined') {
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
- Function.prototype.toString = new Proxy(nativeToString, handler);
392
- } catch(e) {}
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
- if 'FileVault is On' in result.stdout:
405
- return True
406
- print("⛔ HIPAA VIOLATION: FileVault is OFF.", file=sys.stderr)
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
- except Exception:
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
- """Securely wipe a file."""
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 f:
428
- f.write(os.urandom(size))
429
- f.flush()
430
- os.fsync(f.fileno())
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
- except Exception as e:
433
- print(f"⚠️ Secure delete failed: {e}", file=sys.stderr)
434
- try:
435
- path.unlink()
436
- except Exception:
437
- pass
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 _ensure_audit_log_permissions():
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 f:
469
- f.write(f"{ts} | {action} | {safe_target} | {safe_details}\n")
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 stealth — human-like interaction helpers
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 like a human with variable delays between keystrokes."""
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 like a human — variable speed, small increments."""
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 human_mouse_move(page, x, y):
504
- """Move mouse with slight curve (not teleportation)."""
505
- # Get current approximate position
506
- page.mouse.move(x + random.randint(-5, 5), y + random.randint(-5, 5))
507
- human_delay(30, 80)
508
- page.mouse.move(x, y)
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 Session with Stealth
729
+ # Browser session
513
730
  # ---------------------------------------------------------------------------
514
731
  class StealthBrowserSession:
515
- """Manages a stealth Playwright browser session."""
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
- # Pick a consistent UA for this profile
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
- # Validate UA ↔ WebGL fingerprint consistency
552
- # If UA says "Mac OS X 14_5" then WebGL must say Apple/Metal, not SwiftShader
553
- self._validate_fingerprint_consistency()
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
- # Launch persistent context
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'], # Critical: removes automation flag
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
- # Apply stealth evasions
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
- elif self.local_only:
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("session_start", f"profile={self.profile}",
593
- f"stealth={self.stealth_level},headless={self.headless},local_only={self.local_only}")
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 multi-layer stealth evasions."""
597
- # Layer 1: playwright-stealth library (if available)
598
- if STEALTH_AVAILABLE and self.stealth_level == "full":
599
- try:
600
- stealth = Stealth(
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
- stealth.apply_stealth_sync(self._context)
611
- audit_log("stealth", "playwright-stealth", "applied_v2")
612
- except Exception as e:
613
- audit_log("stealth", "playwright-stealth", f"error={e}")
614
-
615
- # Layer 2: Deep JS init script (always applied for full/light)
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(DEEP_STEALTH_INIT_SCRIPT)
990
+ self._context.add_init_script(script)
618
991
  audit_log("stealth", "deep_init_script", "injected")
619
- except Exception as e:
620
- audit_log("stealth", "deep_init_script", f"error={e}")
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
- # Layer 3: Route handler to fix headers
623
- def fix_headers(route, request):
624
- """Ensure realistic HTTP headers on every request."""
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
- headers = {
630
- **request.headers,
631
- 'sec-ch-ua': '"Chromium";v="131", "Google Chrome";v="131", "Not_A_Brand";v="24"',
632
- 'sec-ch-ua-mobile': '?0',
633
- 'sec-ch-ua-platform': '"macOS"',
634
- 'sec-fetch-dest': request.headers.get('sec-fetch-dest', 'document'),
635
- 'sec-fetch-mode': request.headers.get('sec-fetch-mode', 'navigate'),
636
- 'sec-fetch-site': request.headers.get('sec-fetch-site', 'none'),
637
- 'sec-fetch-user': '?1',
638
- 'upgrade-insecure-requests': '1',
639
- }
640
- # Remove automation-specific headers
641
- headers.pop('sec-ch-ua-full-version-list', None)
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("**/*", fix_headers)
646
- audit_log("stealth", "header_fix", "routing_active")
647
- except Exception as e:
648
- audit_log("stealth", "header_fix", f"error={e}")
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 e
1030
+ raise RuntimeError("Cannot enforce --local-only network isolation.") from exc
651
1031
 
652
- def _apply_local_only_guard(self):
653
- """Block non-loopback network requests when stealth routing is disabled."""
654
- def guard(route, request):
655
- if is_local_test_url(request.url, allow_internal=True):
656
- route.continue_()
657
- return
658
- audit_log("request_blocked", request.url, "policy=local-only")
659
- route.abort("blockedbyclient")
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
- self._context.route("**/*", guard)
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
- def _validate_fingerprint_consistency(self):
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
- Validate that User-Agent and WebGL renderer fingerprints are consistent.
666
- Enterprise WAFs (Cloudflare Turnstile) flag mismatches between UA and WebGL.
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
- ua = self._user_agent.lower()
670
- # Ensure macOS UA gets Apple/Metal WebGL (not SwiftShader)
671
- if 'macintosh' in ua or 'mac os x' in ua:
672
- # Our DEEP_STEALTH_INIT_SCRIPT already sets Apple M3 Max — consistent
673
- audit_log("fingerprint_check", "ua_webgl", "consistent=true,platform=macos")
674
- elif 'windows' in ua:
675
- # Would need to adjust WebGL to Intel/NVIDIA — not our case
676
- audit_log("fingerprint_check", "ua_webgl", "warning=windows_ua_with_macos_webgl")
677
- else:
678
- audit_log("fingerprint_check", "ua_webgl", "consistent=unknown")
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
- audit_log("open", url, f"title_chars={len(title)}")
720
- return {"status": "ok", "url": current_url, "title": title}
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
- """Take screenshot. When cleanup=True, writes to /tmp (avoids APFS CoW residue)."""
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%S')
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 Copy-on-Write: overwriting doesn't guarantee physical block wipe.
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 = f"screenshot_{ts}.png"
1262
+ output = name
733
1263
  path = Path(output).resolve()
734
- session.page.screenshot(path=str(path), full_page=True)
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 = {"status": "ok", "path": str(path), "size_bytes": size}
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
- try:
748
- el = session.page.wait_for_selector(selector, timeout=10000)
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 with human-like behavior."""
774
- human_delay(100, 300)
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=True):
781
- """Type text into element."""
782
- if human:
783
- human_type(session.page, text, selector)
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 with human-like behavior."""
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 (Playwright auto-waiting)."""
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
- el = session.page.wait_for_selector(selector, timeout=t, state='visible')
811
- preview = el.inner_text()[:100] if el else ""
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 e:
815
- audit_log("wait_for", selector, f"found=false")
816
- return {"status": "timeout", "found": False, "error": str(e)}
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
- """Evaluate JavaScript."""
821
- result = session.page.evaluate(js_code)
822
- output = str(result)
823
- if sanitize:
824
- output = sanitize_phi(output)
825
- audit_log("eval", "js", f"chars={len(output)}")
826
- return {"status": "ok", "result": output}
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
- # Google Docs commands
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
- # Click into doc body
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('.kix-page', timeout=5000)
1624
+ page.click(selector, timeout=5000)
1625
+ break
847
1626
  except Exception:
848
- pass
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') # Deselect
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 with human-like delays."""
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 using Ctrl+F."""
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 detection tests and report results."""
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
- human_delay(2000, 3000)
923
-
924
- # Extract test results
925
- results = page.evaluate('''() => {
926
- const rows = document.querySelectorAll('table tr');
927
- const results = {};
928
- rows.forEach(row => {
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 >= 2) {
931
- const test = cells[0].textContent.trim();
932
- const cell = cells[1];
933
- const passed = cell.classList.contains('result-passed') ||
934
- cell.style.backgroundColor === 'rgb(144, 238, 144)' ||
935
- cell.textContent.includes('missing') === false;
936
- results[test] = {
937
- value: cells[1].textContent.trim(),
938
- passed: !cell.classList.contains('result-failed')
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
- # Also check specific known indicators
946
- webdriver = page.evaluate('navigator.webdriver')
947
- chrome_exists = page.evaluate('!!window.chrome')
948
- plugins_count = page.evaluate('navigator.plugins.length')
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
- audit_log("stealth_test", "bot.sannysoft.com", f"webdriver={webdriver}")
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
- "webdriver": webdriver,
955
- "chrome_exists": chrome_exists,
956
- "plugins_count": plugins_count,
957
- "detailed_results": results
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
- # REPL mode — interactive session with idle timeout & error resilience
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
- line = input(prompt)
979
- signal.alarm(0) # Cancel alarm
980
- return line
1911
+ return input(prompt)
981
1912
  except _IdleTimeoutError:
982
- return None # Timeout
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
- else:
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.profile,
1003
- "stealth": session.stealth_level,
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> ", REPL_IDLE_TIMEOUT)
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.profile}", f"timeout={REPL_IDLE_TIMEOUT}s")
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.strip()
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
- # Every command output is structured JSON for agent parsing
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 in ('quit', 'exit', 'q'):
1041
- print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "user_quit"}))
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 = {"status": "error", "action": cmd, "message": f"Unknown command: {cmd}. Type 'help'."}
1093
-
1094
- except Exception as e:
1095
- # Error resilience: catch ALL exceptions, return JSON error, keep browser alive
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(e).__name__,
1100
- "message": str(e)
1997
+ "error_type": type(exc).__name__,
1998
+ "message": str(exc),
1101
1999
  }
1102
- audit_log("repl_error", cmd, f"{type(e).__name__}: {str(e)[:150]}")
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 run_pipe(session, sanitize=False):
1113
- """Read commands from stdin, one per line."""
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 sys.stdin:
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
- if cmd == 'open':
1126
- result = cmd_open(session, arg)
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
- except Exception as e:
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(e).__name__,
1170
- "message": str(e),
2073
+ "error_type": type(exc).__name__,
2074
+ "message": str(exc),
1171
2075
  }
1172
2076
 
1173
- if result and result.get("status") not in ("ok",):
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
- if result:
1177
- print(json.dumps(result, default=str))
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 — HIPAA-Hardened Stealth Browser Automation CLI',
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', help='Secure-wipe screenshots')
1193
- p.add_argument('--sanitize', action='store_true', help='Mask PHI patterns')
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='Stealth level: full (all layers), light (JS only), none')
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 network requests')
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
- sys.exit(1)
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
- sys.exit(1)
2314
+ return 1
1264
2315
 
1265
2316
  try:
1266
- vw, vh = args.viewport.split('x')
1267
- viewport = (int(vw), int(vh))
1268
- except Exception:
1269
- viewport = DEFAULT_VIEWPORT
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
- with StealthBrowserSession(
1280
- profile=args.profile,
1281
- headless=args.headless,
1282
- timeout=args.timeout,
1283
- viewport=viewport,
1284
- stealth_level=args.stealth,
1285
- local_only=args.local_only,
1286
- init_script_paths=args.inject,
1287
- ) as session:
1288
-
1289
- if args.command == 'repl':
1290
- run_repl(session, args.sanitize)
1291
- elif args.command == 'pipe':
1292
- if not run_pipe(session, args.sanitize):
1293
- exit_code = 1
1294
- elif args.command == 'stealth-test':
1295
- print(json.dumps(cmd_stealth_test(session), indent=2, default=str))
1296
- else:
1297
- result = None
1298
- if args.command == 'open':
1299
- result = cmd_open(session, args.url)
1300
- elif args.command == 'screenshot':
1301
- result = cmd_screenshot(session, args.output, args.cleanup)
1302
- if result and result.get('cleanup_path'):
1303
- cleanup_files.append(result['cleanup_path'])
1304
- elif args.command == 'read-dom':
1305
- result = cmd_read_dom(session, getattr(args, 'selector', None), args.sanitize)
1306
- if result and result.get('text'):
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
- elif args.command == 'read-page':
1310
- result = cmd_read_page(session, args.sanitize)
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
- elif args.command == 'click':
1315
- result = cmd_click(session, args.selector)
1316
- elif args.command == 'type':
1317
- result = cmd_type_text(session, args.selector, args.text)
1318
- elif args.command == 'press':
1319
- result = cmd_press(session, args.key)
1320
- elif args.command == 'scroll':
1321
- result = cmd_scroll(session, args.direction, args.amount)
1322
- elif args.command == 'wait-for':
1323
- result = cmd_wait_for(session, args.selector, getattr(args, 'wait_timeout', None))
1324
- elif args.command == 'eval':
1325
- result = cmd_eval(session, args.js, args.sanitize)
1326
- elif args.command == 'gdoc-read':
1327
- result = cmd_gdoc_read(session, args.sanitize)
1328
- if result and result.get('text'):
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
- elif args.command == 'gdoc-type':
1332
- result = cmd_gdoc_type(session, args.text)
1333
- elif args.command == 'gdoc-find':
1334
- result = cmd_gdoc_find(session, args.text)
1335
-
1336
- if result:
1337
- print(json.dumps(result, indent=2, default=str))
1338
-
1339
- for f in cleanup_files:
1340
- secure_delete(f)
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