cdpilot 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full list with rationale.
124
124
  ### Navigation & Content
125
125
 
126
126
  ```bash
127
- cdpilot go <url> # Navigate to URL
127
+ cdpilot go <url> # Navigate to URL (alias: open <url>)
128
128
  cdpilot content # Get page text content
129
129
  cdpilot html # Get page HTML
130
130
  cdpilot shot [file] # Take screenshot (PNG)
@@ -537,11 +537,26 @@ cdpilot profile warm # age the profile for reCAPTCHA v3 score
537
537
  which nudges reCAPTCHA v3's behavioral score upward over time. Slow by
538
538
  design — run it ahead of a session, not inline.
539
539
 
540
- Verified against public bot-detection panels:
541
- - **bot.sannysoft.com:** 24/24 PASS (WebDriver, Chrome obj, Plugins as PluginArray, WebGL, PHANTOM_*, HEADCHR_*, SELENIUM_DRIVER)
542
- - **bot.incolumitas.com** intoli: 6/6 OK — new-tests: 6/7 OK (one FAIL = pure CDP presence, cannot be JS-patched)
543
- - **nowsecure.nl** (Cloudflare full challenge): passed
544
- - **arh.antoinevastel.com/areyouheadless:** "You are not Chrome headless"
540
+ Public bot-detection panels, measured 2026-09-27, **headless** Brave (Chrome 154)
541
+ ([method and raw results](.claude/docs/stealth-panel-olcumu-2026-09-27.md)):
542
+
543
+ | `mode` | bot.sannysoft.com (31 rows) | incolumitas intoli | incolumitas fpscanner | incolumitas new-tests |
544
+ |---|---|---|---|---|
545
+ | `regular` (no patches, default) | 28 pass / 3 fail | 5/6 | 17 ok / 3 fail / 1 warn | all ok |
546
+ | `stealth` | 31 pass | 6/6 | 19 ok / 1 fail / 1 warn | all ok |
547
+ | `undetected` | 31 pass | 6/6 | 19 ok / 1 fail / 1 warn | all ok |
548
+
549
+ - The remaining fpscanner fail is `'webdriver' in navigator`, which is true in every modern
550
+ Chrome; faking it away would make cdpilot the odd one out.
551
+ - `regular`'s fails all come from the `HeadlessChrome` user agent. `stealth`/`undetected`
552
+ rewrite it for the page load that `go` starts; a later, separate command in the same page
553
+ can still read `HeadlessChrome`.
554
+ - new-tests: every key is ok; `connectionRTT` is reported as unknown in all tiers.
555
+ - nowsecure.nl and areyouheadless could not be measured (a fixed always-interactive
556
+ Turnstile test key; HTTP 502).
557
+
558
+ Earlier figures on this page (sannysoft 24/24, intoli 6/6) were measured on v0.4.x in April
559
+ 2026; they are replaced by the table above.
545
560
 
546
561
  ### Reliability
547
562
 
@@ -559,6 +574,32 @@ until cdpilot health >/dev/null; do cdpilot launch; sleep 2; done
559
574
  Surfaces today's Brave crash count from `~/Library/Logs/DiagnosticReports/`
560
575
  on macOS — spot degradation before your automation silently stalls.
561
576
 
577
+ **Auto-launch.** A page command (`go`, `content`, `click`, `shot`, …) that finds
578
+ the browser not running starts it the same way `cdpilot launch` does (same
579
+ profile, same headless/visible setting), then carries on, printing one line to
580
+ stderr: `cdpilot: browser was not running — launched it (CDPILOT_NO_AUTOLAUNCH=1 to disable)`.
581
+ Lifecycle, status and configuration commands (`launch`, `stop`, `close`,
582
+ `close-tab`, `stop-all`, `project-stop`, `session-close`, `status`, `health`,
583
+ `tabs`, `sessions`, `projects`, `headless`, `proxy`, `browser`, `extensions`,
584
+ `mcp`, `serve`, …) never launch. `CDPILOT_NO_AUTOLAUNCH=1` restores the old
585
+ "CDP connection error. Is the browser running?" error and exit code 1.
586
+
587
+ **Timeouts.** Any command takes `--timeout <seconds>`, before or after the
588
+ command name, or a default from `CDPILOT_TIMEOUT` (the flag wins; `0`
589
+ disables). It bounds the whole command's wall-clock: on expiry cdpilot prints
590
+ `cdpilot: timed out after <N>s (<command>)` to stderr, kills the child processes
591
+ it started and exits with code **124** (like GNU `timeout`). A browser that was
592
+ already up and registered is left running.
593
+
594
+ ```bash
595
+ cdpilot --timeout 10 click "#submit" # before the command...
596
+ cdpilot shot page.png --timeout 30 # ...or after it
597
+ CDPILOT_TIMEOUT=60 cdpilot run flow.cdp # default for every command (and each script line)
598
+ ```
599
+
600
+ For `mcp` and `serve` the value is not applied to the long-running server
601
+ itself; it is passed on to every tool call / request it runs.
602
+
562
603
  ### Scaling & Workstation Use
563
604
 
564
605
  ```bash
@@ -620,6 +661,8 @@ print(result.stdout)
620
661
  | `BROWSER_SESSION` | Auto | Session identifier |
621
662
  | `CDPILOT_MODE` | `regular` | Stealth tier override (`regular`/`stealth`/`undetected`) |
622
663
  | `CDPILOT_OFFSCREEN` | `0` | Headed but render off-screen — no window steals focus |
664
+ | `CDPILOT_TIMEOUT` | unset | Default `--timeout` in seconds for every command (flag wins, `0` disables); expiry exits 124 |
665
+ | `CDPILOT_NO_AUTOLAUNCH` | `0` | `1` = page commands fail with the old "Is the browser running?" error instead of launching the browser |
623
666
 
624
667
  ## How It Works
625
668
 
