cdpilot 0.9.0 → 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/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.0"
24
+ __version__ = "0.9.2"
18
25
 
19
26
  import asyncio
20
27
  import atexit
@@ -35,13 +42,20 @@ import secrets
35
42
  import glob
36
43
  import datetime
37
44
  import concurrent.futures
45
+ import tempfile
46
+ import contextlib
47
+ import io
48
+ import threading
49
+ import weakref
38
50
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39
51
 
40
52
  # ─── Project-Based Multi-Instance Configuration ───
41
53
  # Each project directory (cwd) gets its own browser instance with
42
54
  # a unique CDP port and isolated profile directory. Zero-config.
43
55
 
44
- CDPILOT_HOME = os.path.expanduser("~/.cdpilot")
56
+ # CDPILOT_HOME override lets tests (and advanced users) point the registry
57
+ # at an isolated directory instead of the real ~/.cdpilot.
58
+ CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
45
59
  REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
46
60
  CDPILOT_PORT_RANGE_START = 9222
47
61
  CDPILOT_PORT_RANGE_END = 9322
@@ -65,10 +79,19 @@ def _get_project_id():
65
79
 
66
80
 
67
81
  def _is_port_free(port):
68
- """Check if a port is available for binding."""
82
+ """Check if a port is available for binding.
83
+
84
+ On Windows, SO_REUSEADDR lets a socket bind on top of a port that another
85
+ socket is actively listening on, so a probe using it called every port
86
+ free (and registry cleanup pruned live entries). Exclusive use fails when
87
+ anything holds the port. POSIX SO_REUSEADDR only skips TIME_WAIT.
88
+ """
69
89
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
70
90
  try:
71
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
91
+ if os.name == "nt":
92
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
93
+ else:
94
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
72
95
  s.bind(("127.0.0.1", port))
73
96
  return True
74
97
  except OSError:
@@ -86,10 +109,26 @@ def _load_registry():
86
109
 
87
110
 
88
111
  def _save_registry(projects):
89
- """Write the global project registry."""
90
- os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
91
- with open(REGISTRY_FILE, 'w') as f:
92
- json.dump({"version": 1, "projects": projects}, f, indent=2)
112
+ """Write the global project registry atomically.
113
+
114
+ Writes to a temp file in the same directory and os.replace()s it into
115
+ place, so a concurrent cdpilot invocation reading registry.json always
116
+ sees either the fully-old or fully-new content — never a torn/partial
117
+ write (os.replace is atomic on both POSIX and Windows).
118
+ """
119
+ reg_dir = os.path.dirname(REGISTRY_FILE)
120
+ os.makedirs(reg_dir, exist_ok=True)
121
+ fd, tmp_path = tempfile.mkstemp(prefix='.registry-', suffix='.tmp', dir=reg_dir)
122
+ try:
123
+ with os.fdopen(fd, 'w') as f:
124
+ json.dump({"version": 1, "projects": projects}, f, indent=2)
125
+ os.replace(tmp_path, REGISTRY_FILE)
126
+ except BaseException:
127
+ try:
128
+ os.remove(tmp_path)
129
+ except OSError:
130
+ pass
131
+ raise
93
132
 
94
133
 
95
134
  def _register_project(project_id, port, profile_dir, pid=None):
@@ -108,25 +147,83 @@ def _register_project(project_id, port, profile_dir, pid=None):
108
147
  _save_registry(registry)
109
148
 
110
149
 
