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