package/bin/cdpilot.js CHANGED
@@ -321,7 +321,14 @@ function showHelp() {
321
321
  cdpilot v${VERSION} — Zero-dependency browser automation
322
322
 
323
323
  USAGE
324
- cdpilot <command> [args]
324
+ cdpilot [--timeout <s>] <command> [args]
325
+
326
+ GLOBAL OPTIONS
327
+ --timeout <s> Abort the command after <s> seconds (exit 124). Before or
328
+ after the command; env CDPILOT_TIMEOUT sets a default
329
+ (the flag wins, 0 disables).
330
+ Page commands start the browser if it is not running
331
+ (set CDPILOT_NO_AUTOLAUNCH=1 to get an error instead).
325
332
 
326
333
  SETUP
327
334
  setup Auto-detect browser, create isolated profile
@@ -332,7 +339,7 @@ function showHelp() {
332
339
  if no user tabs remain (--force quits anyway, --keep never quits)
333
340
 
334
341
  NAVIGATION
335
- go <url> Navigate to URL
342
+ go <url> Navigate to URL (alias: open)
336
343
  content Get page text content
337
344
  html Get page HTML
338
345
  shot [file] Take screenshot
@@ -590,8 +597,23 @@ if (process.argv.includes('--internal-test-runner')) {
590
597
  return; // runAll() is async, this exits via process.exit
591
598
  }
592
599
 
600
+ // Global flags may precede the command name (`cdpilot --timeout 10 click "#x"`).
601
+ // src/cdpilot.py parses and validates them (it is also run directly, without
602
+ // this launcher); here they only have to be skipped to find the command that
603
+ // the launcher answers itself (help / --version / setup / status). args are
604
+ // passed to Python unchanged.
605
+ function commandIndex(argv) {
606
+ let i = 0;
607
+ while (i < argv.length) {
608
+ if (argv[i] === '--timeout') { i += 2; continue; }
609
+ if (argv[i].startsWith('--timeout=')) { i += 1; continue; }
610
+ break;
611
+ }
612
+ return i;
613
+ }
614
+
593
615
  const args = process.argv.slice(2);
594
- const cmd = args[0];
616
+ const cmd = args[commandIndex(args)];
595
617
 
596
618
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
597
619
  showHelp();
@@ -650,7 +672,14 @@ if (cmd === 'status') {
650
672
  env,
651
673
  });
652
674
 
653
- child.on('close', (code) => {
654
- process.exit(code || 0);
675
+ // A child killed by a signal reports code === null. `code || 0` turned that
676
+ // into success, so an OOM-killed or SIGKILLed run looked fine to scripts
677
+ // and agents. Use the shell convention 128 + signal number instead.
678
+ child.on('close', (code, signal) => {
679
+ if (code === null && signal) {
680
+ console.error(`cdpilot: python was terminated by ${signal}`);
681
+ process.exit(128 + (os.constants.signals[signal] || 0));
682
+ }
683
+ process.exit(code ?? 0);
655
684
  });
656
685
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdpilot",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "mcpName": "io.github.mehmetnadir/cdpilot",
5
5
  "description": "Zero-dependency browser automation CLI. Raw CDP, 70+ commands, friction ladder, 3-tier stealth, CAPTCHA + press-and-hold solvers, proxy pools, MCP server. No Puppeteer/Playwright/Selenium.",
6
6
  "bin": {
package/src/cdpilot.py CHANGED
@@ -8,13 +8,20 @@ Chrome DevTools Protocol (CDP). No Puppeteer, no Playwright, no Selenium.
8
8
  Usage:
9
9
  cdpilot <command> [arguments]
10
10
 
11
+ Global options:
12
+ --timeout <seconds> Abort the command after <seconds> (exit 124). Accepted
13
+ before or after the command name; 0 disables.
14
+
11
15
  Environment:
12
16
  CDP_PORT CDP debugging port (default: 9222)
13
17
  CHROME_BIN Browser binary path (auto-detected if not set)
14
18
  CDPILOT_PROFILE Isolated browser profile directory
19
+ CDPILOT_TIMEOUT Default for --timeout (the flag wins)
20
+ CDPILOT_NO_AUTOLAUNCH=1 Do not start the browser when a page command
21
+ finds it not running (print the error instead)
15
22
  """
16
23
 
17
- __version__ = "0.9.1"
24
+ __version__ = "0.9.2"
18
25
 
19
26
  import asyncio
20
27
  import atexit
@@ -36,6 +43,10 @@ import glob
36
43
  import datetime
37
44
  import concurrent.futures
38
45
  import tempfile
46
+ import contextlib
47
+ import io
48
+ import threading
49
+ import weakref
39
50
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
51
 
41
52
  # ─── Project-Based Multi-Instance Configuration ───
@@ -474,7 +485,9 @@ STEALTH_JS_FULL = r"""
474
485
  length: { value: 1, enumerable: true },
475
486
  '0': { value: mime, enumerable: true }
476
487
  });
477
- p.item = function(i) { return i === 0 ? mime : null; };
488
+ // `i >>> 0` = WebIDL unsigned long (ToUint32), like the native item():
489
+ // item(4294967296) wraps to item(0) (incolumitas overflowTest).
490
+ p.item = function(i) { return (i >>> 0) === 0 ? mime : null; };
478
491
  p.namedItem = function(n) { return n === mime.type ? mime : null; };
479
492
  return p;
480
493
  };
@@ -492,7 +505,7 @@ STEALTH_JS_FULL = r"""
492
505
  Object.defineProperty(plugins, p.name, { value: p });
493
506
  }
494
507
  Object.defineProperty(plugins, 'length', { value: pluginNames.length });
495
- plugins.item = function(i) { return plugins[i] || null; };
508
+ plugins.item = function(i) { return plugins[i >>> 0] || null; };
496
509
  plugins.namedItem = function(n) {
497
510
  for (var k = 0; k < plugins.length; k++) if (plugins[k].name === n) return plugins[k];
498
511
  return null;
@@ -505,26 +518,39 @@ STEALTH_JS_FULL = r"""
505
518
  } catch (e) {}
506
519
 
507
520
  // 4b) Worker stealth — workers have their own global scope, so navigator.webdriver