150
+ def _pid_alive(pid):
151
+ """True if a process with this pid exists. Never signals it.
152
+
153
+ Sending signal 0 is a harmless existence probe on POSIX, but on Windows
154
+ Python maps every signal other than CTRL_C/CTRL_BREAK to TerminateProcess:
155
+ the "probe" kills the process. Registry pids are stale by nature and may
156
+ have been reused by an unrelated program, so on Windows ask the kernel
157
+ through OpenProcess/GetExitCodeProcess instead. Defined this early because
158
+ it runs at import time (_resolve_project_config -> _allocate_port).
159
+ """
160
+ try:
161
+ pid = int(pid)
162
+ except (TypeError, ValueError):
163
+ return False
164
+ if pid <= 0:
165
+ return False
166
+ if os.name == "nt":
167
+ import ctypes
168
+ kernel32 = ctypes.windll.kernel32
169
+ handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
170
+ if not handle:
171
+ return False
172
+ try:
173
+ code = ctypes.c_ulong()
174
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
175
+ return False
176
+ return code.value == 259 # STILL_ACTIVE
177
+ finally:
178
+ kernel32.CloseHandle(handle)
179
+ try:
180
+ os.kill(pid, 0)
181
+ except ProcessLookupError:
182
+ return False
183
+ except PermissionError:
184
+ return True # exists, just owned by another user
185
+ except OSError:
186
+ return False
187
+ return True
188
+
189
+
190
+ def _is_registry_entry_dead(info):
191
+ """A registry entry is dead only when we can PROVE nothing is using it.
192
+
193
+ Dead = nothing is listening on its port AND (if a pid was recorded)
194
+ that process is no longer alive. If the port is currently bound, we
195
+ never call it dead even if our own bookkeeping (status/pid) looks
196
+ stale — it may be another project's live browser instance, and a
197
+ live entry must never be pruned.
198
+ """
199
+ port = info.get("port")
200
+ if port and not _is_port_free(port):
201
+ return False
202
+ pid = info.get("pid")
203
+ if pid and _pid_alive(pid):
204
+ return False
205
+ return True
206
+
207
+
111
208
  def _cleanup_registry():
112
- """Update status for dead processes and return cleaned registry."""
209
+ """Prune dead entries from the registry and return what's left.
210
+
211
+ Called before every port allocation (see _allocate_port) so the
212
+ registry never accumulates stale entries that starve the
213
+ 9222-9322 port range — the root cause of the "No free port" failure
214
+ documented in .claude/docs/vaat-denetimi-2026-09-27.md.
215
+ """
113
216
  registry = _load_registry()
114
- changed = False
115
- for pid_key, info in registry.items():
116
- if info.get("status") == "running":
117
- port = info.get("port")
118
- if port and _is_port_free(port):
119
- info["status"] = "stopped"
120
- info["pid"] = None
121
- changed = True
122
- if changed:
123
- _save_registry(registry)
124
- return registry
217
+ alive = {pid_key: info for pid_key, info in registry.items()
218
+ if not _is_registry_entry_dead(info)}
219
+ if len(alive) != len(registry):
220
+ _save_registry(alive)
221
+ return alive
125
222
 
126
223
 
127
224
  def _allocate_port(project_id):
128
225
  """Find a free port for the given project."""
129
- registry = _load_registry()
226
+ registry = _cleanup_registry()
130
227
 
131
228
  # Reuse existing port if still free
132
229
  if project_id in registry:
@@ -388,7 +485,9 @@ STEALTH_JS_FULL = r"""
388
485
  length: { value: 1, enumerable: true },
389
486
  '0': { value: mime, enumerable: true }
390
487
  });
391
- 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; };
392
491
  p.namedItem = function(n) { return n === mime.type ? mime : null; };
393
492
  return p;
394
493
  };
@@ -406,7 +505,7 @@ STEALTH_JS_FULL = r"""
406
505
  Object.defineProperty(plugins, p.name, { value: p });
407
506
  }
408
507
  Object.defineProperty(plugins, 'length', { value: pluginNames.length });
