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/README.md +61 -21
- package/bin/cdpilot.js +35 -6
- package/package.json +7 -3
- package/src/cdpilot.py +853 -118
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// module workers
|
|
426
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2544
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
7421
|
-
"""
|
|
7422
|
-
|
|
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
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
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
|
-
|
|
12003
|
-
|
|
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
|
-
|
|
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())))
|