508
- // patches above do NOT propagate. Fingerprint scripts that probe via Worker
509
- // (e.g. fpscanner.WEBDRIVER) catch this inconsistency. Fix: wrap the Worker
510
- // constructor to prepend a navigator.webdriver patch via blob URL. Limitations:
511
- // module workers and same-origin script URLs are best-effort (importScripts
512
- // doesn't support module workers; cross-origin URLs may bypass via direct fetch).
521
+ // patches above do NOT propagate. Fix: wrap the Worker constructor to prepend a
522
+ // navigator.webdriver patch via blob URL. (fpscanner.WEBDRIVER is NOT a worker
523
+ // probe: it is `'webdriver' in navigator` on the page, true in every modern
524
+ // Chrome.) Limitations: module workers are skipped (importScripts can't load
525
+ // them); inside the blob worker, self.location is the blob: URL.
526
+ // Worker scope has no navigator.webdriver in Chrome; the patch only acts if a
527
+ // worker ever reports `true` (defining it unconditionally made 'webdriver' in
528
+ // navigator true inside workers - a tell). The script URL is resolved against
529
+ // the page: a relative URL inside a blob: worker resolves against blob: and
530
+ // failed to load (incolumitas `new Worker("webworker2.js")` never answered).
513
531
  try {
514
532
  var OrigWorker = window.Worker;
515
533
  if (OrigWorker && !window.__cdpilot_worker_patched) {
516
- var workerPatch = "(function(){try{Object.defineProperty(self.navigator||{},'webdriver',{get:function(){return undefined;},configurable:true});}catch(e){}})();";
534
+ var workerPatch = "(function(){try{if(self.navigator&&self.navigator.webdriver===true){Object.defineProperty(self.navigator,'webdriver',{get:function(){return false;},configurable:true});}}catch(e){}})();";
517
535
  var WrappedWorker = function(scriptURL, options) {
536
+ var origURL = scriptURL;
518
537
  try {
519
538
  var isModule = options && options.type === 'module';
520
539
  if (typeof scriptURL === 'string' && !isModule) {
521
540
  // Wrap script in a blob that applies the patch then importScripts the original.
522
- var wrapped = workerPatch + "importScripts(" + JSON.stringify(String(scriptURL)) + ");";
541
+ var absURL = new URL(String(scriptURL), document.baseURI).href;
542
+ var wrapped = workerPatch + "importScripts(" + JSON.stringify(absURL) + ");";
523
543
  var blob = new Blob([wrapped], { type: 'application/javascript' });
524
544
  scriptURL = URL.createObjectURL(blob);
525
545
  }
526
- } catch (e) {}
527
- return new OrigWorker(scriptURL, options);
546
+ } catch (e) { scriptURL = origURL; }
547
+ try {
548
+ return new OrigWorker(scriptURL, options);
549
+ } catch (e) {
550
+ // e.g. CSP forbids blob: workers - fall back to the untouched native call.
551
+ if (scriptURL === origURL) throw e;
552
+ return new OrigWorker(origURL, options);
553
+ }
528
554
  };
529
555
  WrappedWorker.prototype = OrigWorker.prototype;
530
556
  Object.setPrototypeOf(WrappedWorker, OrigWorker);
@@ -1224,11 +1250,86 @@ def cdp_cache_invalidate():
1224
1250
  _CDP_GET_CACHE.clear()
1225
1251
 
1226
1252
 
1253
+ # ─── Auto-launch ───
1254
+ # Measured on ~1,225 real agent sessions: "CDP connection error. Is the browser
1255
+ # running?" was the #1 failure (292 times) — the agent never ran `launch`, or
1256
+ # the browser died between commands. A page-level command that finds the CDP
1257
+ # port unreachable now launches the browser exactly like `cdpilot launch`
1258
+ # (same profile, same headless/visible config), then continues once.
1259
+ AUTOLAUNCH_DISABLE_ENV = "CDPILOT_NO_AUTOLAUNCH"
1260
+ AUTOLAUNCH_NOTICE = ("cdpilot: browser was not running — launched it "
1261
+ "(CDPILOT_NO_AUTOLAUNCH=1 to disable)")
1262
+ # Commands that never start a browser, whatever they touch:
1263
+ # lifecycle — launching to stop/close would be absurd;
1264
+ # status/diag — a check must report the state, not change it;
1265
+ # launch config — must take effect on the NEXT launch, not after one we forced;
1266
+ # servers — startup never launches (`mcp` tool calls run as separate
1267
+ # page-level commands and do auto-launch).
1268
+ # status/setup/help/--version are answered by bin/cdpilot.js; listed so a direct
1269
+ # `python src/cdpilot.py <name>` can never launch either.
1270
+ AUTOLAUNCH_SKIP_CMDS = frozenset({
1271
+ 'launch', 'stop', 'close', 'close-tab', 'session-close', 'project-stop', 'stop-all',
1272
+ 'status', 'health', 'setup', 'help', '--help', '-h', 'version', '--version', '-v',
1273
+ 'tabs', 'session', 'sessions', 'projects', 'heal',
1274
+ 'headless', 'proxy', 'browser', 'extensions', 'ext-install', 'ext-remove',
1275
+ 'mcp', 'serve',
1276
+ })
1277
+ # cmd is set by the CLI dispatcher; None (module imported) means never launch.
1278
+ _AUTOLAUNCH = {"cmd": None, "attempted": False, "failure": None}
1279
+
1280
+
1281
+ def _autolaunch_disabled():
1282
+ return os.environ.get(AUTOLAUNCH_DISABLE_ENV, "").strip().lower() in ("1", "true", "yes", "on")
1283
+
1284
+
1285
+ def _autolaunch_if_down():
1286
+ """Launch the browser (once per process) when this command may and CDP is down.
1287
+
1288
+ Returns True when CDP answers afterwards. Never exits: on failure the
1289
+ reason is kept for _print_autolaunch_failure(), which callers print right
1290
+ after their usual error, so CDPILOT_NO_AUTOLAUNCH=1 output stays identical.
1291
+ """
1292
+ cmd = _AUTOLAUNCH["cmd"]
1293
+ if (cmd is None or cmd in AUTOLAUNCH_SKIP_CMDS or _AUTOLAUNCH["attempted"]
1294
+ or _autolaunch_disabled()):
1295
+ return False
1296
+ _AUTOLAUNCH["attempted"] = True
1297
+ if cdp_get("/json/version", no_cache=True):
1298
+ return True # came up meanwhile (e.g. a parallel `cdpilot launch`)
1299
+ out, err = io.StringIO(), io.StringIO()
1300
+ code = 0
1301
+ try:
1302
+ # cmd_launch's progress lines would land in the command's stdout,
1303
+ # which agents parse — keep them out; errors become the failure reason.
1304
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
1305
+ cmd_launch()
1306
+ except SystemExit as e:
1307
+ code = 0 if e.code is None else (e.code if isinstance(e.code, int) else 1)
1308
+ except Exception as e:
1309
+ err.write(f"{type(e).__name__}: {e}")
1310
+ code = 1
1311
+ cdp_cache_invalidate()
1312
+ if code == 0 and cdp_get("/json/version", no_cache=True):
1313
+ print(AUTOLAUNCH_NOTICE, file=sys.stderr)
1314
+ return True
1315
+ reason = err.getvalue().strip() or out.getvalue().strip() or "browser did not answer on the CDP port"
1316
+ _AUTOLAUNCH["failure"] = " / ".join(ln.strip() for ln in reason.splitlines() if ln.strip())
1317
+ return False
1318
+
1319
+
1320
+ def _print_autolaunch_failure():
1321
+ if _AUTOLAUNCH["failure"]:
1322
+ print(f"cdpilot: auto-launch failed: {_AUTOLAUNCH['failure']}", file=sys.stderr)
1323
+
1324
+
1227
1325
  def get_tabs():
1228
- """Retrieve all CDP targets."""
1326
+ """Retrieve all CDP targets, auto-launching the browser once if allowed."""
1229
1327
  result = cdp_get("/json")
1328
+ if result is None and _autolaunch_if_down():
1329
+ result = cdp_get("/json", no_cache=True)
1230
1330
  if result is None:
1231
1331
  print("CDP connection error. Is the browser running?", file=sys.stderr)
1332
+ _print_autolaunch_failure()
1232
1333
  sys.exit(1)
1233
1334
  return result
1234
1335
 
@@ -1713,6 +1814,9 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
1713
1814
  "id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
1714
1815
  "params": {"source": stealth_source}
1715
1816
  }))
1817
+ # Same session, same lifetime: headless -> drop the HeadlessChrome
1818
+ # token from UA header, navigator and UA-CH brands (regular: never).
1819
+ await apply_headless_ua_override(ws)
1716
1820
 
1717
1821
  # Apply request blocking BEFORE navigate, on this same WS session.
1718
1822
  # Network.setBlockedURLs is session-bound (just like the stealth
@@ -2265,7 +2369,8 @@ def get_headless_config():
2265
2369
  # Tiers, weakest fingerprint footprint -> strongest spoof:
2266
2370
  # regular -> inject NOTHING (cleanest, default)
2267
2371
  # stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
2268
- # undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker)
2372
+ # + headless UA override (HeadlessChrome -> Chrome, UA-CH brands)
2373
+ # undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker) + same UA override
2269
2374
  MODE_TIERS = ('regular', 'stealth', 'undetected')
2270
2375
  DEFAULT_MODE_TIER = 'regular'
2271
2376
 
@@ -2317,6 +2422,187 @@ def stealth_js_for_tier(tier):
2317
2422
  return None
2318
2423
 
2319
2424
 