409
- plugins.item = function(i) { return plugins[i] || null; };
508
+ plugins.item = function(i) { return plugins[i >>> 0] || null; };
410
509
  plugins.namedItem = function(n) {
411
510
  for (var k = 0; k < plugins.length; k++) if (plugins[k].name === n) return plugins[k];
412
511
  return null;
@@ -419,26 +518,39 @@ STEALTH_JS_FULL = r"""
419
518
  } catch (e) {}
420
519
 
421
520
  // 4b) Worker stealth — workers have their own global scope, so navigator.webdriver
422
- // patches above do NOT propagate. Fingerprint scripts that probe via Worker
423
- // (e.g. fpscanner.WEBDRIVER) catch this inconsistency. Fix: wrap the Worker
424
- // constructor to prepend a navigator.webdriver patch via blob URL. Limitations:
425
- // module workers and same-origin script URLs are best-effort (importScripts
426
- // 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).
427
531
  try {
428
532
  var OrigWorker = window.Worker;
429
533
  if (OrigWorker && !window.__cdpilot_worker_patched) {
430
- 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){}})();";
431
535
  var WrappedWorker = function(scriptURL, options) {
536
+ var origURL = scriptURL;
432
537
  try {
433
538
  var isModule = options && options.type === 'module';
434
539
  if (typeof scriptURL === 'string' && !isModule) {
435
540
  // Wrap script in a blob that applies the patch then importScripts the original.
436
- 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) + ");";
437
543
  var blob = new Blob([wrapped], { type: 'application/javascript' });
438
544
  scriptURL = URL.createObjectURL(blob);
439
545
  }
440
- } catch (e) {}
441
- 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
+ }
442
554
  };
443
555
  WrappedWorker.prototype = OrigWorker.prototype;
444
556
  Object.setPrototypeOf(WrappedWorker, OrigWorker);
@@ -1138,11 +1250,86 @@ def cdp_cache_invalidate():
1138
1250
  _CDP_GET_CACHE.clear()
1139
1251
 
1140
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
+
1141
1325
  def get_tabs():
1142
- """Retrieve all CDP targets."""
1326
+ """Retrieve all CDP targets, auto-launching the browser once if allowed."""
1143
1327
  result = cdp_get("/json")
1328
+ if result is None and _autolaunch_if_down():
1329
+ result = cdp_get("/json", no_cache=True)
1144
1330
  if result is None:
1145
1331
  print("CDP connection error. Is the browser running?", file=sys.stderr)
1332
+ _print_autolaunch_failure()
1146
1333
  sys.exit(1)
1147
1334
  return result
1148
1335
 
@@ -1627,6 +1814,9 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
1627
1814
  "id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
1628
1815
  "params": {"source": stealth_source}
1629
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)
1630
1820
 
1631
1821
  # Apply request blocking BEFORE navigate, on this same WS session.
1632
1822
  # Network.setBlockedURLs is session-bound (just like the stealth
@@ -2179,7 +2369,8 @@ def get_headless_config():
2179
2369
  # Tiers, weakest fingerprint footprint -> strongest spoof:
2180
2370
  # regular -> inject NOTHING (cleanest, default)
2181
2371
  # stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
2182
- # 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
2183
2374
  MODE_TIERS = ('regular', 'stealth', 'undetected')
2184
2375
  DEFAULT_MODE_TIER = 'regular'
2185
2376
 
@@ -2231,6 +2422,187 @@ def stealth_js_for_tier(tier):
2231
2422
  return None
2232
2423
 
2233
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
+
2234
2606
  def cmd_mode(tier=None):
2235
2607
  """Get or set the three-tier stealth mode (regular | stealth | undetected).
2236
2608
 
@@ -2250,8 +2622,10 @@ def cmd_mode(tier=None):
2250
2622
  current = get_mode_config()
2251
2623
  patch = {
2252
2624
  'regular': 'none (no fingerprint patch — cleanest)',
2253
- 'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
2254
- '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',
2255
2629
  }[current]
2256
2630
  print(f'Mode: {current}')
2257
2631
  print(f' Injects: {patch}')
@@ -2504,13 +2878,23 @@ def cmd_launch():
2504
2878
  chrome_args.append('--headless=new')
2505
2879
  print(' Mode: headless')
2506
2880
 
2507
- 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)
2508
2889
 
2509
2890
  for _ in range(20):
2510
2891
  time.sleep(0.5)
2511
2892
  if cdp_get('/json/version'):
2512
2893
  if PROJECT_ID:
2513
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)
2514
2898
  # Off-screen / minimized placement (opt-in). Keeps the browser
2515
2899
  # HEADED (anti-bot still sees a real GPU-backed window) but parks it
2516
2900
  # out of the way so long bench runs never steal focus or pop in
@@ -2528,7 +2912,16 @@ def cmd_launch():
2528
2912
  sys.exit(1)
2529
2913
 
2530
2914
 
2531
- def cmd_tabs():
2915
+ def cmd_tabs(reap=False, max_tabs=1, include_real=False, dry_run=False):
2916
+ if reap:
2917
+ result = _reap_tabs(max_tabs=max_tabs, include_real=include_real,
2918
+ dry_run=dry_run)
2919
+ verb = "would close" if result.get("dry_run") else "closed"
2920
+ print(f"{verb} {len(result['closed'])} tab(s), kept {result['kept']}"
2921
+ f" (blank {result.get('blank', 0)}, real {result.get('real', 0)})")
2922
+ if result.get("reason"):
2923
+ print(f" {result['reason']}")
2924
+ return
2532
2925
  tabs = get_tabs()
2533
2926
  pages = [t for t in tabs if t.get("type") == "page"]
2534
2927
  for i, p in enumerate(pages):
@@ -2540,9 +2933,8 @@ def cmd_tabs():
2540
2933
 
2541
2934
 
2542
2935
  async def cmd_go(url):
2543
- if not cdp_get("/json/version"):
2544
- cmd_launch()
2545
-
2936
+ # No explicit launch here any more: get_page_ws() -> get_tabs()
2937
+ # auto-launches (quietly, honouring CDPILOT_NO_AUTOLAUNCH).
2546
2938
  ws, page = get_page_ws()
2547
2939
  # Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
2548
2940
  if isinstance(page, dict):
@@ -4394,9 +4786,7 @@ async def cmd_glow(state="on"):
4394
4786
 
4395
4787
  async def cmd_debug(url=None):
4396
4788
  """Full auto-debug: navigate + console + network + perf + screenshot."""
4397
- if not cdp_get("/json/version"):
4398
- cmd_launch()
4399
-
4789
+ # get_page_ws() auto-launches the browser if needed (see _autolaunch_if_down).
4400
4790
  ws, page = get_page_ws()
4401
4791
 
4402
4792
  if url is None:
@@ -4527,6 +4917,87 @@ def _is_chrome_internal_url(url):
4527
4917
  or u.startswith("vivaldi://"))
4528
4918
 
4529
4919
 
4920
+ def _close_target(target_id):
4921
+ """Close one CDP target. Returns True when the browser accepted it."""
4922
+ try:
4923
+ urllib.request.urlopen(f"{CDP_BASE}/json/close/{target_id}", timeout=2)
4924
+ return True
4925
+ except Exception:
4926
+ return False
4927
+
4928
+
4929
+ def _reap_tabs(max_tabs=1, include_real=False, dry_run=False):
4930
+ """Enforce a tab budget on the connected browser.
4931
+
4932
+ A long-lived browser accumulates abandoned tabs: every client that opens a
4933
+ target and exits without closing it leaks one, and nothing collects them.
4934
+ Measured on srv21 2026-08-30 — 513 pages, 506 of them about:blank, 12.7 GB
4935
+ across 529 renderer processes, growing ~6/hour for three days on a box with
4936
+ 47 GB total.
4937
+
4938
+ Blank tabs go first: they hold nothing a caller could still want. Pages with
4939
+ a real URL are never touched unless include_real is set, because on a shared
4940
+ browser they may belong to somebody else. The session's own tab and the last
4941
+ remaining page always survive.
4942
+ """
4943
+ tabs = cdp_get("/json", no_cache=True)
4944
+ if not tabs:
4945
+ return {"closed": [], "kept": 0, "reason": "no CDP connection"}
4946
+ pages = [t for t in tabs if t.get("type") == "page"]
4947
+ if len(pages) <= 1:
4948
+ return {"closed": [], "kept": len(pages), "reason": "nothing to reap"}
4949
+
4950
+ session_id = _get_session_window_target_id()
4951
+ real, blank = [], []
4952
+ for t in pages:
4953
+ (blank if _is_chrome_internal_url(t.get("url")) else real).append(t)
4954
+
4955
+ keep_ids = []
4956
+ if session_id and any(t.get("id") == session_id for t in pages):
4957
+ keep_ids.append(session_id)
4958
+ if not include_real:
4959
+ keep_ids += [t["id"] for t in real if t["id"] not in keep_ids]
4960
+ for t in blank:
4961
+ if len(keep_ids) >= max_tabs:
4962
+ break
4963
+ if t["id"] not in keep_ids:
4964
+ keep_ids.append(t["id"])
4965
+ if not keep_ids: # never leave the browser with zero pages
4966
+ keep_ids.append(pages[0]["id"])
4967
+
4968
+ doomed = [t for t in pages if t["id"] not in keep_ids]
4969
+ closed = []
4970
+ for t in doomed:
4971
+ if dry_run or _close_target(t["id"]):
4972
+ closed.append(t["id"])
4973
+ if closed and not dry_run:
4974
+ cdp_cache_invalidate()
4975
+ owned = _load_owned_tabs()
4976
+ if owned & set(closed):
4977
+ _save_owned_tabs(owned - set(closed))
4978
+ return {"closed": closed, "kept": len(keep_ids),
4979
+ "blank": len(blank), "real": len(real), "dry_run": dry_run}
4980
+
4981
+
4982
+ def _enforce_tab_budget():
4983
+ """Opt-in post-command reaping via CDPILOT_MAX_TABS.
4984
+
4985
+ Off unless the variable is set: silently closing tabs would be hostile on a
4986
+ desktop where the browser is also the user's. On an unattended server it is
4987
+ the difference between one tab and five hundred.
4988
+ """
4989
+ raw = os.environ.get("CDPILOT_MAX_TABS", "").strip()
4990
+ if not raw.isdigit() or int(raw) < 1:
4991
+ return
4992
+ try:
4993
+ result = _reap_tabs(max_tabs=int(raw))
4994
+ except Exception:
4995
+ return
4996
+ if result.get("closed"):
4997
+ print(f"[cdpilot] tab budget: closed {len(result['closed'])} idle tab(s)",
4998
+ file=sys.stderr)
4999
+
5000
+
4530
5001
  async def _browser_close_graceful():