2425
+ # ─── Headless UA override (stealth + undetected tiers only) ───────────────────
2426
+ # Headless Chromium says "HeadlessChrome/<v>" in the HTTP User-Agent header AND
2427
+ # in navigator.userAgent/appVersion. No JS patch can fix the header, and the
2428
+ # token alone fails sannysoft/incolumitas User-Agent + HEADCHR_UA; ua-parser then
2429
+ # names the browser "Chrome Headless", which is what fails fpscanner CHR_MEMORY.
2430
+ # The stealth tiers therefore send Emulation.setUserAgentOverride on the SAME
2431
+ # navigate WS that registers the stealth script (session-bound like it: active
2432
+ # for this navigation and its load; it lapses when the WS closes).
2433
+ # userAgentMetadata is mandatory: an override without it empties
2434
+ # navigator.userAgentData.brands and drops Sec-CH-UA (measured) - a louder tell.
2435
+
2436
+ def headless_ua_rewrite(ua):
2437
+ """'... HeadlessChrome/154.0.0.0 ...' -> '... Chrome/154.0.0.0 ...'.
2438
+
2439
+ Pure. A UA without the headless token (headed browser) comes back unchanged,
2440
+ which is also how callers detect that there is nothing to override.
2441
+ """
2442
+ if not isinstance(ua, str) or 'HeadlessChrome' not in ua:
2443
+ return ua
2444
+ return ua.replace('HeadlessChrome', 'Chrome')
2445
+
2446
+
2447
+ def headless_brand_rewrite(brands):
2448
+ """UA-CH brand list with 'HeadlessChrome' renamed to 'Google Chrome' (what a
2449
+ headed Chrome reports). Chromium / Brave / GREASE entries are kept as-is."""
2450
+ out = []
2451
+ for b in brands or []:
2452
+ if isinstance(b, dict) and 'brand' in b:
2453
+ name = 'Google Chrome' if b['brand'] == 'HeadlessChrome' else str(b['brand'])
2454
+ out.append({'brand': name, 'version': str(b.get('version', ''))})
2455
+ return out
2456
+
2457
+
2458
+ def grease_brand_list(major, brand, full_version=None):
2459
+ """Chromium's GREASEd brand list for a major version (port of
2460
+ components/embedder_support/user_agent_utils.cc). Fallback only, for when
2461
+ the current document is not a secure context and cannot report its own list.
2462
+ Matches real Sec-CH-UA: 120 -> Not_A Brand/8, Chromium, Google Chrome;
2463
+ 131 -> Google Chrome, Chromium, Not_A Brand/24; 154 -> Chromium, Brave, Not A(Brand/99.
2464
+ """
2465
+ seed = int(major)
2466
+ chars = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_']
2467
+ order = [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)][seed % 6]
2468
+ grease_version = ['8', '99', '24'][seed % 3]
2469
+ if full_version:
2470
+ grease_version += '.0.0.0'
2471
+ version = full_version or str(seed)
2472
+ out = [None, None, None]
2473
+ out[order[0]] = {'brand': 'Not' + chars[seed % 11] + 'A' + chars[(seed + 1) % 11] + 'Brand',
2474
+ 'version': grease_version}
2475
+ out[order[1]] = {'brand': 'Chromium', 'version': version}
2476
+ out[order[2]] = {'brand': brand, 'version': version}
2477
+ return out
2478
+
2479
+
2480
+ # Evaluated in the CURRENT document (before navigating) to copy the browser's
2481
+ # real UA-CH values. userAgentData exists only in secure contexts; the other
2482
+ # fields feed the synthesized fallback.
2483
+ UA_HINTS_PROBE_JS = r"""(async () => {
2484
+ var r = {brave: !!navigator.brave, platform: navigator.platform};
2485
+ try {
2486
+ var d = navigator.userAgentData;
2487
+ if (d && d.getHighEntropyValues) {
2488
+ r.hints = await d.getHighEntropyValues(['architecture', 'bitness', 'model',
2489
+ 'platformVersion', 'fullVersionList', 'uaFullVersion', 'wow64', 'formFactors']);
2490
+ }
2491
+ } catch (e) {}
2492
+ return JSON.stringify(r);
2493
+ })()"""
2494
+
2495
+
2496
+ def headless_ua_metadata(probe, version_info):
2497
+ """CDP UserAgentMetadata for the override. Copies the live document's own
2498
+ hints (exact) with HeadlessChrome renamed; synthesizes a Chromium-accurate
2499
+ set only when the current document could not report them."""
2500
+ probe = probe if isinstance(probe, dict) else {}
2501
+ hints = probe.get('hints')
2502
+ if isinstance(hints, dict) and hints.get('brands'):
2503
+ meta = {
2504
+ 'brands': headless_brand_rewrite(hints.get('brands')),
2505
+ 'fullVersionList': headless_brand_rewrite(hints.get('fullVersionList')),
2506
+ 'platform': str(hints.get('platform', '')),
2507
+ 'platformVersion': str(hints.get('platformVersion', '')),
2508
+ 'architecture': str(hints.get('architecture', '')),
2509
+ 'model': str(hints.get('model', '')),
2510
+ 'mobile': bool(hints.get('mobile', False)),
2511
+ }
2512
+ if hints.get('uaFullVersion'):
2513
+ meta['fullVersion'] = str(hints['uaFullVersion'])
2514
+ if hints.get('bitness') is not None:
2515
+ meta['bitness'] = str(hints['bitness'])
2516
+ if 'wow64' in hints:
2517
+ meta['wow64'] = bool(hints['wow64'])
2518
+ if isinstance(hints.get('formFactors'), list):
2519
+ meta['formFactors'] = [str(f) for f in hints['formFactors']]
2520
+ return meta
2521
+ # Fallback: insecure current document. Version from Browser.getVersion.
2522
+ product = str((version_info or {}).get('product', ''))
2523
+ ua = str((version_info or {}).get('userAgent', ''))
2524
+ m = _re.search(r'Chrome/(\d+)\.(\d+)\.(\d+)\.(\d+)', product) or \
2525
+ _re.search(r'Chrome/(\d+)\.(\d+)\.(\d+)\.(\d+)', ua)
2526
+ if not m:
2527
+ return None
2528
+ major = m.group(1)
2529
+ if probe.get('brave'):
2530
+ brand, full = 'Brave', major + '.0.0.0' # Brave reports reduced versions
2531
+ else:
2532
+ brand = 'Microsoft Edge' if 'Edg/' in ua else 'Google Chrome'
2533
+ full = '.'.join(m.groups())
2534
+ nav_platform = str(probe.get('platform', ''))
2535
+ if nav_platform.startswith('Mac') or 'Macintosh' in ua:
2536
+ plat, plat_ver = 'macOS', platform.mac_ver()[0]
2537
+ elif nav_platform.startswith('Win') or 'Windows' in ua:
2538
+ plat, plat_ver = 'Windows', ''
2539
+ else:
2540
+ plat, plat_ver = 'Linux', ''
2541
+ machine = platform.machine().lower()
2542
+ return {
2543
+ 'brands': grease_brand_list(major, brand),
2544
+ 'fullVersionList': grease_brand_list(major, brand, full),
2545
+ 'fullVersion': full,
2546
+ 'platform': plat,
2547
+ 'platformVersion': plat_ver,
2548
+ 'architecture': 'arm' if machine.startswith(('arm', 'aarch')) else 'x86',
2549
+ 'bitness': '64' if '64' in machine else '32',
2550
+ 'model': '',
2551
+ 'mobile': False,
2552
+ 'wow64': False,
2553
+ }
2554
+
2555
+
2556
+ async def _ws_request(ws, msg_id, method, params=None, timeout=3.0):
2557
+ """Send one CDP command on a raw navigate WS and wait for its reply,
2558
+ skipping interleaved events. Returns the reply message or None on timeout."""
2559
+ await ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
2560
+ deadline = time.time() + timeout
2561
+ while time.time() < deadline:
2562
+ try:
2563
+ raw = await asyncio.wait_for(ws.recv(), timeout=max(0.05, deadline - time.time()))
2564
+ except asyncio.TimeoutError:
2565
+ break
2566
+ msg = json.loads(raw)
2567
+ if msg.get("id") == msg_id:
2568
+ return msg
2569
+ return None
2570
+
2571
+
2572
+ async def apply_headless_ua_override(ws, base_id=40):
2573
+ """Stealth/undetected tiers: on this navigate session, stop advertising
2574
+ HeadlessChrome (HTTP header, navigator.userAgent/appVersion, UA-CH brands).
2575
+
2576
+ Headless is detected from the browser itself (Browser.getVersion userAgent
2577
+ carries the HeadlessChrome token); headed browsers get no command at all.
2578
+ Returns the applied UA, or None. Never raises - navigation must proceed.
2579
+ """
2580
+ try:
2581
+ ver = await _ws_request(ws, base_id, "Browser.getVersion")
2582
+ info = (ver or {}).get("result") or {}
2583
+ real_ua = info.get("userAgent", "")
2584
+ new_ua = headless_ua_rewrite(real_ua)
2585
+ if not new_ua or new_ua == real_ua:
2586
+ return None
2587
+ probe = None
2588
+ r = await _ws_request(ws, base_id + 1, "Runtime.evaluate", {
2589
+ "expression": UA_HINTS_PROBE_JS, "returnByValue": True, "awaitPromise": True})
2590
+ try:
2591
+ probe = json.loads(r["result"]["result"]["value"])
2592
+ except Exception:
2593
+ probe = None
2594
+ params = {"userAgent": new_ua}
2595
+ meta = headless_ua_metadata(probe, info)
2596
+ if meta:
2597
+ params["userAgentMetadata"] = meta
2598
+ res = await _ws_request(ws, base_id + 2, "Emulation.setUserAgentOverride", params)
2599
+ if not res or "error" in res:
2600
+ return None
2601
+ return new_ua
2602
+ except Exception:
2603
+ return None
2604
+
2605
+
2320
2606
  def cmd_mode(tier=None):