4531
5002
  """Ask the browser to shut down gracefully via the CDP Browser.close
4532
5003
  command (cleanest cross-platform path — flushes state, no orphaned procs).
@@ -4927,12 +5398,13 @@ async def cmd_context_create(url='about:blank'):
4927
5398
  The target_id is what CDPILOT_TARGET expects.
4928
5399
  """
4929
5400
  if not cdp_get('/json/version'):
4930
- cmd_launch()
5401
+ _autolaunch_if_down()
4931
5402
  # Resolve a base WS to talk to the browser itself (not a tab).
4932
5403
  ver = cdp_get('/json/version')
4933
5404
  browser_ws = ver.get('webSocketDebuggerUrl') if ver else None
4934
5405
  if not browser_ws:
4935
5406
  print('Cannot reach browser-level WS', file=sys.stderr)
5407
+ _print_autolaunch_failure()
4936
5408
  sys.exit(1)
4937
5409
  r = await cdp_send(browser_ws, [
4938
5410
  (1, "Target.createBrowserContext", {}),
@@ -5049,6 +5521,8 @@ async def cmd_new_tab(url='about:blank'):
5049
5521
  """Open a new tab."""
5050
5522
  import urllib.parse
5051
5523
  safe_chars = ":/?#[]@!$&'()*+,;="
5524
+ if not cdp_get('/json/version'):
5525
+ _autolaunch_if_down()
5052
5526
  data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
5053
5527
  cdp_cache_invalidate()
5054
5528
  if data:
@@ -5057,6 +5531,7 @@ async def cmd_new_tab(url='about:blank'):
5057
5531
  print(f' ID: {data.get("id", "?")}')
5058
5532
  else:
5059
5533
  print('Failed to open tab', file=sys.stderr)
5534
+ _print_autolaunch_failure()
5060
5535
 
5061
5536
  def cmd_switch_tab(index_or_id):
5062
5537
  """Switch to a tab by index number or tab ID."""
@@ -7192,9 +7667,10 @@ async def cmd_press_hold(selector=None):
7192
7667
  cdpilot press-hold # auto-find the px-captcha target
7193
7668
  cdpilot press-hold "#px-captcha button"
7194
7669
  """
7195
- if not cdp_get("/json/version"):
7670
+ if not cdp_get("/json/version") and not _autolaunch_if_down():
7196
7671
  print(json.dumps({'solved': False, 'error': 'no_browser',
7197
7672
  'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7673
+ _print_autolaunch_failure()
7198
7674
  sys.exit(1)
7199
7675
  ws, _ = get_page_ws()
7200
7676
  res = await _solve_press_and_hold(ws, target_sel=selector)
@@ -7339,8 +7815,9 @@ async def cmd_profile_warm(minutes=None, sites=None):
7339
7815
  if not site_list:
7340
7816
  site_list = WARM_SAFE_SITES
7341
7817
 
7342
- if not cdp_get("/json/version"):
7818
+ if not cdp_get("/json/version") and not _autolaunch_if_down():
7343
7819
  print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
7820
+ _print_autolaunch_failure()
7344
7821
  sys.exit(1)
7345
7822
 
7346
7823
  ws, _ = get_page_ws()
@@ -7417,27 +7894,149 @@ async def cmd_profile_dispatch(args: list) -> None:
7417
7894
  # ─── End Captcha Solver Plugin ───────────────────────────────────────────
7418
7895
 
7419
7896
 
7420
- def _stop_browser_on_port(port):
7421
- """Stop the browser process listening on the given port."""
7422
- 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
+ """
7423
7904
  try:
7424
- result = subprocess.run(
7425
- ["lsof", "-ti", f":{port}"],
7426
- capture_output=True, text=True
7427
- )
7428
- pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
7429
- if pids:
7430
- for pid in pids:
7431
- os.kill(int(pid), signal.SIGTERM)
7432
- return True
7433
- else:
7434
- subprocess.run(
7435
- ["pkill", "-f", f"remote-debugging-port={port}"],
7436
- capture_output=True, text=True
7437
- )
7438
- 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")
7439
7908
  except Exception:
7440
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
7441
8040
 
7442
8041
 
7443
8042
  # ─── Browserbase-Compatible Local API ───
@@ -7514,7 +8113,11 @@ def _api_release_session(session_id: str) -> bool:
7514
8113
  if not sess:
7515
8114
  return False
7516
8115
  port = sess.get('port')
7517
- if port:
8116
+ # In TEST_MODE, _api_create_session never actually launched a browser on
8117
+ # `port` (see the sibling check there) — nothing is listening, so skip
8118
+ # the Browser.close/pgrep round trip entirely instead of paying its cost for a
8119
+ # no-op every time.
8120
+ if port and os.environ.get('CDPILOT_API_TEST_MODE') != '1':
7518
8121
  _stop_browser_on_port(port)
7519
8122
  sess['status'] = 'STOPPED'
7520
8123
  del _api_session_store[session_id]
@@ -7637,48 +8240,12 @@ def cmd_serve(api: bool = False, port: int = 9333):
7637
8240
 
7638
8241
 
7639
8242
  def cmd_stop():
7640
- """Stop the browser instance managed by cdpilot."""
7641
- if platform.system() == "Windows":
7642
- browser_procs = ["brave.exe", "chrome.exe", "chromium.exe"]
7643
- stopped_any = False
7644
- for proc in browser_procs:
7645
- try:
7646
- result = subprocess.run(
7647
- ["taskkill", "/F", "/IM", proc],
7648
- capture_output=True, text=True
7649
- )
7650
- if result.returncode == 0:
7651
- print(f" {proc} terminated")
7652
- stopped_any = True
7653
- except Exception:
7654
- pass
7655
- if stopped_any:
7656
- print(f"Browser stopped (port {CDP_PORT}).")
7657
- else:
7658
- print(f"No browser process found (port {CDP_PORT}).", file=sys.stderr)
7659
- return
7660
-
7661
- import signal
7662
- try:
7663
- result = subprocess.run(
7664
- ["lsof", "-ti", f":{CDP_PORT}"],
7665
- capture_output=True, text=True
7666
- )
7667
- pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
7668
- if pids:
7669
- for pid in pids:
7670
- os.kill(int(pid), signal.SIGTERM)
7671
- print(f" PID {pid} terminated")
7672
- print(f"Browser stopped (port {CDP_PORT}).")
7673
- else:
7674
- # lsof bulamazsa pkill ile dene
7675
- subprocess.run(
7676
- ["pkill", "-f", f"remote-debugging-port={CDP_PORT}"],
7677
- capture_output=True, text=True
7678
- )
7679
- print(f"Browser stopped (port {CDP_PORT}).")
7680
- except Exception as e:
7681
- 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)
7682
8249
 
7683
8250
  # Update registry
7684
8251
  if PROJECT_ID:
@@ -7790,8 +8357,11 @@ async def _get_element_center(ws_url, selector):
7790
8357
  async def _get_browser_ws():
7791
8358
  """Return the browser-level WebSocket URL (/json/version)."""
7792
8359
  info = cdp_get("/json/version")
8360
+ if not info and _autolaunch_if_down():
8361
+ info = cdp_get("/json/version", no_cache=True)
7793
8362
  if not info:
7794
8363
  print("Error: browser not running (CDP /json/version unreachable).", file=sys.stderr)
8364
+ _print_autolaunch_failure()
7795
8365
  sys.exit(1)
7796
8366
  return info.get("webSocketDebuggerUrl")
7797
8367
 
@@ -11999,13 +12569,8 @@ def _watch_clear_state():
11999
12569
 
12000
12570
 
12001
12571
  def _watch_pid_alive(pid):
12002
- if not pid:
12003
- return False
12004
- try:
12005
- os.kill(int(pid), 0)
12006
- return True
12007
- except (OSError, ValueError):
12008
- return False
12572
+ # os.kill(pid, 0) terminates the process on Windows — see _pid_alive.
12573
+ return _pid_alive(pid)
12009
12574
 
12010
12575
 
12011
12576
  def _watch_parse_timecode(spec):
@@ -12154,8 +12719,9 @@ async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
12154
12719
  fdir = _watch_frames_dir()
12155
12720
  os.makedirs(fdir, exist_ok=True)
12156
12721
 
12157
- # Launch browser if needed
12158
- 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():
12159
12725
  cmd_launch()
12160
12726
 
12161
12727
  ws_url, _ = get_page_ws()
@@ -12749,9 +13315,156 @@ def _dispatch_watch_cmd(args):
12749
13315
  # ─── End cdpilot watch namespace ──────────────────────────────────────────────
12750
13316
 
12751
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
+
12752
13457
  # ─── CLI ───
12753
13458
 
12754
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
+
12755
13468
  if len(sys.argv) < 2:
12756
13469
  print(__doc__)
12757
13470
  sys.exit(0)
@@ -12768,6 +13481,11 @@ if __name__ == "__main__":
12768
13481
  print(__doc__)
12769
13482
  sys.exit(0)
12770
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
+
12771
13489
  # Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
12772
13490
  # forks this process with the flag so the screencast consumer runs in
12773
13491
  # the background while the foreground process returns immediately.
@@ -12775,9 +13493,23 @@ if __name__ == "__main__":
12775
13493
  _cmd_watch_daemon_entry()
12776
13494
  sys.exit(0)
12777
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
+
12778
13505
  sync_cmds = {
12779
13506
  'launch': cmd_launch,
12780
- 'tabs': cmd_tabs,
13507
+ 'tabs': lambda: cmd_tabs(
13508
+ reap='--reap' in args,
13509
+ max_tabs=next((int(a.split('=')[1]) for a in args
13510
+ if a.startswith('--max=') and a.split('=')[1].isdigit()), 1),
13511
+ include_real='--all' in args,
13512
+ dry_run='--dry-run' in args),
12781
13513
  'extensions': cmd_extensions,
12782
13514
  'stop': cmd_stop,
12783
13515
  'version': cmd_version,
@@ -12857,6 +13589,7 @@ if __name__ == "__main__":
12857
13589
 
12858
13590
  if cmd in sync_cmds:
12859
13591
  sync_cmds[cmd]()
13592
+ _enforce_tab_budget()
12860
13593
  sys.exit(0)
12861
13594
 
12862
13595
  def require_args(n, usage):
@@ -13001,6 +13734,7 @@ if __name__ == "__main__":
13001
13734
  if cmd in NO_CONTROL_CMDS:
13002
13735
  asyncio.run(async_map[cmd]())
13003
13736
  _update_session_timestamp()
13737
+ _enforce_tab_budget()
13004
13738
  else:
13005
13739
  async def _wrapped():
13006
13740
  ws_url = None
@@ -13021,6 +13755,7 @@ if __name__ == "__main__":
13021
13755
  await _control_end(ws_url)
13022
13756
  asyncio.run(_wrapped())
13023
13757
  _update_session_timestamp()
13758
+ _enforce_tab_budget()
13024
13759
  else:
13025
13760
  print(f"Unknown command: {cmd}", file=sys.stderr)
13026
13761
  all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))