2321
2607
  """Get or set the three-tier stealth mode (regular | stealth | undetected).
2322
2608
 
@@ -2336,8 +2622,10 @@ def cmd_mode(tier=None):
2336
2622
  current = get_mode_config()
2337
2623
  patch = {
2338
2624
  'regular': 'none (no fingerprint patch — cleanest)',
2339
- 'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
2340
- 'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)',
2625
+ 'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)'
2626
+ ' + headless UA override',
2627
+ 'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)'
2628
+ ' + headless UA override',
2341
2629
  }[current]
2342
2630
  print(f'Mode: {current}')
2343
2631
  print(f' Injects: {patch}')
@@ -2590,13 +2878,23 @@ def cmd_launch():
2590
2878
  chrome_args.append('--headless=new')
2591
2879
  print(' Mode: headless')
2592
2880
 
2593
- proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
2881
+ try:
2882
+ proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
2883
+ except OSError as e:
2884
+ # Windows' FileNotFoundError ("[WinError 2] The system cannot find the
2885
+ # file specified") does not name the binary; say which one, on every OS,
2886
+ # instead of a traceback.
2887
+ print(f"Cannot start browser {chrome_args[0]!r}: {e.strerror or e}", file=sys.stderr)
2888
+ sys.exit(1)
2594
2889
 
2595
2890
  for _ in range(20):
2596
2891
  time.sleep(0.5)
2597
2892
  if cdp_get('/json/version'):
2598
2893
  if PROJECT_ID:
2599
2894
  _register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
2895
+ # Up and registered: the browser is meant to outlive this command,
2896
+ # so a --timeout expiring later must not kill it as an orphan.
2897
+ _timeout_release_child(proc)
2600
2898
  # Off-screen / minimized placement (opt-in). Keeps the browser
2601
2899
  # HEADED (anti-bot still sees a real GPU-backed window) but parks it
2602
2900
  # out of the way so long bench runs never steal focus or pop in
@@ -2635,9 +2933,8 @@ def cmd_tabs(reap=False, max_tabs=1, include_real=False, dry_run=False):
2635
2933
 
2636
2934
 
2637
2935
  async def cmd_go(url):
2638
- if not cdp_get("/json/version"):
2639
- cmd_launch()
2640
-
2936
+ # No explicit launch here any more: get_page_ws() -> get_tabs()
2937
+ # auto-launches (quietly, honouring CDPILOT_NO_AUTOLAUNCH).
2641
2938
  ws, page = get_page_ws()
2642
2939
  # Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
2643
2940
  if isinstance(page, dict):
@@ -4489,9 +4786,7 @@ async def cmd_glow(state="on"):
4489
4786
 
4490
4787
  async def cmd_debug(url=None):
4491
4788
  """Full auto-debug: navigate + console + network + perf + screenshot."""
4492
- if not cdp_get("/json/version"):
4493
- cmd_launch()
4494
-
4789
+ # get_page_ws() auto-launches the browser if needed (see _autolaunch_if_down).
4495
4790
  ws, page = get_page_ws()
4496
4791
 
4497
4792
  if url is None:
@@ -5103,12 +5398,13 @@ async def cmd_context_create(url='about:blank'):
5103
5398
  The target_id is what CDPILOT_TARGET expects.
5104
5399
  """
5105
5400
  if not cdp_get('/json/version'):
5106
- cmd_launch()
5401
+ _autolaunch_if_down()
5107
5402
  # Resolve a base WS to talk to the browser itself (not a tab).
5108
5403
  ver = cdp_get('/json/version')
5109
5404
  browser_ws = ver.get('webSocketDebuggerUrl') if ver else None
5110
5405
  if not browser_ws:
5111
5406
  print('Cannot reach browser-level WS', file=sys.stderr)
5407
+ _print_autolaunch_failure()
5112
5408
  sys.exit(1)
5113
5409
  r = await cdp_send(browser_ws, [
5114
5410
  (1, "Target.createBrowserContext", {}),
@@ -5225,6 +5521,8 @@ async def cmd_new_tab(url='about:blank'):
5225
5521
  """Open a new tab."""
5226
5522
  import urllib.parse
5227
5523
  safe_chars = ":/?#[]@!$&'()*+,;="
5524
+ if not cdp_get('/json/version'):
5525
+ _autolaunch_if_down()
5228
5526
  data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
5229
5527
  cdp_cache_invalidate()
5230
5528
  if data:
@@ -5233,6 +5531,7 @@ async def cmd_new_tab(url='about:blank'):
5233
5531
  print(f' ID: {data.get("id", "?")}')
5234
5532
  else:
5235
5533
  print('Failed to open tab', file=sys.stderr)
5534
+ _print_autolaunch_failure()
5236
5535
 
5237
5536
  def cmd_switch_tab(index_or_id):
5238
5537
  """Switch to a tab by index number or tab ID."""
@@ -7368,9 +7667,10 @@ async def cmd_press_hold(selector=None):
7368
7667
  cdpilot press-hold # auto-find the px-captcha target
7369
7668
  cdpilot press-hold "#px-captcha button"
7370
7669
  """
7371
- if not cdp_get("/json/version"):
7670
+ if not cdp_get("/json/version") and not _autolaunch_if_down():
7372
7671
  print(json.dumps({'solved': False, 'error': 'no_browser',
7373
7672
  'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7673
+ _print_autolaunch_failure()
7374
7674
  sys.exit(1)
7375
7675
  ws, _ = get_page_ws()
7376
7676
  res = await _solve_press_and_hold(ws, target_sel=selector)
@@ -7515,8 +7815,9 @@ async def cmd_profile_warm(minutes=None, sites=None):
7515
7815
  if not site_list:
7516
7816
  site_list = WARM_SAFE_SITES
7517
7817
 
7518
- if not cdp_get("/json/version"):
7818
+ if not cdp_get("/json/version") and not _autolaunch_if_down():
7519
7819
  print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7820
+ _print_autolaunch_failure()
7520
7821
  sys.exit(1)
7521
7822
 
7522
7823
  ws, _ = get_page_ws()
@@ -7593,31 +7894,149 @@ async def cmd_profile_dispatch(args: list) -> None:
7593
7894
  # ─── End Captcha Solver Plugin ───────────────────────────────────────────
7594
7895
 
7595
7896
 
7596
- def _stop_browser_on_port(port):
7597
- """Stop the browser process listening on the given port."""
7598
- import signal
7897
+ def _cdp_browser_close(port):
7898
+ """Send CDP Browser.close to the browser answering /json/version on port.
7899
+
7900
+ Same command the smart close sends, through the same cdp_send. It runs on
7901
+ a worker thread with its own loop, so callers inside a running loop
7902
+ (_browser_close_graceful) work too. True once the command went out.
7903
+ """
7599
7904
  try:
7600
- # Bounded: on some hosts/sandboxes `lsof`/`pkill` can hang instead of
7601
- # erroring (observed while testing the port-registry fix — a stuck
7602
- # `lsof -ti :<port>` blocked the whole /v1/sessions DELETE request).
7603
- # A timeout keeps this best-effort call from ever wedging the caller.
7604
- result = subprocess.run(
7605
- ["lsof", "-ti", f":{port}"],
7606
- capture_output=True, text=True, timeout=5
7607
- )
7608
- pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
7609
- if pids:
7610
- for pid in pids:
7611
- os.kill(int(pid), signal.SIGTERM)
7612
- return True
7613
- else:
7614
- subprocess.run(
7615
- ["pkill", "-f", f"remote-debugging-port={port}"],
7616
- capture_output=True, text=True, timeout=5
7617
- )
7618
- return True
7905
+ url = f"http://127.0.0.1:{int(port)}/json/version"
7906
+ with urllib.request.urlopen(url, timeout=2) as resp:
7907
+ ws_url = json.loads(resp.read()).get("webSocketDebuggerUrl")
7619
7908
  except Exception:
7620
7909
  return False
7910
+ if not ws_url:
7911
+ return False
7912
+ import threading
7913
+ sent = []
7914
+
7915
+ def _send():
7916
+ try:
7917
+ asyncio.run(cdp_send(ws_url, [(1, "Browser.close", {})], timeout=5))
7918
+ sent.append(True)
7919
+ except SystemExit:
7920
+ pass # cdp_send exits when it cannot connect at all
7921
+ except Exception as e:
7922
+ # The browser may drop the socket before it answers.
7923
+ if type(e).__name__.startswith("ConnectionClosed"):
7924
+ sent.append(True)
7925
+
7926
+ worker = threading.Thread(target=_send, daemon=True)
7927
+ worker.start()
7928
+ worker.join(8)
7929
+ return bool(sent)
7930
+
7931
+
7932
+ def _debug_port_pids(port):
7933
+ """Pids whose command line carries --remote-debugging-port=<port> exactly.
7934
+
7935
+ Port 9222 never matches a 92220 flag. Every probe is bounded: `lsof` hung
7936
+ forever on a Mac with a stale network mount (2026-09-27), so it is gone.
7937
+ """
7938
+ port = int(port)
7939
+ pids = set()
7940
+ try:
7941
+ if os.name == "nt":
7942
+ script = (
7943
+ "Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID"
7944
+ " -and $_.CommandLine -match '--remote-debugging-port=%d(?!\\d)' }"
7945
+ " | ForEach-Object { $_.ProcessId }" % port
7946
+ )
7947
+ out = subprocess.run(
7948
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
7949
+ capture_output=True, text=True, timeout=10).stdout
7950
+ else:
7951
+ out = subprocess.run(
7952
+ ["pgrep", "-f", f"remote-debugging-port={port}( |$)"],
7953
+ capture_output=True, text=True, timeout=5).stdout
7954
+ pids = {int(t) for t in out.split() if t.isdigit()}
7955
+ except (OSError, subprocess.SubprocessError):
7956
+ pass
7957
+ # The registry pid, if alive and its command line has the exact flag
7958
+ # (covers hosts without pgrep). Windows: the CIM query above is complete.
7959
+ for info in _load_registry().values():
7960
+ pid = info.get("pid")
7961
+ if (os.name != "nt" and info.get("port") == port and _pid_alive(pid)
7962
+ and int(pid) not in pids):
7963
+ try:
7964
+ with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
7965
+ cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
7966
+ except OSError:
7967
+ try:
7968
+ cmd = subprocess.run(["ps", "-o", "command=", "-p", str(int(pid))],
7969
+ capture_output=True, text=True, timeout=5).stdout
7970
+ except (OSError, subprocess.SubprocessError):
7971
+ cmd = ""
7972
+ if _re.search(rf"--remote-debugging-port={port}(?!\d)", cmd):
7973
+ pids.add(int(pid))
7974
+ pids.discard(os.getpid())
7975
+ return pids
7976
+
7977
+
7978
+ def _stop_browser_on_port(port, verbose=False):
7979
+ """Stop cdpilot's browser on `port`; True if one was stopped.
7980
+
7981
+ 1. Graceful: CDP Browser.close, then wait up to 5 s for the port to free
7982
+ and up to 10 s more for the browser's processes to exit.
7983
+ 2. Otherwise end ONLY the processes whose command line has
7984
+ --remote-debugging-port=<port> exactly: SIGTERM, SIGKILL after 3 s on
7985
+ POSIX; `taskkill /PID <pid> /T /F` on Windows. Never by image name:
7986
+ killing every chrome.exe/brave.exe also took the user's own browser.
7987
+ Every subprocess call has a timeout (a hung `lsof` froze `cdpilot stop`).
7988
+ """
7989
+ port = int(port)
7990
+ closed = _cdp_browser_close(port)
7991
+ deadline = time.time() + 5
7992
+ while closed and time.time() < deadline and not _is_port_free(port):
7993
+ time.sleep(0.1)
7994
+ pids = _debug_port_pids(port)
7995
+ if closed and _is_port_free(port):
7996
+ # The main process outlives its socket while it shuts down (measured
7997
+ # on macOS: 0.5-2 s warm, ~7 s on a fresh profile). Let it exit rather
7998
+ # than signal it, so a following launch does not race its profile lock.
7999
+ deadline = time.time() + 10
8000
+ while pids and time.time() < deadline:
8001
+ time.sleep(0.2)
8002
+ pids = {p for p in pids if _pid_alive(p)}
8003
+ if not pids:
8004
+ if verbose:
8005
+ print(" Closed via CDP Browser.close.")
8006
+ return True
8007
+ if not pids:
8008
+ if closed and verbose:
8009
+ print(" Closed via CDP Browser.close.")
8010
+ return closed
8011
+ if os.name == "nt":
8012
+ for pid in sorted(pids):
8013
+ try:
8014
+ subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
8015
+ capture_output=True, text=True, timeout=10)
8016
+ except (OSError, subprocess.SubprocessError):
8017
+ pass
8018
+ method = "taskkill"
8019
+ else:
8020
+ import signal
8021
+ for pid in pids:
8022
+ try:
8023
+ os.kill(pid, signal.SIGTERM)
8024
+ except OSError:
8025
+ pass
8026
+ deadline = time.time() + 3
8027
+ while time.time() < deadline and any(_pid_alive(p) for p in pids):
8028
+ time.sleep(0.1)
8029
+ method = "SIGTERM"
8030
+ for pid in pids:
8031
+ if _pid_alive(pid):
8032
+ try:
8033
+ os.kill(pid, signal.SIGKILL)
8034
+ method = "SIGTERM+SIGKILL"
8035
+ except OSError:
8036
+ pass
8037
+ if verbose:
8038
+ print(f" Stopped via {method}: PID {', '.join(str(p) for p in sorted(pids))}")
8039
+ return True
7621
8040
 
7622
8041
 
7623
8042
  # ─── Browserbase-Compatible Local API ───
@@ -7696,7 +8115,7 @@ def _api_release_session(session_id: str) -> bool:
7696
8115
  port = sess.get('port')
7697
8116
  # In TEST_MODE, _api_create_session never actually launched a browser on
7698
8117
  # `port` (see the sibling check there) — nothing is listening, so skip
7699
- # the lsof/pkill round trip entirely instead of paying its cost for a
8118
+ # the Browser.close/pgrep round trip entirely instead of paying its cost for a
7700
8119
  # no-op every time.
7701
8120
  if port and os.environ.get('CDPILOT_API_TEST_MODE') != '1':
7702
8121
  _stop_browser_on_port(port)
@@ -7821,48 +8240,12 @@ def cmd_serve(api: bool = False, port: int = 9333):
7821
8240
 
7822
8241
 
7823
8242
  def cmd_stop():
7824
- """Stop the browser instance managed by cdpilot."""
7825
- if platform.system() == "Windows":
7826
- browser_procs = ["brave.exe", "chrome.exe", "chromium.exe"]
7827
- stopped_any = False
7828
- for proc in browser_procs:
7829
- try:
7830
- result = subprocess.run(
7831
- ["taskkill", "/F", "/IM", proc],
7832
- capture_output=True, text=True
7833
- )
7834
- if result.returncode == 0:
7835
- print(f" {proc} terminated")
7836
- stopped_any = True
7837
- except Exception:
7838
- pass
7839
- if stopped_any:
7840
- print(f"Browser stopped (port {CDP_PORT}).")
7841
- else:
7842
- print(f"No browser process found (port {CDP_PORT}).", file=sys.stderr)
7843
- return
7844
-
7845
- import signal
7846
- try:
7847
- result = subprocess.run(
7848
- ["lsof", "-ti", f":{CDP_PORT}"],
7849
- capture_output=True, text=True
7850
- )
7851
- pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
7852
- if pids:
7853
- for pid in pids:
7854
- os.kill(int(pid), signal.SIGTERM)
7855
- print(f" PID {pid} terminated")
7856
- print(f"Browser stopped (port {CDP_PORT}).")
7857
- else:
7858
- # lsof bulamazsa pkill ile dene
7859
- subprocess.run(
7860
- ["pkill", "-f", f"remote-debugging-port={CDP_PORT}"],
7861
- capture_output=True, text=True
7862
- )
7863
- print(f"Browser stopped (port {CDP_PORT}).")
7864
- except Exception as e:
7865
- print(f"Stop error: {e}", file=sys.stderr)
8243
+ """Stop the browser instance managed by cdpilot (only that one: never the
8244
+ user's own Brave/Chrome — see _stop_browser_on_port)."""
8245
+ if _stop_browser_on_port(CDP_PORT, verbose=True):
8246
+ print(f"Browser stopped (port {CDP_PORT}).")
8247
+ else:
8248
+ print(f"No browser process found (port {CDP_PORT}).", file=sys.stderr)
7866
8249
 
7867
8250
  # Update registry
7868
8251
  if PROJECT_ID:
@@ -7974,8 +8357,11 @@ async def _get_element_center(ws_url, selector):
7974
8357
  async def _get_browser_ws():
7975
8358
  """Return the browser-level WebSocket URL (/json/version)."""
7976
8359
  info = cdp_get("/json/version")
8360
+ if not info and _autolaunch_if_down():
8361
+ info = cdp_get("/json/version", no_cache=True)
7977
8362
  if not info:
7978
8363
  print("Error: browser not running (CDP /json/version unreachable).", file=sys.stderr)
8364
+ _print_autolaunch_failure()
7979
8365
  sys.exit(1)
7980
8366
  return info.get("webSocketDebuggerUrl")
7981
8367
 
@@ -12333,8 +12719,9 @@ async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
12333
12719
  fdir = _watch_frames_dir()
12334
12720
  os.makedirs(fdir, exist_ok=True)
12335
12721
 
12336
- # Launch browser if needed
12337
- if not cdp_get("/json/version"):
12722
+ # Launch browser if needed (the daemon is forked by `watch start`, a page
12723
+ # command, so it follows the same CDPILOT_NO_AUTOLAUNCH switch).
12724
+ if not cdp_get("/json/version") and not _autolaunch_disabled():
12338
12725
  cmd_launch()
12339
12726
 
12340
12727
  ws_url, _ = get_page_ws()
@@ -12928,9 +13315,156 @@ def _dispatch_watch_cmd(args):
12928
13315
  # ─── End cdpilot watch namespace ──────────────────────────────────────────────
12929
13316
 
12930
13317
 
13318
+ # ─── Global --timeout (issue #2) ───
13319
+ # Measured on ~1,225 real agent sessions: 171 had a command hang with no way to
13320
+ # bound it. `--timeout <s>` (before or after the command name) or
13321
+ # CDPILOT_TIMEOUT caps the whole command's wall-clock. On expiry: one stderr
13322
+ # line, exit 124 (GNU `timeout` convention), child processes killed.
13323
+ #
13324
+ # Mechanism: a daemon watchdog thread that ends the process with os._exit(124).
13325
+ # - SIGALRM does not exist on Windows.
13326
+ # - asyncio.wait_for cannot preempt the blocking sync calls commands make all
13327
+ # over (urllib, time.sleep, subprocess.run), and many commands are sync —
13328
+ # a cooperative timeout would miss exactly the hangs we measured.
13329
+ # - Killing from the Node launcher would miss `python src/cdpilot.py ...`,
13330
+ # MCP tool calls and `run` scripts, which spawn cdpilot.py directly.
13331
+ # os._exit from a helper thread ends the process on every OS whatever the main
13332
+ # thread is blocked in. Children spawned while the watchdog is armed are
13333
+ # tracked and killed first; the browser counts as ours only until it is up and
13334
+ # registered (like `cdpilot launch`, it is meant to outlive the command).
13335
+ TIMEOUT_ENV = "CDPILOT_TIMEOUT"
13336
+ TIMEOUT_EXIT_CODE = 124
13337
+ # Long-running servers are not bounded themselves; the value is exported in
13338
+ # CDPILOT_TIMEOUT so each per-request child (MCP tool call, API launch) is.
13339
+ TIMEOUT_EXEMPT_CMDS = frozenset({'mcp', 'serve'})
13340
+ # Commands that parse their own `--timeout` after the command name: that flag
13341
+ # is left in their args and wins; the global value is only their default.
13342
+ # None exist today — checked when --timeout was added.
13343
+ COMMANDS_WITH_OWN_TIMEOUT = frozenset()
13344
+ _TIMEOUT_CHILDREN = weakref.WeakSet()
13345
+
13346
+
13347
+ def _parse_timeout_seconds(raw, source):
13348
+ """Seconds as float from a --timeout / CDPILOT_TIMEOUT value; 0 = no timeout."""
13349
+ try:
13350
+ val = float(str(raw).strip())
13351
+ except (TypeError, ValueError):
13352
+ raise ValueError(f"{source} expects a number of seconds, got {raw!r}")
13353
+ if val != val or val < 0 or val == float("inf"):
13354
+ raise ValueError(f"{source} expects seconds >= 0, got {raw!r}")
13355
+ return val
13356
+
13357
+
13358
+ def _extract_timeout(argv, env=None):
13359
+ """Split the global --timeout flag out of argv (argv excludes the program).
13360
+
13361
+ Accepts `--timeout N` / `--timeout=N` before or after the command name.
13362
+ Returns (seconds, remaining_argv): seconds is None when neither the flag
13363
+ nor CDPILOT_TIMEOUT is set, 0.0 when explicitly disabled. The flag wins
13364
+ over the env var. Raises ValueError on a malformed value.
13365
+ """
13366
+ env = os.environ if env is None else env
13367
+ seconds = None
13368
+ rest = []
13369
+ cmd = None
13370
+ i = 0
13371
+ while i < len(argv):
13372
+ a = argv[i]
13373
+ own = cmd in COMMANDS_WITH_OWN_TIMEOUT
13374
+ if not own and a == "--timeout":
13375
+ if i + 1 >= len(argv):
13376
+ raise ValueError("--timeout expects a number of seconds")
13377
+ seconds = _parse_timeout_seconds(argv[i + 1], "--timeout")
13378
+ i += 2
13379
+ continue
13380
+ if not own and a.startswith("--timeout="):
13381
+ seconds = _parse_timeout_seconds(a.split("=", 1)[1], "--timeout")
13382
+ i += 1
13383
+ continue
13384
+ if cmd is None:
13385
+ cmd = a
13386
+ rest.append(a)
13387
+ i += 1
13388
+ if seconds is None:
13389
+ raw = (env.get(TIMEOUT_ENV) or "").strip()
13390
+ if raw:
13391
+ seconds = _parse_timeout_seconds(raw, TIMEOUT_ENV)
13392
+ return seconds, rest
13393
+
13394
+
13395
+ class _TimeoutTrackedPopen(subprocess.Popen):
13396
+ """subprocess.Popen that registers the child for the --timeout watchdog.
13397
+
13398
+ Installed only while a timeout is armed. Detached children (own session,
13399
+ e.g. the `watch` daemon) are meant to outlive cdpilot and are left alone.
13400
+ """
13401
+
13402
+ def __init__(self, *args, **kwargs):
13403
+ super().__init__(*args, **kwargs)
13404
+ detached = kwargs.get("start_new_session") or (
13405
+ kwargs.get("creationflags", 0) & getattr(subprocess, "DETACHED_PROCESS", 0))
13406
+ if not detached:
13407
+ _TIMEOUT_CHILDREN.add(self)
13408
+
13409
+
13410
+ def _timeout_release_child(proc):
13411
+ """Stop treating proc as a child to kill on timeout (it is meant to live on)."""
13412
+ _TIMEOUT_CHILDREN.discard(proc)
13413
+
13414
+
13415
+ def _arm_timeout_watchdog(seconds, cmd):
13416
+ """Start the watchdog: after `seconds`, kill tracked children, exit 124."""
13417
+ subprocess.Popen = _TimeoutTrackedPopen # subprocess.run() uses it too
13418
+ message = f"cdpilot: timed out after {seconds:g}s ({cmd})\n".encode()
13419
+
13420
+ def _flush_std_streams():
13421
+ for stream in (sys.stdout, sys.stderr):
13422
+ try:
13423
+ stream.flush()
13424
+ except Exception:
13425
+ pass
13426
+
13427
+ def _expire():
13428
+ try:
13429
+ children = list(_TIMEOUT_CHILDREN)
13430
+ except RuntimeError: # main thread added one mid-copy
13431
+ children = list(_TIMEOUT_CHILDREN)
13432
+ for proc in children:
13433
+ try:
13434
+ if proc.poll() is None:
13435
+ proc.kill()
13436
+ except Exception:
13437
+ pass
13438
+ # Keep what the command already printed. Flush from a helper thread
13439
+ # with a deadline: the main thread may hold the stdout lock while
13440
+ # blocked on a full pipe, and the timeout must still fire.
13441
+ flusher = threading.Thread(target=_flush_std_streams, daemon=True)
13442
+ flusher.start()
13443
+ flusher.join(0.5)
13444
+ try:
13445
+ os.write(2, message)
13446
+ except OSError:
13447
+ pass
13448
+ os._exit(TIMEOUT_EXIT_CODE)
13449
+
13450
+ timer = threading.Timer(seconds, _expire)
13451
+ timer.daemon = True
13452
+ timer.start()
13453
+ atexit.register(timer.cancel) # finished in time: never fire during shutdown
13454
+ return timer
13455
+
13456
+
12931
13457
  # ─── CLI ───
12932
13458
 
12933
13459
  if __name__ == "__main__":
13460
+ try:
13461
+ _timeout_s, _argv = _extract_timeout(sys.argv[1:])
13462
+ except ValueError as _e:
13463
+ print(f"cdpilot: {_e}", file=sys.stderr)
13464
+ sys.exit(2)
13465
+ # Downstream code (and anything reading sys.argv) sees argv without it.
13466
+ sys.argv = sys.argv[:1] + _argv
13467
+
12934
13468
  if len(sys.argv) < 2:
12935
13469
  print(__doc__)
12936
13470
  sys.exit(0)
@@ -12947,6 +13481,11 @@ if __name__ == "__main__":
12947
13481
  print(__doc__)
12948
13482
  sys.exit(0)
12949
13483
 
13484
+ # `open <url>` is what agents type when they mean `go` (14 times in ~1,225
13485
+ # measured sessions). `open` has no other meaning here, so alias it.
13486
+ if cmd == 'open':
13487
+ cmd = 'go'
13488
+
12950
13489
  # Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
12951
13490
  # forks this process with the flag so the screencast consumer runs in
12952
13491
  # the background while the foreground process returns immediately.
@@ -12954,6 +13493,15 @@ if __name__ == "__main__":
12954
13493
  _cmd_watch_daemon_entry()
12955
13494
  sys.exit(0)
12956
13495
 
13496
+ _AUTOLAUNCH["cmd"] = cmd
13497
+ if _timeout_s is not None:
13498
+ # Children (MCP tool calls, `run` lines, test steps) inherit the bound.
13499
+ os.environ[TIMEOUT_ENV] = f"{_timeout_s:g}"
13500
+ if _timeout_s and cmd not in TIMEOUT_EXEMPT_CMDS and not (
13501
+ cmd in COMMANDS_WITH_OWN_TIMEOUT
13502
+ and any(a == "--timeout" or a.startswith("--timeout=") for a in args)):
13503
+ _arm_timeout_watchdog(_timeout_s, cmd)
13504
+
12957
13505
  sync_cmds = {
12958
13506
  'launch': cmd_launch,
12959
13507
  'tabs': lambda: cmd_tabs(