cdpilot 0.9.4 → 0.9.5
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 -17
- package/bin/cdpilot.js +1 -0
- package/package.json +1 -1
- package/src/cdpilot.py +2184 -162
package/src/cdpilot.py
CHANGED
|
@@ -8,8 +8,8 @@ Chrome DevTools Protocol (CDP). No Puppeteer, no Playwright, no Selenium.
|
|
|
8
8
|
Usage:
|
|
9
9
|
cdpilot <command> [arguments]
|
|
10
10
|
|
|
11
|
-
Elements inside iframes (
|
|
12
|
-
cdpilot click "iframe#card >>> input[name=cardnumber]" # or --frame <sel
|
|
11
|
+
Elements inside iframes and shadow roots (open shadow DOM; nest with >>>):
|
|
12
|
+
cdpilot click "iframe#card >>> input[name=cardnumber]" # or --frame <sel> / my-card >>> .inner
|
|
13
13
|
|
|
14
14
|
WebMCP tools of the page (browser started with `launch --webmcp`):
|
|
15
15
|
cdpilot tools list [--json] | tools call <name> [json | --arg k=v ...]
|
|
@@ -38,6 +38,7 @@ Environment:
|
|
|
38
38
|
CHROME_BIN Browser binary path (auto-detected if not set)
|
|
39
39
|
CDPILOT_PROFILE Isolated browser profile directory
|
|
40
40
|
CDPILOT_LOG=0 Do not write the session log
|
|
41
|
+
CDPILOT_VAR_<NAME> Value for flow placeholder {{name}} in replay
|
|
41
42
|
CDPILOT_TIMEOUT Default for --timeout (the flag wins)
|
|
42
43
|
CDPILOT_NO_AUTOLAUNCH=1 Do not start the browser when a page command
|
|
43
44
|
finds it not running (print the error instead)
|
|
@@ -69,6 +70,22 @@ pip install cryptography):
|
|
|
69
70
|
(tabs, popups, iframes, workers), until `stop`
|
|
70
71
|
cdpilot bot-auth status Agent URL, keyid, signer on/off
|
|
71
72
|
|
|
73
|
+
Network monitoring & HAR export:
|
|
74
|
+
cdpilot network [url] [--har <file>]
|
|
75
|
+
Monitor HTTP requests of navigation. With `--har <file>`,
|
|
76
|
+
exports traffic as HAR 1.2 JSON (`--har -` for stdout).
|
|
77
|
+
Sensitive header values (Cookie, Authorization, ...) and
|
|
78
|
+
secret-named URL parameters are masked. Response bodies
|
|
79
|
+
are omitted.
|
|
80
|
+
|
|
81
|
+
Record & replay:
|
|
82
|
+
cdpilot record start [--name <n>] Start recording commands into active session
|
|
83
|
+
cdpilot record status Show active recording status
|
|
84
|
+
cdpilot record cancel Cancel and discard active recording
|
|
85
|
+
cdpilot record stop [--save <file.json>] Stop recording and emit flow JSON
|
|
86
|
+
cdpilot replay <file.json> [--var k=v] [--delay real|none|<ms>] [--dry-run] [--continue]
|
|
87
|
+
Replay a recorded flow with variable substitution
|
|
88
|
+
|
|
72
89
|
Exit codes: 0 ok, 1 error (batch / run: a step failed), 124 --timeout;
|
|
73
90
|
exit 3 from click / smart-click (batch / run: one, and no failed step):
|
|
74
91
|
pressed, release missed the target, not clicked (a real mouse click whose
|
|
@@ -77,7 +94,7 @@ Exit codes: 0 ok, 1 error (batch / run: a step failed), 124 --timeout;
|
|
|
77
94
|
"Pressed (the page replaced or left it ...)" and exits 0.
|
|
78
95
|
"""
|
|
79
96
|
|
|
80
|
-
__version__ = "0.9.
|
|
97
|
+
__version__ = "0.9.5"
|
|
81
98
|
|
|
82
99
|
import asyncio
|
|
83
100
|
import atexit
|
|
@@ -501,17 +518,157 @@ DEV_EXTENSIONS_FILE = os.path.join(PROFILE_DIR, 'dev-extensions.json')
|
|
|
501
518
|
# A function declaration pasted INSIDE each command's IIFE, so it stays local:
|
|
502
519
|
# nothing is left on `window` for page scripts (anti-bot checks, often in an
|
|
503
520
|
# iframe) to find. It used to be window.__cdpilot_waitFor.
|
|
504
|
-
|
|
521
|
+
# ─── Shadow DOM & Selector JS Helper ─────────────────────────────────────────
|
|
522
|
+
# Shared JS helper for resolving CSS selectors with Shadow DOM support.
|
|
523
|
+
# 1. Traverses explicit `>>>` chains through open shadow roots.
|
|
524
|
+
# 2. Automatically falls back to depth-first traversal of all open shadow roots
|
|
525
|
+
# when a plain CSS selector matches nothing at document level.
|
|
526
|
+
_SHADOW_QUERY_JS = r"""
|
|
527
|
+
function __cdpilot_splitHops(sel) {
|
|
528
|
+
var parts = [], buf = '', quote = null, depth = 0;
|
|
529
|
+
for (var i = 0; i < sel.length; i++) {
|
|
530
|
+
var ch = sel[i];
|
|
531
|
+
if (ch === '\\' && i + 1 < sel.length) {
|
|
532
|
+
buf += sel.substr(i, 2);
|
|
533
|
+
i++;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (quote) {
|
|
537
|
+
if (ch === quote) quote = null;
|
|
538
|
+
buf += ch;
|
|
539
|
+
} else if (ch === '"' || ch === "'") {
|
|
540
|
+
quote = ch; buf += ch;
|
|
541
|
+
} else if (ch === '[' || ch === '(') {
|
|
542
|
+
depth++; buf += ch;
|
|
543
|
+
} else if (ch === ']' || ch === ')') {
|
|
544
|
+
depth = Math.max(0, depth - 1); buf += ch;
|
|
545
|
+
} else if (depth === 0 && sel.substr(i, 3) === '>>>') {
|
|
546
|
+
parts.push(buf.trim());
|
|
547
|
+
buf = '';
|
|
548
|
+
i += 2;
|
|
549
|
+
} else {
|
|
550
|
+
buf += ch;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
parts.push(buf.trim());
|
|
554
|
+
return parts;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function __cdpilot_walkHops(root, hops) {
|
|
558
|
+
root = root || document;
|
|
559
|
+
if (!hops || !hops.length) return { el: null, failedHop: null };
|
|
560
|
+
var curr = root;
|
|
561
|
+
var failedHop = null;
|
|
562
|
+
for (var i = 0; i < hops.length; i++) {
|
|
563
|
+
var seg = hops[i];
|
|
564
|
+
var el = null;
|
|
565
|
+
try { el = curr.querySelector(seg); } catch (e) { return { el: null, failedHop: failedHop }; }
|
|
566
|
+
if (!el) return { el: null, failedHop: failedHop };
|
|
567
|
+
if (i === hops.length - 1) {
|
|
568
|
+
if (!el.shadowRoot && !failedHop) failedHop = seg;
|
|
569
|
+
return { el: el, failedHop: failedHop };
|
|
570
|
+
}
|
|
571
|
+
if (el.shadowRoot) {
|
|
572
|
+
curr = el.shadowRoot;
|
|
573
|
+
} else {
|
|
574
|
+
if (!failedHop) failedHop = seg;
|
|
575
|
+
return { el: null, failedHop: failedHop };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { el: null, failedHop: failedHop };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function __cdpilot_deepQuery(root, selector) {
|
|
582
|
+
var all = [];
|
|
583
|
+
try { all = root.querySelectorAll('*'); } catch (e) {}
|
|
584
|
+
for (var i = 0; i < all.length; i++) {
|
|
585
|
+
var el = all[i];
|
|
586
|
+
if (el.shadowRoot) {
|
|
587
|
+
try {
|
|
588
|
+
var m = el.shadowRoot.querySelector(selector);
|
|
589
|
+
if (m) return { el: m, host: el };
|
|
590
|
+
} catch (e) {}
|
|
591
|
+
var deeper = __cdpilot_deepQuery(el.shadowRoot, selector);
|
|
592
|
+
if (deeper) return deeper;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function __cdpilot_hostDesc(host) {
|
|
599
|
+
if (!host) return '';
|
|
600
|
+
var tag = (host.tagName || '').toLowerCase();
|
|
601
|
+
return host.id ? tag + '#' + host.id : tag;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function __cdpilot_resolveSelector(root, selector, deepFallback) {
|
|
605
|
+
root = root || document;
|
|
606
|
+
if (!selector) return null;
|
|
607
|
+
if (selector.indexOf('>>>') !== -1) {
|
|
608
|
+
var parts = __cdpilot_splitHops(selector);
|
|
609
|
+
if (parts.length > 1 && parts.every(function (p) { return p.length > 0; })) {
|
|
610
|
+
var res = __cdpilot_walkHops(root, parts);
|
|
611
|
+
if (res && res.el) {
|
|
612
|
+
return { el: res.el, host: null };
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
var direct = null;
|
|
618
|
+
try { direct = root.querySelector(selector); } catch (e) {}
|
|
619
|
+
if (direct) {
|
|
620
|
+
return { el: direct, host: null };
|
|
621
|
+
}
|
|
622
|
+
if (deepFallback !== false && selector.indexOf('>>>') === -1) {
|
|
623
|
+
var hit = __cdpilot_deepQuery(root, selector);
|
|
624
|
+
if (hit) return hit;
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
_SHADOW_NOTES_PRINTED = set()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _maybe_print_shadow_note(sel, host_desc):
|
|
634
|
+
"""Print the shadow fallback note at most once per selector in a command run."""
|
|
635
|
+
if not host_desc:
|
|
636
|
+
return
|
|
637
|
+
key = (sel, host_desc)
|
|
638
|
+
if key not in _SHADOW_NOTES_PRINTED:
|
|
639
|
+
_SHADOW_NOTES_PRINTED.add(key)
|
|
640
|
+
print(_shadow_deep_fallback_note(sel, host_desc), file=sys.stderr)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
WAIT_AND_QUERY_JS = _SHADOW_QUERY_JS + """
|
|
505
644
|
function __cdpilot_waitFor(selector, timeout) {
|
|
506
645
|
return new Promise(function(resolve) {
|
|
507
|
-
var
|
|
508
|
-
if (el) { resolve(el); return; }
|
|
509
|
-
var obs =
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
646
|
+
var res = __cdpilot_resolveSelector(document, selector, true);
|
|
647
|
+
if (res && res.el) { resolve(res.el); return; }
|
|
648
|
+
var obs = null;
|
|
649
|
+
var timer = null;
|
|
650
|
+
var timerTimeout = null;
|
|
651
|
+
function cleanup() {
|
|
652
|
+
if (obs) { obs.disconnect(); obs = null; }
|
|
653
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
654
|
+
if (timerTimeout) { clearTimeout(timerTimeout); timerTimeout = null; }
|
|
655
|
+
}
|
|
656
|
+
function check() {
|
|
657
|
+
var found = __cdpilot_resolveSelector(document, selector, true);
|
|
658
|
+
if (found && found.el) {
|
|
659
|
+
cleanup();
|
|
660
|
+
resolve(found.el);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
obs = new MutationObserver(check);
|
|
513
666
|
obs.observe(document.documentElement, {childList: true, subtree: true});
|
|
514
|
-
|
|
667
|
+
timer = setInterval(check, 200);
|
|
668
|
+
timerTimeout = setTimeout(function() {
|
|
669
|
+
cleanup();
|
|
670
|
+
resolve(null);
|
|
671
|
+
}, timeout || 5000);
|
|
515
672
|
});
|
|
516
673
|
}
|
|
517
674
|
"""
|
|
@@ -1471,6 +1628,7 @@ AUTOLAUNCH_SKIP_CMDS = frozenset({
|
|
|
1471
1628
|
'tabs', 'session', 'sessions', 'projects', 'heal',
|
|
1472
1629
|
'headless', 'proxy', 'browser', 'extensions', 'ext-install', 'ext-remove',
|
|
1473
1630
|
'mcp', 'serve', 'log', 'connect', 'disconnect',
|
|
1631
|
+
'record', 'replay',
|
|
1474
1632
|
})
|
|
1475
1633
|
# cmd is set by the CLI dispatcher; None (module imported) means never launch.
|
|
1476
1634
|
_AUTOLAUNCH = {"cmd": None, "attempted": False, "failure": None}
|
|
@@ -1579,6 +1737,7 @@ IDLE_POLL_MAX_S = 30.0
|
|
|
1579
1737
|
IDLE_PASSIVE_CMDS = frozenset({
|
|
1580
1738
|
'status', 'health', 'projects', 'version', '--version', '-v',
|
|
1581
1739
|
'help', '--help', '-h', 'setup', 'connect', 'disconnect',
|
|
1740
|
+
'record', 'replay',
|
|
1582
1741
|
})
|
|
1583
1742
|
_IDLE_WARNED = []
|
|
1584
1743
|
|
|
@@ -2553,17 +2712,23 @@ def _bot_auth_warn_if_stale(port=None, version=_BOT_AUTH_UNSET):
|
|
|
2553
2712
|
return False
|
|
2554
2713
|
|
|
2555
2714
|
|
|
2556
|
-
def _cdp_client_pids(port):
|
|
2715
|
+
def _cdp_client_pids(port, _run=None):
|
|
2557
2716
|
"""Pids holding a TCP connection TO the CDP port (clients: a cdpilot
|
|
2558
2717
|
command, `watch`, Playwright, the signer), or None when it cannot be
|
|
2559
|
-
told. Per OS the tool that answers fast: `netstat -anv` on macOS
|
|
2560
|
-
|
|
2561
|
-
|
|
2718
|
+
told. Per OS the tool that answers fast: `netstat -anv` on macOS with a
|
|
2719
|
+
fallback to port-scoped `lsof` (on macOS 26 `netstat -anv` can exit 0
|
|
2720
|
+
with empty stdout under unprivileged parent interpreters; unscoped lsof
|
|
2721
|
+
scanned every process and took >30 s, but port-scoped lsof answers in
|
|
2722
|
+
~0.1-0.3 s), `ss -tnp` on Linux (lsof as a fallback), `netstat -ano` on
|
|
2723
|
+
Windows."""
|
|
2562
2724
|
port = int(port)
|
|
2563
2725
|
|
|
2564
|
-
def
|
|
2726
|
+
def _default_run(argv):
|
|
2565
2727
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=5)
|
|
2566
|
-
|
|
2728
|
+
ok = (0, 1) if (argv and argv[0] == "lsof") else (0,)
|
|
2729
|
+
return r.stdout if r.returncode in ok else None
|
|
2730
|
+
|
|
2731
|
+
run = _run or _default_run
|
|
2567
2732
|
|
|
2568
2733
|
try:
|
|
2569
2734
|
if os.name == "nt":
|
|
@@ -2571,36 +2736,43 @@ def _cdp_client_pids(port):
|
|
|
2571
2736
|
return None if out is None else _cdp_client_pids_netstat(out, port)
|
|
2572
2737
|
if sys.platform == "darwin":
|
|
2573
2738
|
out = run(["netstat", "-anv", "-p", "tcp"])
|
|
2574
|
-
|
|
2739
|
+
res = None if out is None else _cdp_client_pids_bsd_netstat(out, port)
|
|
2740
|
+
if res is not None:
|
|
2741
|
+
return res
|
|
2742
|
+
out = run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:ESTABLISHED", "-F", "pn"])
|
|
2743
|
+
return None if out is None else _cdp_client_pids_lsof(out, port)
|
|
2575
2744
|
try:
|
|
2576
2745
|
out = run(["ss", "-tnp"])
|
|
2577
2746
|
if out is not None:
|
|
2578
2747
|
return _cdp_client_pids_ss(out, port)
|
|
2579
2748
|
except OSError:
|
|
2580
2749
|
pass
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
if r.returncode not in (0, 1): # lsof exits 1 when nothing matches
|
|
2584
|
-
return None
|
|
2585
|
-
return _cdp_client_pids_lsof(r.stdout, port)
|
|
2750
|
+
out = run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:ESTABLISHED", "-F", "pn"])
|
|
2751
|
+
return None if out is None else _cdp_client_pids_lsof(out, port)
|
|
2586
2752
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
2587
2753
|
return None
|
|
2588
2754
|
|
|
2589
2755
|
|
|
2590
2756
|
def _cdp_client_pids_bsd_netstat(text, port):
|
|
2591
2757
|
"""Client pids from macOS `netstat -anv -p tcp`: foreign address
|
|
2592
|
-
`<ip>.<port>`, the owner in the `process:pid` column.
|
|
2758
|
+
`<ip>.<port>`, the owner in the `process:pid` column. An empty or
|
|
2759
|
+
header-only table returns None (unknown), not an empty set."""
|
|
2593
2760
|
pids = set()
|
|
2761
|
+
has_rows = False
|
|
2594
2762
|
for line in (text or "").splitlines():
|
|
2595
2763
|
parts = line.split()
|
|
2596
|
-
if
|
|
2764
|
+
if not parts:
|
|
2765
|
+
continue
|
|
2766
|
+
if parts[0].lower().startswith("tcp"):
|
|
2767
|
+
has_rows = True
|
|
2768
|
+
if len(parts) < 6 or not parts[0].lower().startswith("tcp") or parts[5] != "ESTABLISHED":
|
|
2597
2769
|
continue
|
|
2598
2770
|
if parts[4].rsplit(".", 1)[-1] != str(port):
|
|
2599
2771
|
continue
|
|
2600
2772
|
m = _re.search(r"\S:(\d+)(?=\s|$)", " ".join(parts[6:]))
|
|
2601
2773
|
if m and int(m.group(1)) > 0:
|
|
2602
2774
|
pids.add(int(m.group(1)))
|
|
2603
|
-
return pids
|
|
2775
|
+
return pids if has_rows else None
|
|
2604
2776
|
|
|
2605
2777
|
|
|
2606
2778
|
def _cdp_client_pids_ss(text, port):
|
|
@@ -3936,6 +4108,44 @@ def _frame_selector_hops(target):
|
|
|
3936
4108
|
return parts[:-1], parts[-1]
|
|
3937
4109
|
|
|
3938
4110
|
|
|
4111
|
+
def _frame_fallback_note(hop):
|
|
4112
|
+
"""Stderr note when a >>> first hop matches no iframe or shadow host."""
|
|
4113
|
+
return f"note: '{hop}' matched no iframe or shadow host; used the selector as written"
|
|
4114
|
+
|
|
4115
|
+
|
|
4116
|
+
def _shadow_closed_error(hop):
|
|
4117
|
+
"""Error message when a >>> hop matches an element with a closed shadow root."""
|
|
4118
|
+
return f"'{hop}' has a closed shadow root; cdpilot cannot reach inside it"
|
|
4119
|
+
|
|
4120
|
+
|
|
4121
|
+
def _shadow_deep_fallback_note(sel, host_desc):
|
|
4122
|
+
"""Stderr note when a selector is resolved via deep shadow-piercing fallback."""
|
|
4123
|
+
return f"note: '{sel}' found inside the shadow root of {host_desc}"
|
|
4124
|
+
|
|
4125
|
+
|
|
4126
|
+
def _classify_hop(node_info):
|
|
4127
|
+
"""Classify a probed hop target into: 'frame', 'shadow_open', 'shadow_closed', or 'none'.
|
|
4128
|
+
|
|
4129
|
+
Pure.
|
|
4130
|
+
"""
|
|
4131
|
+
if not node_info or not node_info.get("found", True):
|
|
4132
|
+
return "none"
|
|
4133
|
+
if node_info.get("frameId"):
|
|
4134
|
+
return "frame"
|
|
4135
|
+
shadow_roots = node_info.get("shadowRoots") or []
|
|
4136
|
+
has_open = False
|
|
4137
|
+
for sr in shadow_roots:
|
|
4138
|
+
if isinstance(sr, dict):
|
|
4139
|
+
st = sr.get("shadowRootType")
|
|
4140
|
+
if st == "closed":
|
|
4141
|
+
return "shadow_closed"
|
|
4142
|
+
if st == "open":
|
|
4143
|
+
has_open = True
|
|
4144
|
+
if has_open:
|
|
4145
|
+
return "shadow_open"
|
|
4146
|
+
return "none"
|
|
4147
|
+
|
|
4148
|
+
|
|
3939
4149
|
def _frame_selector_plan(target, first_hop_is_frame, kind="css"):
|
|
3940
4150
|
"""How an element command reads `target` once its first hop was probed.
|
|
3941
4151
|
|
|
@@ -3949,8 +4159,12 @@ def _frame_selector_plan(target, first_hop_is_frame, kind="css"):
|
|
|
3949
4159
|
if cand is None:
|
|
3950
4160
|
return [], target, None
|
|
3951
4161
|
names, inner = cand
|
|
3952
|
-
if
|
|
3953
|
-
|
|
4162
|
+
if first_hop_is_frame in ("shadow_closed", "closed"):
|
|
4163
|
+
raise _FrameError(_shadow_closed_error(names[0]))
|
|
4164
|
+
if first_hop_is_frame in ("shadow_open", "shadow"):
|
|
4165
|
+
return [], target, None
|
|
4166
|
+
if not first_hop_is_frame or first_hop_is_frame == "none":
|
|
4167
|
+
return [], target, _frame_fallback_note(names[0])
|
|
3954
4168
|
return [_parse_frame_hop(n, implicit=True) for n in names], inner, None
|
|
3955
4169
|
|
|
3956
4170
|
|
|
@@ -4493,12 +4707,76 @@ async def _frame_route_rewrite(route, commands):
|
|
|
4493
4707
|
return out
|
|
4494
4708
|
|
|
4495
4709
|
|
|
4710
|
+
async def _probe_route_hop(route, hop):
|
|
4711
|
+
"""Probe whether `hop` in `route` is an iframe, an open shadow host, closed, or neither.
|
|
4712
|
+
|
|
4713
|
+
Returns (kind, oid, node_info):
|
|
4714
|
+
kind: 'frame', 'shadow_open', 'shadow_closed', or 'none'.
|
|
4715
|
+
"""
|
|
4716
|
+
args = f"{json.dumps(hop['kind'])}, {json.dumps(hop['value'])}"
|
|
4717
|
+
# 1. First see if _FRAME_OWNER_JS matches an iframe (same-process or out-of-process)
|
|
4718
|
+
r1 = await _frame_cdp(route, [(1, "Runtime.evaluate",
|
|
4719
|
+
route.eval_params(f"({_FRAME_OWNER_JS})({args}, false)", by_value=False),
|
|
4720
|
+
route.session_id)])
|
|
4721
|
+
oid1 = r1.get(1, {}).get("result", {}).get("objectId")
|
|
4722
|
+
if oid1:
|
|
4723
|
+
d = await _frame_cdp(route, [(2, "DOM.describeNode", {"objectId": oid1}, route.session_id)])
|
|
4724
|
+
node = d.get(2, {}).get("node", {})
|
|
4725
|
+
if node.get("frameId"):
|
|
4726
|
+
return "frame", oid1, node
|
|
4727
|
+
|
|
4728
|
+
# 2. Not an iframe: query the element itself in the current context
|
|
4729
|
+
r2 = await _frame_cdp(route, [(3, "Runtime.evaluate",
|
|
4730
|
+
route.eval_params(f"""(function () {{
|
|
4731
|
+
try {{ return document.querySelector({json.dumps(hop['value'])}); }} catch (e) {{ return null; }}
|
|
4732
|
+
}})()""", by_value=False),
|
|
4733
|
+
route.session_id)])
|
|
4734
|
+
oid2 = r2.get(3, {}).get("result", {}).get("objectId")
|
|
4735
|
+
if not oid2:
|
|
4736
|
+
return "none", None, {"found": False}
|
|
4737
|
+
|
|
4738
|
+
d2 = await _frame_cdp(route, [
|
|
4739
|
+
(4, "DOM.describeNode", {"objectId": oid2, "depth": 1, "pierce": True}, route.session_id)
|
|
4740
|
+
])
|
|
4741
|
+
node2 = d2.get(4, {}).get("node", {})
|
|
4742
|
+
classification = _classify_hop(node2)
|
|
4743
|
+
return classification, oid2, node2
|
|
4744
|
+
|
|
4745
|
+
|
|
4746
|
+
async def _check_chain_closed_shadow(route, names):
|
|
4747
|
+
"""Check if any subsequent hop in names has a closed shadow root."""
|
|
4748
|
+
if len(names) <= 1:
|
|
4749
|
+
return
|
|
4750
|
+
chk_closed_js = f"""(function () {{
|
|
4751
|
+
{_SHADOW_QUERY_JS}
|
|
4752
|
+
var res = __cdpilot_walkHops(document, {json.dumps(names)});
|
|
4753
|
+
return res ? res.failedHop : null;
|
|
4754
|
+
}})()"""
|
|
4755
|
+
r_chk = await _frame_cdp(route, [(1, "Runtime.evaluate", route.eval_params(chk_closed_js, by_value=True), route.session_id)])
|
|
4756
|
+
failed_hop = r_chk.get(1, {}).get("result", {}).get("value")
|
|
4757
|
+
if failed_hop:
|
|
4758
|
+
idx = names.index(failed_hop) if failed_hop in names else len(names) - 1
|
|
4759
|
+
sub_hops = names[:idx + 1]
|
|
4760
|
+
get_node_js = f"""(function() {{
|
|
4761
|
+
{_SHADOW_QUERY_JS}
|
|
4762
|
+
var res = __cdpilot_walkHops(document, {json.dumps(sub_hops)});
|
|
4763
|
+
return res ? res.el : null;
|
|
4764
|
+
}})()"""
|
|
4765
|
+
r_nd = await _frame_cdp(route, [(2, "Runtime.evaluate", route.eval_params(get_node_js, by_value=False), route.session_id)])
|
|
4766
|
+
failed_oid = r_nd.get(2, {}).get("result", {}).get("objectId")
|
|
4767
|
+
if failed_oid:
|
|
4768
|
+
d_nd = await _frame_cdp(route, [(3, "DOM.describeNode", {"objectId": failed_oid, "depth": 1, "pierce": True}, route.session_id)])
|
|
4769
|
+
node_d = d_nd.get(3, {}).get("node", {})
|
|
4770
|
+
if _classify_hop(node_d) == "shadow_closed":
|
|
4771
|
+
raise _FrameError(_shadow_closed_error(failed_hop))
|
|
4772
|
+
|
|
4773
|
+
|
|
4496
4774
|
async def _frame_resolve_target(ws_url, flag_hops, target, kind="css"):
|
|
4497
4775
|
"""Route for one element command: (route or None, selector, note).
|
|
4498
4776
|
|
|
4499
4777
|
`--frame` hops are strict. A `>>>` chain in `target` is used only when its
|
|
4500
|
-
first hop is an iframe (or wraps one) and, for smart-*
|
|
4501
|
-
selector; otherwise `target` stays a plain selector / text with no
|
|
4778
|
+
first hop is an iframe (or wraps one) or an open shadow host and, for smart-*
|
|
4779
|
+
text, reads as a selector; otherwise `target` stays a plain selector / text with no
|
|
4502
4780
|
routing (None) and `note` explains, if needed.
|
|
4503
4781
|
"""
|
|
4504
4782
|
route = _FrameRoute(ws_url)
|
|
@@ -4507,13 +4785,47 @@ async def _frame_resolve_target(ws_url, flag_hops, target, kind="css"):
|
|
|
4507
4785
|
for hop in flag_hops:
|
|
4508
4786
|
await _frame_route_push(route, hop)
|
|
4509
4787
|
cand = _frame_chain_candidate(target, kind)
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4788
|
+
if cand is None:
|
|
4789
|
+
if not route.chain:
|
|
4790
|
+
await _frame_route_close(route)
|
|
4791
|
+
return None, target, None
|
|
4792
|
+
return route, target, None
|
|
4793
|
+
|
|
4794
|
+
names, inner = cand
|
|
4795
|
+
first = _parse_frame_hop(names[0], implicit=True)
|
|
4796
|
+
first_kind, first_oid, _ = await _probe_route_hop(route, first)
|
|
4797
|
+
plan_hops, plan_target, note = _frame_selector_plan(target, first_kind, kind)
|
|
4798
|
+
if not first_kind or first_kind == "none":
|
|
4799
|
+
if not route.chain:
|
|
4800
|
+
await _frame_route_close(route)
|
|
4801
|
+
return None, plan_target, note
|
|
4802
|
+
return route, plan_target, note
|
|
4803
|
+
if first_kind in ("shadow_open", "shadow"):
|
|
4804
|
+
await _check_chain_closed_shadow(route, names)
|
|
4805
|
+
if not route.chain:
|
|
4806
|
+
await _frame_route_close(route)
|
|
4807
|
+
return None, plan_target, None
|
|
4808
|
+
return route, plan_target, None
|
|
4809
|
+
|
|
4810
|
+
# First hop is an iframe: enter it
|
|
4811
|
+
await _frame_route_push(route, plan_hops[0])
|
|
4812
|
+
for i, hop_name in enumerate(names[1:], 1):
|
|
4813
|
+
next_hop = plan_hops[i]
|
|
4814
|
+
hop_kind, _, _ = await _probe_route_hop(route, next_hop)
|
|
4815
|
+
if hop_kind == "shadow_closed":
|
|
4816
|
+
raise _FrameError(_shadow_closed_error(hop_name))
|
|
4817
|
+
if hop_kind == "frame":
|
|
4818
|
+
await _frame_route_push(route, next_hop)
|
|
4819
|
+
elif hop_kind == "shadow_open":
|
|
4820
|
+
await _check_chain_closed_shadow(route, names[i:])
|
|
4821
|
+
rem_selector = " >>> ".join(names[i:] + [inner])
|
|
4822
|
+
return route, rem_selector, None
|
|
4823
|
+
else:
|
|
4824
|
+
frames = await _frame_list(route)
|
|
4825
|
+
avail = "; ".join(f"[{f['index']}] {f['id'] or f['name'] or f['src'][:60]}" for f in frames)
|
|
4826
|
+
raise _FrameError(f"no iframe matches '{hop_name}' in {route.describe()}"
|
|
4827
|
+
+ (f" (frames: {avail})" if avail else " (it has no iframes)"))
|
|
4828
|
+
return route, inner, None
|
|
4517
4829
|
except _FrameError as e:
|
|
4518
4830
|
await _frame_route_close(route)
|
|
4519
4831
|
print(f"Error: {e}", file=sys.stderr)
|
|
@@ -4521,10 +4833,6 @@ async def _frame_resolve_target(ws_url, flag_hops, target, kind="css"):
|
|
|
4521
4833
|
except BaseException:
|
|
4522
4834
|
await _frame_route_close(route)
|
|
4523
4835
|
raise
|
|
4524
|
-
if not route.chain:
|
|
4525
|
-
await _frame_route_close(route)
|
|
4526
|
-
return None, selector, note
|
|
4527
|
-
return route, selector, note
|
|
4528
4836
|
|
|
4529
4837
|
|
|
4530
4838
|
def _frame_aware(kind="css"):
|
|
@@ -5224,10 +5532,91 @@ async def _smart_act_real(route, expr, label):
|
|
|
5224
5532
|
await _frame_cdp(route, [(9, "Runtime.releaseObjectGroup", {"objectGroup": g}, sid)])
|
|
5225
5533
|
|
|
5226
5534
|
|
|
5227
|
-
|
|
5535
|
+
def _process_har_event(data, har_records, har_active, har_page_events):
|
|
5536
|
+
method = data.get("method", "")
|
|
5537
|
+
params = data.get("params", {})
|
|
5538
|
+
req_id = params.get("requestId")
|
|
5539
|
+
|
|
5540
|
+
if method == "Network.requestWillBeSent":
|
|
5541
|
+
if req_id and req_id in har_active:
|
|
5542
|
+
prev = har_active[req_id]
|
|
5543
|
+
if "redirectResponse" in params and not prev.get("responseReceived"):
|
|
5544
|
+
prev["responseReceived"] = params["redirectResponse"]
|
|
5545
|
+
req_data = params.get("request", {})
|
|
5546
|
+
post_data = req_data.get("postData")
|
|
5547
|
+
rec = {
|
|
5548
|
+
"requestId": req_id,
|
|
5549
|
+
"requestWillBeSent": {
|
|
5550
|
+
"url": req_data.get("url", ""),
|
|
5551
|
+
"method": req_data.get("method", "GET"),
|
|
5552
|
+
"headers": req_data.get("headers", {}),
|
|
5553
|
+
"hasPostData": req_data.get("hasPostData", bool(post_data)),
|
|
5554
|
+
"postDataLength": len(post_data.encode("utf-8")) if isinstance(post_data, str) else (len(post_data) if post_data else 0),
|
|
5555
|
+
"wallTime": params.get("wallTime"),
|
|
5556
|
+
"timestamp": params.get("timestamp"),
|
|
5557
|
+
"initiator": params.get("initiator", {}),
|
|
5558
|
+
"type": params.get("type", ""),
|
|
5559
|
+
},
|
|
5560
|
+
}
|
|
5561
|
+
har_records.append(rec)
|
|
5562
|
+
if req_id:
|
|
5563
|
+
har_active[req_id] = rec
|
|
5564
|
+
|
|
5565
|
+
elif method == "Network.responseReceived":
|
|
5566
|
+
resp_data = params.get("response", {})
|
|
5567
|
+
rec = har_active.get(req_id)
|
|
5568
|
+
if not rec:
|
|
5569
|
+
rec = {"requestId": req_id}
|
|
5570
|
+
har_records.append(rec)
|
|
5571
|
+
if req_id:
|
|
5572
|
+
har_active[req_id] = rec
|
|
5573
|
+
rec["responseReceived"] = {
|
|
5574
|
+
"status": resp_data.get("status"),
|
|
5575
|
+
"statusText": resp_data.get("statusText", ""),
|
|
5576
|
+
"headers": resp_data.get("headers", {}),
|
|
5577
|
+
"mimeType": resp_data.get("mimeType", ""),
|
|
5578
|
+
"timing": resp_data.get("timing"),
|
|
5579
|
+
"remoteIPAddress": resp_data.get("remoteIPAddress", ""),
|
|
5580
|
+
"remotePort": resp_data.get("remotePort"),
|
|
5581
|
+
"protocol": resp_data.get("protocol", ""),
|
|
5582
|
+
"fromDiskCache": resp_data.get("fromDiskCache", False),
|
|
5583
|
+
"fromServiceWorker": resp_data.get("fromServiceWorker", False),
|
|
5584
|
+
"type": params.get("type", ""),
|
|
5585
|
+
}
|
|
5586
|
+
|
|
5587
|
+
elif method == "Network.loadingFinished":
|
|
5588
|
+
rec = har_active.get(req_id)
|
|
5589
|
+
if rec:
|
|
5590
|
+
rec["loadingFinished"] = {
|
|
5591
|
+
"encodedDataLength": params.get("encodedDataLength", 0),
|
|
5592
|
+
"timestamp": params.get("timestamp"),
|
|
5593
|
+
}
|
|
5594
|
+
|
|
5595
|
+
elif method == "Network.loadingFailed":
|
|
5596
|
+
rec = har_active.get(req_id)
|
|
5597
|
+
if rec:
|
|
5598
|
+
rec["loadingFailed"] = {
|
|
5599
|
+
"errorText": params.get("errorText", ""),
|
|
5600
|
+
"canceled": params.get("canceled", False),
|
|
5601
|
+
"timestamp": params.get("timestamp"),
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5604
|
+
elif method == "Page.domContentEventFired":
|
|
5605
|
+
har_page_events["domContentEventFired"] = params.get("timestamp")
|
|
5606
|
+
|
|
5607
|
+
elif method == "Page.loadEventFired":
|
|
5608
|
+
har_page_events["loadEventFired"] = params.get("timestamp")
|
|
5609
|
+
|
|
5610
|
+
|
|
5611
|
+
async def navigate_collect(ws_url, url, network=False, console=False, glow=True, har=False):
|
|
5228
5612
|
"""Navigate to a page and optionally collect network/console events."""
|
|
5229
5613
|
import websockets
|
|
5230
5614
|
events = {"network": [], "console": []}
|
|
5615
|
+
har_records = []
|
|
5616
|
+
har_active = {}
|
|
5617
|
+
har_page_events = {}
|
|
5618
|
+
if har:
|
|
5619
|
+
events["har"] = {"records": har_records, "page_events": har_page_events}
|
|
5231
5620
|
|
|
5232
5621
|
async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
|
|
5233
5622
|
# Enable CDP domains
|
|
@@ -5289,6 +5678,9 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
5289
5678
|
data = json.loads(resp)
|
|
5290
5679
|
method = data.get("method", "")
|
|
5291
5680
|
|
|
5681
|
+
if har:
|
|
5682
|
+
_process_har_event(data, har_records, har_active, har_page_events)
|
|
5683
|
+
|
|
5292
5684
|
if network and method == "Network.responseReceived":
|
|
5293
5685
|
r = data["params"]["response"]
|
|
5294
5686
|
events["network"].append({
|
|
@@ -5377,6 +5769,8 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
5377
5769
|
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
|
5378
5770
|
data = json.loads(resp)
|
|
5379
5771
|
m = data.get("method", "")
|
|
5772
|
+
if har:
|
|
5773
|
+
_process_har_event(data, har_records, har_active, har_page_events)
|
|
5380
5774
|
if network and m == "Network.responseReceived":
|
|
5381
5775
|
r = data["params"]["response"]
|
|
5382
5776
|
events["network"].append({
|
|
@@ -6811,6 +7205,7 @@ def cmd_launch(auto=False, idle_close=None):
|
|
|
6811
7205
|
'IPFSCompanion', 'Translate', 'OptimizationGuideModelDownloading',
|
|
6812
7206
|
'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
|
|
6813
7207
|
'PasswordManagerOnboarding', 'AutofillServerCommunication',
|
|
7208
|
+
'MacAppCodeSignClone',
|
|
6814
7209
|
]),
|
|
6815
7210
|
# Anti-bot leak fix (2026-05-31): Chrome's blink runtime exposes an
|
|
6816
7211
|
# "automation controlled" flag when --remote-debugging-port is set;
|
|
@@ -7186,17 +7581,21 @@ async def cmd_shot(output=None, quality=None, element=None, fmt=None):
|
|
|
7186
7581
|
safe_sel = json.dumps(element)
|
|
7187
7582
|
js = f"""
|
|
7188
7583
|
(function() {{
|
|
7189
|
-
|
|
7190
|
-
|
|
7584
|
+
{_SHADOW_QUERY_JS}
|
|
7585
|
+
var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
7586
|
+
if (!res || !res.el) return null;
|
|
7587
|
+
var el = res.el;
|
|
7191
7588
|
el.scrollIntoView({{block: 'center'}});
|
|
7192
7589
|
var r = el.getBoundingClientRect();
|
|
7193
|
-
return JSON.stringify({{x: r.x, y: r.y, width: r.width, height: r.height}});
|
|
7590
|
+
return JSON.stringify({{x: r.x, y: r.y, width: r.width, height: r.height, shadowHost: res.host ? __cdpilot_hostDesc(res.host) : null}});
|
|
7194
7591
|
}})()
|
|
7195
7592
|
"""
|
|
7196
7593
|
cr = await cdp_send(ws, [(2, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
7197
7594
|
rect_str = cr.get(2, {}).get("result", {}).get("value")
|
|
7198
7595
|
if rect_str:
|
|
7199
7596
|
rect = json.loads(rect_str)
|
|
7597
|
+
if rect.get("shadowHost"):
|
|
7598
|
+
_maybe_print_shadow_note(element, rect["shadowHost"])
|
|
7200
7599
|
params["clip"] = {
|
|
7201
7600
|
"x": rect["x"], "y": rect["y"],
|
|
7202
7601
|
"width": rect["width"], "height": rect["height"],
|
|
@@ -7407,6 +7806,22 @@ async def _resolve_selector_ladder(ws_url, inp, strategies=None):
|
|
|
7407
7806
|
if r.get(1, {}).get("result", {}).get("value"):
|
|
7408
7807
|
tried.append({"strategy": "css", "hit": True, "selector": inp})
|
|
7409
7808
|
return inp, tried
|
|
7809
|
+
js_shadow = f"""(function(){{
|
|
7810
|
+
{_SHADOW_QUERY_JS}
|
|
7811
|
+
var res = __cdpilot_resolveSelector(document, {json.dumps(inp)}, true);
|
|
7812
|
+
if (res && res.el) {{
|
|
7813
|
+
return {{ hit: true, shadowHost: res.host ? __cdpilot_hostDesc(res.host) : null }};
|
|
7814
|
+
}}
|
|
7815
|
+
return {{ hit: false }};
|
|
7816
|
+
}})()"""
|
|
7817
|
+
r2 = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
7818
|
+
"expression": js_shadow, "returnByValue": True})])
|
|
7819
|
+
val = r2.get(1, {}).get("result", {}).get("value") or {}
|
|
7820
|
+
if val.get("hit"):
|
|
7821
|
+
if val.get("shadowHost"):
|
|
7822
|
+
_maybe_print_shadow_note(inp, val["shadowHost"])
|
|
7823
|
+
tried.append({"strategy": "css", "hit": True, "selector": inp})
|
|
7824
|
+
return inp, tried
|
|
7410
7825
|
tried.append({"strategy": "css", "hit": False})
|
|
7411
7826
|
continue
|
|
7412
7827
|
|
|
@@ -8054,7 +8469,9 @@ async def cmd_submit(selector="form", ladder=None, no_heal=False):
|
|
|
8054
8469
|
_log_heal("submit", selector, tried, dur, no_heal)
|
|
8055
8470
|
safe_sel = json.dumps(res_sel)
|
|
8056
8471
|
js = f"""(function() {{
|
|
8057
|
-
|
|
8472
|
+
{_SHADOW_QUERY_JS}
|
|
8473
|
+
const resolved = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
8474
|
+
const form = resolved ? resolved.el : null;
|
|
8058
8475
|
if (!form) return 'Form not found: ' + {safe_sel};
|
|
8059
8476
|
const btn = form.querySelector('button[type=submit], input[type=submit], button:last-of-type');
|
|
8060
8477
|
var res;
|
|
@@ -8070,18 +8487,51 @@ async def cmd_submit(selector="form", ladder=None, no_heal=False):
|
|
|
8070
8487
|
async def cmd_wait(selector, timeout=5):
|
|
8071
8488
|
ws, _ = get_page_ws()
|
|
8072
8489
|
safe_sel = json.dumps(selector)
|
|
8073
|
-
js = f"""
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8490
|
+
js = f"""(function() {{
|
|
8491
|
+
{_SHADOW_QUERY_JS}
|
|
8492
|
+
return new Promise((resolve) => {{
|
|
8493
|
+
const res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
8494
|
+
if (res && res.el) {{
|
|
8495
|
+
const el = res.el;
|
|
8496
|
+
const hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
8497
|
+
return resolve({{ out: 'Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim(), shadowHost: hostDesc }});
|
|
8498
|
+
}}
|
|
8499
|
+
let obs = null;
|
|
8500
|
+
let timer = null;
|
|
8501
|
+
let timerTimeout = null;
|
|
8502
|
+
function cleanup() {{
|
|
8503
|
+
if (obs) {{ obs.disconnect(); obs = null; }}
|
|
8504
|
+
if (timer) {{ clearInterval(timer); timer = null; }}
|
|
8505
|
+
if (timerTimeout) {{ clearTimeout(timerTimeout); timerTimeout = null; }}
|
|
8506
|
+
}}
|
|
8507
|
+
function check() {{
|
|
8508
|
+
const found = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
8509
|
+
if (found && found.el) {{
|
|
8510
|
+
cleanup();
|
|
8511
|
+
const el = found.el;
|
|
8512
|
+
const hostDesc = found.host ? __cdpilot_hostDesc(found.host) : null;
|
|
8513
|
+
resolve({{ out: 'Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim(), shadowHost: hostDesc }});
|
|
8514
|
+
return true;
|
|
8515
|
+
}}
|
|
8516
|
+
return false;
|
|
8517
|
+
}}
|
|
8518
|
+
obs = new MutationObserver(check);
|
|
8519
|
+
obs.observe(document.documentElement || document.body, {{childList:true, subtree:true}});
|
|
8520
|
+
timer = setInterval(check, 200);
|
|
8521
|
+
timerTimeout = setTimeout(() => {{
|
|
8522
|
+
cleanup();
|
|
8523
|
+
resolve({{ out: 'Timeout: ' + {safe_sel} + ' not found ({timeout}s)' }});
|
|
8524
|
+
}}, {int(timeout)*1000});
|
|
8079
8525
|
}});
|
|
8080
|
-
|
|
8081
|
-
setTimeout(() => {{ obs.disconnect(); resolve('Timeout: ' + {safe_sel} + ' not found ({timeout}s)'); }}, {int(timeout)*1000});
|
|
8082
|
-
}})"""
|
|
8526
|
+
}})()"""
|
|
8083
8527
|
r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
|
|
8084
|
-
|
|
8528
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
8529
|
+
if isinstance(val, dict):
|
|
8530
|
+
if val.get("shadowHost"):
|
|
8531
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
8532
|
+
print(val.get("out", "?"))
|
|
8533
|
+
else:
|
|
8534
|
+
print(val or "?")
|
|
8085
8535
|
|
|
8086
8536
|
|
|
8087
8537
|
async def cmd_batch():
|
|
@@ -8145,11 +8595,309 @@ async def cmd_batch():
|
|
|
8145
8595
|
sys.exit(CLICK_MISSED_EXIT_CODE)
|
|
8146
8596
|
|
|
8147
8597
|
|
|
8148
|
-
|
|
8598
|
+
def _har_usage_exit():
|
|
8599
|
+
print("Usage: cdpilot network [url] [--har <file>]\ncdpilot: --har needs a value", file=sys.stderr)
|
|
8600
|
+
sys.exit(2)
|
|
8601
|
+
|
|
8602
|
+
|
|
8603
|
+
def _atomic_write_har(path, har_dict):
|
|
8604
|
+
"""Write HAR dict to path atomically (tmp file in target directory + os.replace)."""
|
|
8605
|
+
target_path = os.path.abspath(path)
|
|
8606
|
+
target_dir = os.path.dirname(target_path) or "."
|
|
8607
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
8608
|
+
fd, tmp = tempfile.mkstemp(prefix=".har-", suffix=".tmp", dir=target_dir)
|
|
8609
|
+
try:
|
|
8610
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
8611
|
+
json.dump(har_dict, f, indent=2, ensure_ascii=False)
|
|
8612
|
+
f.write("\n")
|
|
8613
|
+
os.replace(tmp, target_path)
|
|
8614
|
+
except BaseException:
|
|
8615
|
+
try:
|
|
8616
|
+
os.remove(tmp)
|
|
8617
|
+
except OSError:
|
|
8618
|
+
pass
|
|
8619
|
+
raise
|
|
8620
|
+
|
|
8621
|
+
|
|
8622
|
+
def _har_from_events(events, page=None, creator_version=None):
|
|
8623
|
+
"""Pure builder: converts HAR records and page events into a HAR 1.2 dict."""
|
|
8624
|
+
import urllib.parse
|
|
8625
|
+
import datetime
|
|
8626
|
+
|
|
8627
|
+
records = []
|
|
8628
|
+
page_events = {}
|
|
8629
|
+
|
|
8630
|
+
if isinstance(events, dict):
|
|
8631
|
+
if "har" in events and isinstance(events["har"], dict):
|
|
8632
|
+
h = events["har"]
|
|
8633
|
+
records = h.get("records") or []
|
|
8634
|
+
page_events = h.get("page_events") or {}
|
|
8635
|
+
elif "records" in events:
|
|
8636
|
+
records = events.get("records") or []
|
|
8637
|
+
page_events = events.get("page_events") or {}
|
|
8638
|
+
|
|
8639
|
+
def _format_http_version(protocol):
|
|
8640
|
+
if not protocol:
|
|
8641
|
+
return "HTTP/1.1"
|
|
8642
|
+
p = str(protocol).lower().strip()
|
|
8643
|
+
if p.startswith("h2"):
|
|
8644
|
+
return "HTTP/2"
|
|
8645
|
+
if p.startswith("h3"):
|
|
8646
|
+
return "HTTP/3"
|
|
8647
|
+
if p == "http/1.1":
|
|
8648
|
+
return "HTTP/1.1"
|
|
8649
|
+
if p == "http/1.0":
|
|
8650
|
+
return "HTTP/1.0"
|
|
8651
|
+
return str(protocol).upper()
|
|
8652
|
+
|
|
8653
|
+
def _mask_headers(hdr_dict):
|
|
8654
|
+
"""Secret-named headers are masked whole; URL-bearing values of the
|
|
8655
|
+
others (Location, Referer, Link, Refresh) get their secret query
|
|
8656
|
+
parameters masked, like the session log does."""
|
|
8657
|
+
out = []
|
|
8658
|
+
if isinstance(hdr_dict, dict):
|
|
8659
|
+
for k, v in hdr_dict.items():
|
|
8660
|
+
val = "«masked»" if _SLOG_SECRET_NAME_RE.search(str(k)) else _slog_mask_urls(str(v))
|
|
8661
|
+
out.append({"name": str(k), "value": val})
|
|
8662
|
+
elif isinstance(hdr_dict, list):
|
|
8663
|
+
for item in hdr_dict:
|
|
8664
|
+
if isinstance(item, dict) and "name" in item and "value" in item:
|
|
8665
|
+
k = item["name"]
|
|
8666
|
+
v = item["value"]
|
|
8667
|
+
val = "«masked»" if _SLOG_SECRET_NAME_RE.search(str(k)) else _slog_mask_urls(str(v))
|
|
8668
|
+
out.append({"name": str(k), "value": val})
|
|
8669
|
+
return out
|
|
8670
|
+
|
|
8671
|
+
first_wall = None
|
|
8672
|
+
first_ts = None
|
|
8673
|
+
for rec in records:
|
|
8674
|
+
req_sent = rec.get("requestWillBeSent") or {}
|
|
8675
|
+
w = req_sent.get("wallTime")
|
|
8676
|
+
t = req_sent.get("timestamp")
|
|
8677
|
+
if w is not None and t is not None:
|
|
8678
|
+
first_wall = w
|
|
8679
|
+
first_ts = t
|
|
8680
|
+
break
|
|
8681
|
+
|
|
8682
|
+
entries = []
|
|
8683
|
+
for rec in records:
|
|
8684
|
+
req_sent = rec.get("requestWillBeSent", {})
|
|
8685
|
+
resp_rec = rec.get("responseReceived", {})
|
|
8686
|
+
load_fin = rec.get("loadingFinished")
|
|
8687
|
+
load_fail = rec.get("loadingFailed")
|
|
8688
|
+
|
|
8689
|
+
wall_time = req_sent.get("wallTime")
|
|
8690
|
+
start_ts = req_sent.get("timestamp")
|
|
8691
|
+
|
|
8692
|
+
derived_wall = None
|
|
8693
|
+
missing_wall = False
|
|
8694
|
+
if wall_time is not None:
|
|
8695
|
+
derived_wall = wall_time
|
|
8696
|
+
elif first_wall is not None and first_ts is not None and start_ts is not None:
|
|
8697
|
+
derived_wall = first_wall + (start_ts - first_ts)
|
|
8698
|
+
else:
|
|
8699
|
+
missing_wall = True
|
|
8700
|
+
|
|
8701
|
+
if derived_wall is not None:
|
|
8702
|
+
dt = datetime.datetime.fromtimestamp(derived_wall, tz=datetime.timezone.utc)
|
|
8703
|
+
started_dt = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
8704
|
+
else:
|
|
8705
|
+
started_dt = "1970-01-01T00:00:00.000Z"
|
|
8706
|
+
|
|
8707
|
+
if load_fin and "timestamp" in load_fin and start_ts is not None:
|
|
8708
|
+
entry_time = round((load_fin["timestamp"] - start_ts) * 1000, 2)
|
|
8709
|
+
if entry_time < 0:
|
|
8710
|
+
entry_time = -1
|
|
8711
|
+
else:
|
|
8712
|
+
entry_time = -1
|
|
8713
|
+
|
|
8714
|
+
url = _slog_mask_url(req_sent.get("url", ""))
|
|
8715
|
+
method = req_sent.get("method", "GET")
|
|
8716
|
+
protocol = resp_rec.get("protocol", "")
|
|
8717
|
+
http_ver = _format_http_version(protocol)
|
|
8718
|
+
req_headers = _mask_headers(req_sent.get("headers", {}))
|
|
8719
|
+
|
|
8720
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
8721
|
+
qsl = urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True)
|
|
8722
|
+
query_string = [{"name": k, "value": v} for k, v in qsl]
|
|
8723
|
+
|
|
8724
|
+
body_size = req_sent.get("postDataLength", 0) or 0
|
|
8725
|
+
|
|
8726
|
+
status = resp_rec.get("status", 0) if resp_rec else 0
|
|
8727
|
+
status_text = resp_rec.get("statusText", "") if resp_rec else ""
|
|
8728
|
+
resp_headers = _mask_headers(resp_rec.get("headers", {})) if resp_rec else []
|
|
8729
|
+
mime_type = resp_rec.get("mimeType", "") if resp_rec else ""
|
|
8730
|
+
enc_length = load_fin.get("encodedDataLength", 0) if load_fin else 0
|
|
8731
|
+
|
|
8732
|
+
redirect_url = ""
|
|
8733
|
+
if resp_rec and "headers" in resp_rec:
|
|
8734
|
+
hdrs = resp_rec["headers"]
|
|
8735
|
+
if isinstance(hdrs, dict):
|
|
8736
|
+
for hk, hv in hdrs.items():
|
|
8737
|
+
if hk.lower() == "location":
|
|
8738
|
+
redirect_url = _slog_mask_url(str(hv))
|
|
8739
|
+
break
|
|
8740
|
+
elif isinstance(hdrs, list):
|
|
8741
|
+
for hitem in hdrs:
|
|
8742
|
+
if isinstance(hitem, dict) and str(hitem.get("name", "")).lower() == "location":
|
|
8743
|
+
redirect_url = _slog_mask_url(str(hitem.get("value", "")))
|
|
8744
|
+
break
|
|
8745
|
+
|
|
8746
|
+
timing = resp_rec.get("timing") if resp_rec else None
|
|
8747
|
+
if timing:
|
|
8748
|
+
def _tdiff(end_k, start_k):
|
|
8749
|
+
s = timing.get(start_k)
|
|
8750
|
+
e = timing.get(end_k)
|
|
8751
|
+
if s is not None and e is not None and s != -1 and e != -1 and e >= s:
|
|
8752
|
+
return round(float(e - s), 2)
|
|
8753
|
+
return -1
|
|
8754
|
+
|
|
8755
|
+
dns = _tdiff("dnsEnd", "dnsStart")
|
|
8756
|
+
connect = _tdiff("connectEnd", "connectStart")
|
|
8757
|
+
ssl = _tdiff("sslEnd", "sslStart")
|
|
8758
|
+
send = _tdiff("sendEnd", "sendStart")
|
|
8759
|
+
wait = _tdiff("receiveHeadersEnd", "sendEnd")
|
|
8760
|
+
|
|
8761
|
+
receive = -1
|
|
8762
|
+
rec_headers_end = timing.get("receiveHeadersEnd")
|
|
8763
|
+
req_time = timing.get("requestTime")
|
|
8764
|
+
if req_time is None and start_ts is not None:
|
|
8765
|
+
req_time = start_ts
|
|
8766
|
+
fin_ts = load_fin.get("timestamp") if load_fin else None
|
|
8767
|
+
if rec_headers_end is not None and rec_headers_end != -1 and req_time is not None and fin_ts is not None:
|
|
8768
|
+
total_ms = (fin_ts - req_time) * 1000
|
|
8769
|
+
if total_ms >= rec_headers_end:
|
|
8770
|
+
receive = round(float(total_ms - rec_headers_end), 2)
|
|
8771
|
+
|
|
8772
|
+
entry_timings = {
|
|
8773
|
+
"blocked": -1,
|
|
8774
|
+
"dns": dns,
|
|
8775
|
+
"connect": connect,
|
|
8776
|
+
"ssl": ssl,
|
|
8777
|
+
"send": send,
|
|
8778
|
+
"wait": wait,
|
|
8779
|
+
"receive": receive,
|
|
8780
|
+
}
|
|
8781
|
+
else:
|
|
8782
|
+
entry_timings = {
|
|
8783
|
+
"blocked": -1,
|
|
8784
|
+
"dns": -1,
|
|
8785
|
+
"connect": -1,
|
|
8786
|
+
"ssl": -1,
|
|
8787
|
+
"send": -1,
|
|
8788
|
+
"wait": -1,
|
|
8789
|
+
"receive": -1,
|
|
8790
|
+
}
|
|
8791
|
+
|
|
8792
|
+
entry = {
|
|
8793
|
+
"pageref": "page_1",
|
|
8794
|
+
"startedDateTime": started_dt,
|
|
8795
|
+
"time": entry_time,
|
|
8796
|
+
"request": {
|
|
8797
|
+
"method": method,
|
|
8798
|
+
"url": url,
|
|
8799
|
+
"httpVersion": http_ver,
|
|
8800
|
+
"headers": req_headers,
|
|
8801
|
+
"queryString": query_string,
|
|
8802
|
+
"cookies": [],
|
|
8803
|
+
"headersSize": -1,
|
|
8804
|
+
"bodySize": body_size,
|
|
8805
|
+
},
|
|
8806
|
+
"response": {
|
|
8807
|
+
"status": status,
|
|
8808
|
+
"statusText": status_text,
|
|
8809
|
+
"httpVersion": http_ver,
|
|
8810
|
+
"headers": resp_headers,
|
|
8811
|
+
"cookies": [],
|
|
8812
|
+
"content": {
|
|
8813
|
+
"size": enc_length,
|
|
8814
|
+
"mimeType": mime_type,
|
|
8815
|
+
},
|
|
8816
|
+
"redirectURL": redirect_url,
|
|
8817
|
+
"headersSize": -1,
|
|
8818
|
+
"bodySize": -1,
|
|
8819
|
+
},
|
|
8820
|
+
"cache": {},
|
|
8821
|
+
"timings": entry_timings,
|
|
8822
|
+
"serverIPAddress": resp_rec.get("remoteIPAddress", "") if resp_rec else "",
|
|
8823
|
+
"_resourceType": resp_rec.get("type") or req_sent.get("type", ""),
|
|
8824
|
+
"_sort_key": (derived_wall if derived_wall is not None else 0, started_dt),
|
|
8825
|
+
}
|
|
8826
|
+
|
|
8827
|
+
if missing_wall:
|
|
8828
|
+
entry["_comment"] = "wallTime missing"
|
|
8829
|
+
|
|
8830
|
+
if resp_rec.get("fromDiskCache"):
|
|
8831
|
+
entry["_fromCache"] = "disk"
|
|
8832
|
+
elif resp_rec.get("fromServiceWorker"):
|
|
8833
|
+
entry["_fromCache"] = "serviceworker"
|
|
8834
|
+
|
|
8835
|
+
if load_fail:
|
|
8836
|
+
entry["_error"] = load_fail.get("errorText", "Failed")
|
|
8837
|
+
|
|
8838
|
+
entries.append(entry)
|
|
8839
|
+
|
|
8840
|
+
entries.sort(key=lambda e: e.pop("_sort_key"))
|
|
8841
|
+
|
|
8842
|
+
ts_list = [r["requestWillBeSent"]["timestamp"] for r in records if (r.get("requestWillBeSent") or {}).get("timestamp") is not None]
|
|
8843
|
+
earliest_ts = min(ts_list) if ts_list else None
|
|
8844
|
+
|
|
8845
|
+
if page_events.get("domContentEventFired") is not None and earliest_ts is not None:
|
|
8846
|
+
diff = round((page_events["domContentEventFired"] - earliest_ts) * 1000, 2)
|
|
8847
|
+
on_content_load = diff if diff >= 0 else -1
|
|
8848
|
+
else:
|
|
8849
|
+
on_content_load = -1
|
|
8850
|
+
|
|
8851
|
+
if page_events.get("loadEventFired") is not None and earliest_ts is not None:
|
|
8852
|
+
diff = round((page_events["loadEventFired"] - earliest_ts) * 1000, 2)
|
|
8853
|
+
on_load = diff if diff >= 0 else -1
|
|
8854
|
+
else:
|
|
8855
|
+
on_load = -1
|
|
8856
|
+
|
|
8857
|
+
page_title = page.get("title", "") if isinstance(page, dict) else ""
|
|
8858
|
+
page_started = entries[0]["startedDateTime"] if entries else "1970-01-01T00:00:00.000Z"
|
|
8859
|
+
|
|
8860
|
+
pages = [{
|
|
8861
|
+
"startedDateTime": page_started,
|
|
8862
|
+
"id": "page_1",
|
|
8863
|
+
"title": page_title,
|
|
8864
|
+
"pageTimings": {
|
|
8865
|
+
"onContentLoad": on_content_load,
|
|
8866
|
+
"onLoad": on_load,
|
|
8867
|
+
}
|
|
8868
|
+
}]
|
|
8869
|
+
|
|
8870
|
+
return {
|
|
8871
|
+
"log": {
|
|
8872
|
+
"version": "1.2",
|
|
8873
|
+
"creator": {
|
|
8874
|
+
"name": "cdpilot",
|
|
8875
|
+
"version": str(creator_version or globals().get("__version__", "1.0")),
|
|
8876
|
+
},
|
|
8877
|
+
"pages": pages,
|
|
8878
|
+
"entries": entries,
|
|
8879
|
+
}
|
|
8880
|
+
}
|
|
8881
|
+
|
|
8882
|
+
|
|
8883
|
+
async def cmd_network(url=None, har=None):
|
|
8149
8884
|
ws, page = get_page_ws()
|
|
8150
8885
|
if url is None:
|
|
8151
8886
|
url = page.get("url", "")
|
|
8152
|
-
content, events = await navigate_collect(ws, url, network=True)
|
|
8887
|
+
content, events = await navigate_collect(ws, url, network=True, har=bool(har))
|
|
8888
|
+
if har:
|
|
8889
|
+
har_dict = _har_from_events(events, page, __version__)
|
|
8890
|
+
entry_count = len(har_dict.get("log", {}).get("entries", []))
|
|
8891
|
+
if har == "-":
|
|
8892
|
+
har_json = json.dumps(har_dict, indent=2, ensure_ascii=False)
|
|
8893
|
+
sys.stdout.write(har_json + "\n")
|
|
8894
|
+
sys.stdout.flush()
|
|
8895
|
+
print(f"har: {entry_count} entries -> -", file=sys.stderr)
|
|
8896
|
+
else:
|
|
8897
|
+
_atomic_write_har(har, har_dict)
|
|
8898
|
+
print(f"har: {entry_count} entries -> {har}", file=sys.stderr)
|
|
8899
|
+
return
|
|
8900
|
+
|
|
8153
8901
|
print("=== Network Requests ===")
|
|
8154
8902
|
for req in events["network"]:
|
|
8155
8903
|
s = req.get("status", "?")
|
|
@@ -13137,10 +13885,11 @@ async def _get_element_center(ws_url, selector):
|
|
|
13137
13885
|
"""Return the screen center (x, y) of the element matching selector."""
|
|
13138
13886
|
js = f"""
|
|
13139
13887
|
(function() {{
|
|
13140
|
-
|
|
13141
|
-
|
|
13142
|
-
|
|
13143
|
-
|
|
13888
|
+
{_SHADOW_QUERY_JS}
|
|
13889
|
+
var res = __cdpilot_resolveSelector(document, {json.dumps(selector)}, true);
|
|
13890
|
+
if (!res || !res.el) return null;
|
|
13891
|
+
var r = res.el.getBoundingClientRect();
|
|
13892
|
+
return {{x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2), shadowHost: res.host ? __cdpilot_hostDesc(res.host) : null}};
|
|
13144
13893
|
}})()
|
|
13145
13894
|
"""
|
|
13146
13895
|
res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
@@ -13148,6 +13897,8 @@ async def _get_element_center(ws_url, selector):
|
|
|
13148
13897
|
if not val:
|
|
13149
13898
|
print(f"Error: element '{selector}' not found.", file=sys.stderr)
|
|
13150
13899
|
sys.exit(1)
|
|
13900
|
+
if val.get("shadowHost"):
|
|
13901
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
13151
13902
|
return val["x"], val["y"]
|
|
13152
13903
|
|
|
13153
13904
|
|
|
@@ -14448,19 +15199,30 @@ async def cmd_assert(selector, expected_text=None, check_visible=True):
|
|
|
14448
15199
|
safe_sel = json.dumps(selector)
|
|
14449
15200
|
js = f"""
|
|
14450
15201
|
(function() {{
|
|
15202
|
+
{_SHADOW_QUERY_JS}
|
|
14451
15203
|
var sel = {safe_sel};
|
|
14452
|
-
var
|
|
14453
|
-
if (!el) return 'FAIL: Element not found: ' + sel;
|
|
15204
|
+
var res = __cdpilot_resolveSelector(document, sel, true);
|
|
15205
|
+
if (!res || !res.el) return {{ msg: 'FAIL: Element not found: ' + sel }};
|
|
15206
|
+
var el = res.el;
|
|
14454
15207
|
{visible_check}
|
|
14455
15208
|
{text_check}
|
|
14456
15209
|
var tag = el.tagName.toLowerCase();
|
|
14457
15210
|
var txt = (el.textContent || '').substring(0, 60).trim();
|
|
14458
|
-
|
|
15211
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15212
|
+
return {{
|
|
15213
|
+
msg: 'PASS: ' + tag + (txt ? ' "' + txt + '"' : '') + ' (' + sel + ')',
|
|
15214
|
+
shadowHost: hostDesc
|
|
15215
|
+
}};
|
|
14459
15216
|
}})()
|
|
14460
15217
|
"""
|
|
14461
15218
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
14462
|
-
|
|
14463
|
-
|
|
15219
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15220
|
+
if isinstance(val, dict):
|
|
15221
|
+
if val.get("shadowHost"):
|
|
15222
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
15223
|
+
print(val.get("msg", "ERROR"))
|
|
15224
|
+
else:
|
|
15225
|
+
print(val or "ERROR")
|
|
14464
15226
|
|
|
14465
15227
|
|
|
14466
15228
|
async def cmd_wait_for(selector, timeout_ms=5000):
|
|
@@ -14469,22 +15231,56 @@ async def cmd_wait_for(selector, timeout_ms=5000):
|
|
|
14469
15231
|
safe_sel = json.dumps(selector)
|
|
14470
15232
|
js = f"""
|
|
14471
15233
|
(function() {{
|
|
15234
|
+
{_SHADOW_QUERY_JS}
|
|
14472
15235
|
return new Promise(function(resolve) {{
|
|
14473
15236
|
var sel = {safe_sel};
|
|
14474
|
-
var
|
|
14475
|
-
if (
|
|
14476
|
-
|
|
14477
|
-
|
|
14478
|
-
|
|
14479
|
-
|
|
14480
|
-
|
|
14481
|
-
|
|
15237
|
+
var res = __cdpilot_resolveSelector(document, sel, true);
|
|
15238
|
+
if (res && res.el) {{
|
|
15239
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15240
|
+
return resolve({{
|
|
15241
|
+
out: 'FOUND: ' + res.el.tagName + ' "' + (res.el.textContent || '').substring(0, 60).trim() + '"',
|
|
15242
|
+
shadowHost: hostDesc
|
|
15243
|
+
}});
|
|
15244
|
+
}}
|
|
15245
|
+
var obs = null;
|
|
15246
|
+
var timer = null;
|
|
15247
|
+
var timerTimeout = null;
|
|
15248
|
+
function cleanup() {{
|
|
15249
|
+
if (obs) {{ obs.disconnect(); obs = null; }}
|
|
15250
|
+
if (timer) {{ clearInterval(timer); timer = null; }}
|
|
15251
|
+
if (timerTimeout) {{ clearTimeout(timerTimeout); timerTimeout = null; }}
|
|
15252
|
+
}}
|
|
15253
|
+
function check() {{
|
|
15254
|
+
var found = __cdpilot_resolveSelector(document, sel, true);
|
|
15255
|
+
if (found && found.el) {{
|
|
15256
|
+
cleanup();
|
|
15257
|
+
var hostDesc = found.host ? __cdpilot_hostDesc(found.host) : null;
|
|
15258
|
+
resolve({{
|
|
15259
|
+
out: 'FOUND: ' + found.el.tagName + ' "' + (found.el.textContent || '').substring(0, 60).trim() + '"',
|
|
15260
|
+
shadowHost: hostDesc
|
|
15261
|
+
}});
|
|
15262
|
+
return true;
|
|
15263
|
+
}}
|
|
15264
|
+
return false;
|
|
15265
|
+
}}
|
|
15266
|
+
obs = new MutationObserver(check);
|
|
15267
|
+
obs.observe(document.documentElement || document.body, {{childList: true, subtree: true}});
|
|
15268
|
+
timer = setInterval(check, 200);
|
|
15269
|
+
timerTimeout = setTimeout(function() {{
|
|
15270
|
+
cleanup();
|
|
15271
|
+
resolve({{ out: 'TIMEOUT: ' + sel + ' not found after {timeout_ms}ms' }});
|
|
15272
|
+
}}, {timeout_ms});
|
|
14482
15273
|
}});
|
|
14483
15274
|
}})()
|
|
14484
15275
|
"""
|
|
14485
15276
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})], timeout=max(15, timeout_ms // 1000 + 5))
|
|
14486
|
-
|
|
14487
|
-
|
|
15277
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15278
|
+
if isinstance(val, dict):
|
|
15279
|
+
if val.get("shadowHost"):
|
|
15280
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
15281
|
+
print(val.get("out", "ERROR"))
|
|
15282
|
+
else:
|
|
15283
|
+
print(val or "ERROR")
|
|
14488
15284
|
|
|
14489
15285
|
|
|
14490
15286
|
async def cmd_wait_for_text(text, timeout_ms=5000):
|
|
@@ -14561,14 +15357,26 @@ async def cmd_check(checks_json=None):
|
|
|
14561
15357
|
}}"""
|
|
14562
15358
|
js = f"""
|
|
14563
15359
|
(function() {{
|
|
14564
|
-
|
|
14565
|
-
|
|
15360
|
+
{_SHADOW_QUERY_JS}
|
|
15361
|
+
var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
15362
|
+
if (!res || !res.el) return {{ msg: 'FAIL: Not found: ' + {safe_sel} }};
|
|
15363
|
+
var el = res.el;
|
|
14566
15364
|
{text_check}
|
|
14567
|
-
|
|
15365
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15366
|
+
return {{
|
|
15367
|
+
msg: 'PASS: ' + el.tagName + ' (' + {safe_sel} + ')',
|
|
15368
|
+
shadowHost: hostDesc
|
|
15369
|
+
}};
|
|
14568
15370
|
}})()
|
|
14569
15371
|
"""
|
|
14570
15372
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
14571
|
-
|
|
15373
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15374
|
+
if isinstance(val, dict):
|
|
15375
|
+
if val.get("shadowHost"):
|
|
15376
|
+
_maybe_print_shadow_note(sel, val["shadowHost"])
|
|
15377
|
+
result = val.get("msg", "ERROR")
|
|
15378
|
+
else:
|
|
15379
|
+
result = val or "ERROR"
|
|
14572
15380
|
if result.startswith("PASS"):
|
|
14573
15381
|
passed += 1
|
|
14574
15382
|
else:
|
|
@@ -14633,16 +15441,24 @@ async def cmd_assert_value(selector, expected_value):
|
|
|
14633
15441
|
safe_sel = json.dumps(selector)
|
|
14634
15442
|
safe_expected = json.dumps(expected_value)
|
|
14635
15443
|
js = f"""(function() {{
|
|
14636
|
-
|
|
14637
|
-
|
|
15444
|
+
{_SHADOW_QUERY_JS}
|
|
15445
|
+
var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
15446
|
+
if (!res || !res.el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
15447
|
+
var el = res.el;
|
|
14638
15448
|
var val = el.value;
|
|
14639
15449
|
var expected = {safe_expected};
|
|
14640
|
-
|
|
14641
|
-
|
|
15450
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15451
|
+
var msg = val === expected ? 'PASS: Value matches \"' + expected + '\"' : 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
|
|
15452
|
+
return {{ msg: msg, shadowHost: hostDesc }};
|
|
14642
15453
|
}})()"""
|
|
14643
15454
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
14644
|
-
|
|
14645
|
-
|
|
15455
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15456
|
+
if isinstance(val, dict):
|
|
15457
|
+
if val.get("shadowHost"):
|
|
15458
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
15459
|
+
print(val.get("msg", "ERROR"))
|
|
15460
|
+
else:
|
|
15461
|
+
print(val or "ERROR")
|
|
14646
15462
|
|
|
14647
15463
|
|
|
14648
15464
|
async def cmd_assert_attr(selector, attr, expected):
|
|
@@ -14652,16 +15468,24 @@ async def cmd_assert_attr(selector, attr, expected):
|
|
|
14652
15468
|
safe_attr = json.dumps(attr)
|
|
14653
15469
|
safe_expected = json.dumps(expected)
|
|
14654
15470
|
js = f"""(function() {{
|
|
14655
|
-
|
|
14656
|
-
|
|
15471
|
+
{_SHADOW_QUERY_JS}
|
|
15472
|
+
var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
15473
|
+
if (!res || !res.el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
15474
|
+
var el = res.el;
|
|
14657
15475
|
var val = el.getAttribute({safe_attr}) || '';
|
|
14658
15476
|
var expected = {safe_expected};
|
|
14659
|
-
|
|
14660
|
-
|
|
15477
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15478
|
+
var msg = val.indexOf(expected) !== -1 ? 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"' : 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
|
|
15479
|
+
return {{ msg: msg, shadowHost: hostDesc }};
|
|
14661
15480
|
}})()"""
|
|
14662
15481
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
14663
|
-
|
|
14664
|
-
|
|
15482
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15483
|
+
if isinstance(val, dict):
|
|
15484
|
+
if val.get("shadowHost"):
|
|
15485
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
15486
|
+
print(val.get("msg", "ERROR"))
|
|
15487
|
+
else:
|
|
15488
|
+
print(val or "ERROR")
|
|
14665
15489
|
|
|
14666
15490
|
|
|
14667
15491
|
async def cmd_assert_visible(selector, should_be_visible=True):
|
|
@@ -14671,8 +15495,10 @@ async def cmd_assert_visible(selector, should_be_visible=True):
|
|
|
14671
15495
|
expect_label = "visible" if should_be_visible else "hidden"
|
|
14672
15496
|
opposite_label = "hidden" if should_be_visible else "visible"
|
|
14673
15497
|
js = f"""(function() {{
|
|
14674
|
-
|
|
14675
|
-
|
|
15498
|
+
{_SHADOW_QUERY_JS}
|
|
15499
|
+
var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
|
|
15500
|
+
if (!res || !res.el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
15501
|
+
var el = res.el;
|
|
14676
15502
|
var style = window.getComputedStyle(el);
|
|
14677
15503
|
var rect = el.getBoundingClientRect();
|
|
14678
15504
|
var isVisible = (
|
|
@@ -14682,12 +15508,18 @@ async def cmd_assert_visible(selector, should_be_visible=True):
|
|
|
14682
15508
|
(rect.width > 0 || rect.height > 0)
|
|
14683
15509
|
);
|
|
14684
15510
|
var expectVisible = {str(should_be_visible).lower()};
|
|
14685
|
-
|
|
14686
|
-
|
|
15511
|
+
var hostDesc = res.host ? __cdpilot_hostDesc(res.host) : null;
|
|
15512
|
+
var msg = isVisible === expectVisible ? 'PASS: ' + {safe_sel} + ' is {expect_label}' : 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
|
|
15513
|
+
return {{ msg: msg, shadowHost: hostDesc }};
|
|
14687
15514
|
}})()"""
|
|
14688
15515
|
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
14689
|
-
|
|
14690
|
-
|
|
15516
|
+
val = r.get(1, {}).get("result", {}).get("value")
|
|
15517
|
+
if isinstance(val, dict):
|
|
15518
|
+
if val.get("shadowHost"):
|
|
15519
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
15520
|
+
print(val.get("msg", "ERROR"))
|
|
15521
|
+
else:
|
|
15522
|
+
print(val or "ERROR")
|
|
14691
15523
|
|
|
14692
15524
|
|
|
14693
15525
|
async def cmd_screenshot_diff(path1, path2):
|
|
@@ -16167,25 +16999,36 @@ async def cmd_scroll_to(selector, entropy=None):
|
|
|
16167
16999
|
if entropy:
|
|
16168
17000
|
# Get element position then humanize scroll
|
|
16169
17001
|
js_pos = f"""(function(){{
|
|
16170
|
-
|
|
16171
|
-
|
|
16172
|
-
|
|
16173
|
-
|
|
17002
|
+
{_SHADOW_QUERY_JS}
|
|
17003
|
+
var res = __cdpilot_resolveSelector(document, {json.dumps(selector)}, true);
|
|
17004
|
+
if(!res || !res.el) return null;
|
|
17005
|
+
var r=res.el.getBoundingClientRect();
|
|
17006
|
+
return {{top: r.top, height: r.height, shadowHost: res.host ? __cdpilot_hostDesc(res.host) : null}};
|
|
16174
17007
|
}})()"""
|
|
16175
17008
|
res_pos = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js_pos, "returnByValue": True})])
|
|
16176
17009
|
val = res_pos.get(1, {}).get("result", {}).get("value")
|
|
16177
17010
|
if not val:
|
|
16178
17011
|
print(f"Error: element '{selector}' not found.", file=sys.stderr)
|
|
16179
17012
|
sys.exit(1)
|
|
17013
|
+
if val.get("shadowHost"):
|
|
17014
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
16180
17015
|
delta = int(val.get("top", 0))
|
|
16181
17016
|
if delta != 0:
|
|
16182
17017
|
await _humanize_scroll(ws_url, delta)
|
|
16183
17018
|
print(f"Scrolled to (entropy): {selector}")
|
|
16184
17019
|
else:
|
|
16185
|
-
js = f"(function(){{
|
|
17020
|
+
js = f"""(function(){{
|
|
17021
|
+
{_SHADOW_QUERY_JS}
|
|
17022
|
+
var res = __cdpilot_resolveSelector(document, {json.dumps(selector)}, true);
|
|
17023
|
+
if(!res || !res.el) return null;
|
|
17024
|
+
res.el.scrollIntoView({{behavior:'instant',block:'center'}});
|
|
17025
|
+
return {{ ok: true, shadowHost: res.host ? __cdpilot_hostDesc(res.host) : null }};
|
|
17026
|
+
}})()"""
|
|
16186
17027
|
res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
16187
|
-
|
|
16188
|
-
if ok:
|
|
17028
|
+
val = res.get(1, {}).get("result", {}).get("value")
|
|
17029
|
+
if val and val.get("ok"):
|
|
17030
|
+
if val.get("shadowHost"):
|
|
17031
|
+
_maybe_print_shadow_note(selector, val["shadowHost"])
|
|
16189
17032
|
print(f"Scrolled to: {selector}")
|
|
16190
17033
|
else:
|
|
16191
17034
|
print(f"Error: element '{selector}' not found.", file=sys.stderr)
|
|
@@ -16657,30 +17500,67 @@ WEBMCP_LIST_JS = r"""(async () => {
|
|
|
16657
17500
|
url: location.href
|
|
16658
17501
|
};
|
|
16659
17502
|
if (!facts.api) return { facts };
|
|
16660
|
-
|
|
16661
|
-
|
|
16662
|
-
|
|
16663
|
-
|
|
16664
|
-
|
|
16665
|
-
|
|
16666
|
-
|
|
16667
|
-
|
|
16668
|
-
|
|
16669
|
-
|
|
16670
|
-
|
|
17503
|
+
// Every readable document in the frame tree is asked for its own tools. The
|
|
17504
|
+
// top document's getTools() is meant to include its same-origin frames' tools,
|
|
17505
|
+
// but Chromium 156 leaves out a frame that registered after the parent first
|
|
17506
|
+
// touched document.modelContext (CI, 08.11; 153 never answered some frame
|
|
17507
|
+
// registrations at all), so the lists are merged: one entry per (owning
|
|
17508
|
+
// document, name), the owner being RegisteredTool.window. A cross-origin
|
|
17509
|
+
// frame's document is unreadable and stays out, as the Permissions Policy
|
|
17510
|
+
// 'tools' intends.
|
|
17511
|
+
const docs = [window];
|
|
17512
|
+
const walk = (w) => {
|
|
17513
|
+
let n = 0;
|
|
17514
|
+
try { n = w.frames.length; } catch (e) { return; }
|
|
17515
|
+
for (let i = 0; i < n; i++) {
|
|
17516
|
+
const f = w.frames[i];
|
|
17517
|
+
try { if (!f.document) continue; } catch (e) { continue; }
|
|
17518
|
+
docs.push(f);
|
|
17519
|
+
walk(f);
|
|
16671
17520
|
}
|
|
16672
|
-
|
|
17521
|
+
};
|
|
17522
|
+
walk(window);
|
|
17523
|
+
const seen = new Map();
|
|
17524
|
+
const out = [];
|
|
17525
|
+
for (const w of docs) {
|
|
17526
|
+
let mc = null;
|
|
17527
|
+
try { mc = w.document.modelContext; } catch (e) { continue; }
|
|
17528
|
+
if (!mc || typeof mc.getTools !== 'function') continue;
|
|
17529
|
+
let tools;
|
|
16673
17530
|
try {
|
|
16674
|
-
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16679
|
-
|
|
16680
|
-
|
|
16681
|
-
|
|
16682
|
-
|
|
17531
|
+
tools = await mc.getTools();
|
|
17532
|
+
} catch (e) {
|
|
17533
|
+
if (w === window) {
|
|
17534
|
+
return { facts, error: { name: String(e && e.name), message: String(e && e.message) } };
|
|
17535
|
+
}
|
|
17536
|
+
continue;
|
|
17537
|
+
}
|
|
17538
|
+
for (const t of tools) {
|
|
17539
|
+
let owner = w;
|
|
17540
|
+
try { if (t.window) owner = t.window; } catch (e) { /* keep the document asked */ }
|
|
17541
|
+
let names = seen.get(owner);
|
|
17542
|
+
if (!names) { names = new Set(); seen.set(owner, names); }
|
|
17543
|
+
if (names.has(t.name)) continue;
|
|
17544
|
+
names.add(t.name);
|
|
17545
|
+
let schema = t.inputSchema;
|
|
17546
|
+
if (typeof schema === 'string') {
|
|
17547
|
+
try { schema = JSON.parse(schema); } catch (e) { /* keep the string */ }
|
|
17548
|
+
}
|
|
17549
|
+
let frame = null, form = null;
|
|
17550
|
+
try {
|
|
17551
|
+
if (owner !== window) frame = owner.location.href;
|
|
17552
|
+
form = owner.document.querySelector('form[toolname="' + CSS.escape(t.name) + '"]');
|
|
17553
|
+
} catch (e) { /* not readable: leave frame/form unknown */ }
|
|
17554
|
+
const rec = { name: t.name, title: t.title || '', description: t.description,
|
|
17555
|
+
inputSchema: schema === undefined ? null : schema,
|
|
17556
|
+
origin: t.origin, frame: frame, declarative: !!form };
|
|
17557
|
+
if (form) rec.autosubmit = form.hasAttribute('toolautosubmit');
|
|
17558
|
+
if (t.annotations) rec.annotations = t.annotations;
|
|
17559
|
+
out.push(rec);
|
|
17560
|
+
}
|
|
16683
17561
|
}
|
|
17562
|
+
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1
|
|
17563
|
+
: (a.frame || '') < (b.frame || '') ? -1 : (a.frame || '') > (b.frame || '') ? 1 : 0));
|
|
16684
17564
|
return { facts, tools: out };
|
|
16685
17565
|
})()"""
|
|
16686
17566
|
|
|
@@ -16688,15 +17568,42 @@ WEBMCP_LIST_JS = r"""(async () => {
|
|
|
16688
17568
|
# fresh getTools(), then executeTool(tool, input, {signal}); the signal aborts
|
|
16689
17569
|
# after __MS__ ms. Only local bindings: nothing is left on the page.
|
|
16690
17570
|
WEBMCP_CALL_JS = r"""(async () => {
|
|
16691
|
-
const
|
|
16692
|
-
if (!
|
|
17571
|
+
const top = document.modelContext;
|
|
17572
|
+
if (!top || typeof top.executeTool !== 'function') return { status: 'unavailable' };
|
|
16693
17573
|
const name = __NAME__, frame = __FRAME__, input = __INPUT__;
|
|
16694
|
-
|
|
16695
|
-
|
|
16696
|
-
|
|
16697
|
-
|
|
16698
|
-
|
|
16699
|
-
|
|
17574
|
+
// Looked up again in every readable document (as WEBMCP_LIST_JS lists them)
|
|
17575
|
+
// and run on the modelContext of the document that registered it; the top
|
|
17576
|
+
// document's copy of a frame tool is only the fallback.
|
|
17577
|
+
const docs = [window];
|
|
17578
|
+
const walk = (w) => {
|
|
17579
|
+
let n = 0;
|
|
17580
|
+
try { n = w.frames.length; } catch (e) { return; }
|
|
17581
|
+
for (let i = 0; i < n; i++) {
|
|
17582
|
+
const f = w.frames[i];
|
|
17583
|
+
try { if (!f.document) continue; } catch (e) { continue; }
|
|
17584
|
+
docs.push(f);
|
|
17585
|
+
walk(f);
|
|
17586
|
+
}
|
|
17587
|
+
};
|
|
17588
|
+
walk(window);
|
|
17589
|
+
let mc = null, tool = null;
|
|
17590
|
+
for (const w of docs) {
|
|
17591
|
+
let c = null;
|
|
17592
|
+
try { c = w.document.modelContext; } catch (e) { continue; }
|
|
17593
|
+
if (!c || typeof c.getTools !== 'function' || typeof c.executeTool !== 'function') continue;
|
|
17594
|
+
let tools;
|
|
17595
|
+
try { tools = await c.getTools(); } catch (e) { continue; }
|
|
17596
|
+
const hit = tools.find((t) => {
|
|
17597
|
+
if (t.name !== name) return false;
|
|
17598
|
+
let owner = w;
|
|
17599
|
+
try { if (t.window) owner = t.window; } catch (e) { /* keep the document asked */ }
|
|
17600
|
+
try { return frame === null ? owner === window : owner.location.href === frame; }
|
|
17601
|
+
catch (e) { return false; }
|
|
17602
|
+
});
|
|
17603
|
+
if (!hit) continue;
|
|
17604
|
+
mc = c; tool = hit;
|
|
17605
|
+
if (frame === null || w !== window) break;
|
|
17606
|
+
}
|
|
16700
17607
|
if (!tool) return { status: 'gone' };
|
|
16701
17608
|
const ac = new AbortController();
|
|
16702
17609
|
const timer = setTimeout(() => ac.abort(new DOMException(
|
|
@@ -17052,12 +17959,59 @@ class MCPServer:
|
|
|
17052
17959
|
"inputSchema": {"type": "object", "properties": {"name": {"type": "string", "description": "Tool name, as returned by browser_site_tools"}, "arguments": {"type": "object", "description": "Tool input: a JSON object matching the tool's inputSchema"}, "frame": {"type": "string", "description": "Only when several frames register the same name: part of the frame URL"}}, "required": ["name"]}},
|
|
17053
17960
|
]
|
|
17054
17961
|
|
|
17055
|
-
|
|
17962
|
+
# Tool profiles (`mcp --tools core|all|a,b,c`, env CDPILOT_MCP_TOOLS): a smaller
|
|
17963
|
+
# tools/list cuts the schema tokens an MCP client sends with every request.
|
|
17964
|
+
CORE_TOOLS = (
|
|
17965
|
+
"browser_navigate", "browser_click", "browser_fill", "browser_content",
|
|
17966
|
+
"browser_screenshot", "browser_eval", "browser_wait_for", "browser_assert",
|
|
17967
|
+
"browser_tabs", "browser_connect",
|
|
17968
|
+
)
|
|
17969
|
+
DISCOVERY_TOOL = {
|
|
17970
|
+
"name": "browser_tools",
|
|
17971
|
+
"description": "Tools outside this server's profile. action=list: name and "
|
|
17972
|
+
"description of each tool that is not enabled. action=enable "
|
|
17973
|
+
"with names: add those tools to tools/list "
|
|
17974
|
+
"(notifications/tools/list_changed follows).",
|
|
17975
|
+
"inputSchema": {"type": "object", "properties": {
|
|
17976
|
+
"action": {"type": "string", "enum": ["list", "enable"]},
|
|
17977
|
+
"names": {"type": "array", "items": {"type": "string"},
|
|
17978
|
+
"description": "Tool names to enable (action=enable)"}},
|
|
17979
|
+
"required": ["action"]},
|
|
17980
|
+
}
|
|
17981
|
+
NOT_ENABLED = "tool not enabled; call browser_tools enable"
|
|
17982
|
+
|
|
17983
|
+
def __init__(self, tools_spec=None):
|
|
17056
17984
|
self.tools = self._register_tools()
|
|
17985
|
+
spec = (tools_spec or os.environ.get("CDPILOT_MCP_TOOLS") or "all").strip()
|
|
17986
|
+
self.profiled = spec != "all"
|
|
17987
|
+
self.enabled = set(self.CORE_TOOLS if spec == "core" else spec.split(","))
|
|
17988
|
+
self.pending_notifications = []
|
|
17057
17989
|
|
|
17058
17990
|
def listed_tools(self):
|
|
17059
|
-
"""tools/list: the
|
|
17060
|
-
|
|
17991
|
+
"""tools/list: the profile's tools (all by default), plus the WebMCP ones
|
|
17992
|
+
while the mode is on."""
|
|
17993
|
+
webmcp = self.WEBMCP_TOOLS if get_webmcp_config() else []
|
|
17994
|
+
if not self.profiled:
|
|
17995
|
+
return self.tools + webmcp
|
|
17996
|
+
base = [t for t in self.tools if t["name"] in self.enabled]
|
|
17997
|
+
return base + [self.DISCOVERY_TOOL] + webmcp
|
|
17998
|
+
|
|
17999
|
+
def _browser_tools(self, req_id, args):
|
|
18000
|
+
"""browser_tools: list the tools the profile left out, or enable some."""
|
|
18001
|
+
off = [t for t in self.tools if t["name"] not in self.enabled]
|
|
18002
|
+
if args.get("action") == "enable":
|
|
18003
|
+
names = args.get("names") or []
|
|
18004
|
+
known = {t["name"] for t in off}
|
|
18005
|
+
self.enabled.update(n for n in names if n in known)
|
|
18006
|
+
unknown = [n for n in names if n not in known and n not in self.enabled]
|
|
18007
|
+
text = "enabled: " + ", ".join(n for n in names if n in self.enabled)
|
|
18008
|
+
if unknown:
|
|
18009
|
+
text += "\nunknown tools: " + ", ".join(unknown)
|
|
18010
|
+
self.pending_notifications.append("notifications/tools/list_changed")
|
|
18011
|
+
else:
|
|
18012
|
+
text = "\n".join(f"{t['name']}: {t['description']}" for t in off) or "none"
|
|
18013
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {
|
|
18014
|
+
"content": [{"type": "text", "text": text}], "isError": False}}
|
|
17061
18015
|
|
|
17062
18016
|
def _register_tools(self):
|
|
17063
18017
|
return [
|
|
@@ -17066,9 +18020,9 @@ class MCPServer:
|
|
|
17066
18020
|
{"name": "browser_screenshot", "description": "Capture a screenshot of the current browser viewport. Supports element-level cropping to save tokens (capture only a specific element instead of full page). Use JPEG format with quality parameter for smaller files (~5x smaller than PNG). Prefer element cropping + JPEG for token-efficient AI workflows.",
|
|
17067
18021
|
"inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output file path (e.g. /tmp/screenshot.png). Extension determines format: .png, .jpg, .webp", "default": "screenshot.png"}, "element": {"type": "string", "description": "CSS selector to capture only that element (crops to bounding box). Saves ~3-7x tokens vs full page."}, "quality": {"type": "number", "description": "JPEG quality 1-100 (only for .jpg files). Lower = smaller file = fewer tokens. Default: 80"}}}},
|
|
17068
18022
|
{"name": "browser_click", "description": "Click an element on the page identified by CSS selector. Auto-waits up to 5 seconds for the element to appear (MutationObserver). Scrolls the element into view before clicking. Returns the tag name and text of the clicked element. When a real mouse click (inside a frame, or with entropy) was pressed but the page changed under the press so the release missed the target, nothing is clicked again by script and the first line is JSON {\"clicked\": false, \"reason\": \"moved\"|\"gone\"|\"unknown\"} (isError stays false): the page may already have reacted to the press, so check it before retrying.",
|
|
17069
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to click (e.g. '#submit-btn', '.nav a', 'button[type=submit]'). Inside an iframe
|
|
18023
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to click (e.g. '#submit-btn', '.nav a', 'button[type=submit]'). Inside an iframe or shadow root: 'iframe#card >>> button#pay' or 'my-card >>> button.inner'"}}, "required": ["selector"]}},
|
|
17070
18024
|
{"name": "browser_type", "description": "Type text into an input or textarea element. Uses React-compatible value setting (native setter + input/change events). Auto-waits up to 5 seconds for the element. Use browser_fill as an alias.",
|
|
17071
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element. Inside an iframe
|
|
18025
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element. Inside an iframe or shadow root: 'iframe#card >>> input[name=cardnumber]' or 'my-card >>> input.inner'"}, "text": {"type": "string", "description": "Text value to type into the element"}}, "required": ["selector", "text"]}},
|
|
17072
18026
|
{"name": "browser_content", "description": "Get the visible text content of the current page (document.body.innerText). Returns up to 10000 characters. Use this to read page content without HTML markup. Prefer browser_a11y for structured navigation data.",
|
|
17073
18027
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
17074
18028
|
{"name": "browser_html", "description": "Get the full HTML source of the current page (document.documentElement.outerHTML). Returns up to 50000 characters. Use sparingly — prefer browser_content or browser_a11y for most tasks as they return smaller, more useful data.",
|
|
@@ -17086,7 +18040,7 @@ class MCPServer:
|
|
|
17086
18040
|
{"name": "browser_a11y", "description": "Get the accessibility tree of the current page as structured data. Returns interactive elements with @N references that can be used with browser_click. This is the PREFERRED way to understand page structure — uses ~500 tokens vs ~250K for screenshots. Use 'summary' mode for a compact view.",
|
|
17087
18041
|
"inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "description": "Output detail level: 'full' for complete tree, 'summary' for interactive elements only", "default": "full"}}}},
|
|
17088
18042
|
{"name": "browser_fill", "description": "Set an input element's value using React-compatible method (native descriptor setter + input/change events). Auto-waits up to 5 seconds for the element. Works with regular inputs, textareas, and React controlled components.",
|
|
17089
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element. Inside an iframe
|
|
18043
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element. Inside an iframe or shadow root: 'iframe#card >>> input[name=cardnumber]' or 'my-card >>> input.inner'"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
|
|
17090
18044
|
{"name": "browser_launch", "description": "Launch an isolated browser instance with Chrome DevTools Protocol enabled. Uses existing Brave/Chrome/Chromium installation — no browser download needed. Creates an isolated profile directory so your personal browser data is never touched.",
|
|
17091
18045
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
17092
18046
|
{"name": "browser_close", "description": "Smart close: shut down every tab cdpilot opened during this automation, then close the whole browser application gracefully ONLY if no user-opened tabs remain. If the user has their own tabs open in the same browser, those are left untouched and the browser stays open. Use this to clean up after automation. Set force=true to close the browser even when user tabs remain; set keep_browser=true to only close cdpilot's tabs and never quit the browser.",
|
|
@@ -17176,7 +18130,7 @@ class MCPServer:
|
|
|
17176
18130
|
if method == "initialize":
|
|
17177
18131
|
return {"jsonrpc": "2.0", "id": req_id, "result": {
|
|
17178
18132
|
"protocolVersion": "2024-11-05",
|
|
17179
|
-
"capabilities": {"tools": {}},
|
|
18133
|
+
"capabilities": {"tools": {"listChanged": True} if self.profiled else {}},
|
|
17180
18134
|
"serverInfo": {"name": "cdpilot", "version": __version__}
|
|
17181
18135
|
}}
|
|
17182
18136
|
elif method == "notifications/initialized":
|
|
@@ -17184,7 +18138,14 @@ class MCPServer:
|
|
|
17184
18138
|
elif method == "tools/list":
|
|
17185
18139
|
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": self.listed_tools()}}
|
|
17186
18140
|
elif method == "tools/call":
|
|
17187
|
-
|
|
18141
|
+
name, args = params.get("name", ""), params.get("arguments") or {}
|
|
18142
|
+
if self.profiled and name == "browser_tools":
|
|
18143
|
+
return self._browser_tools(req_id, args)
|
|
18144
|
+
if self.profiled and name not in self.enabled and any(
|
|
18145
|
+
t["name"] == name for t in self.tools):
|
|
18146
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {
|
|
18147
|
+
"content": [{"type": "text", "text": self.NOT_ENABLED}], "isError": True}}
|
|
18148
|
+
return self._execute_tool(req_id, name, args)
|
|
17188
18149
|
elif method == "ping":
|
|
17189
18150
|
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
17190
18151
|
else:
|
|
@@ -17212,7 +18173,9 @@ class MCPServer:
|
|
|
17212
18173
|
"browser_eval_batch": lambda a: ["eval-batch", json.dumps(a.get("expressions", []))],
|
|
17213
18174
|
"browser_tabs": lambda a: ["tabs"],
|
|
17214
18175
|
"browser_console": lambda a: ["console"] + ([a["url"]] if a.get("url") else []),
|
|
17215
|
-
"
|
|
18176
|
+
# "--" ends option parsing: a url shaped like "--har=/path" (an LLM client
|
|
18177
|
+
# fed by a page) must never choose the HAR output file.
|
|
18178
|
+
"browser_network": lambda a: ["network"] + (["--", a["url"]] if a.get("url") else []),
|
|
17216
18179
|
"browser_a11y": lambda a: ["a11y"] + ([a["mode"]] if a.get("mode") and a["mode"] != "full" else []),
|
|
17217
18180
|
"browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
|
|
17218
18181
|
"browser_launch": lambda a: ["launch"],
|
|
@@ -17321,7 +18284,11 @@ class MCPServer:
|
|
|
17321
18284
|
response = self._handle_request(request)
|
|
17322
18285
|
if response is not None:
|
|
17323
18286
|
sys.stdout.write(json_mod.dumps(response) + "\n")
|
|
17324
|
-
|
|
18287
|
+
for method in self.pending_notifications:
|
|
18288
|
+
sys.stdout.write(json_mod.dumps(
|
|
18289
|
+
{"jsonrpc": "2.0", "method": method}) + "\n")
|
|
18290
|
+
self.pending_notifications = []
|
|
18291
|
+
sys.stdout.flush()
|
|
17325
18292
|
except json_mod.JSONDecodeError as e:
|
|
17326
18293
|
sys.stdout.write(json_mod.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {str(e)}"}}) + "\n")
|
|
17327
18294
|
sys.stdout.flush()
|
|
@@ -19029,7 +19996,10 @@ SLOG_JS_LITERAL_CHARS = 40
|
|
|
19029
19996
|
SLOG_MAX_FILES = 20
|
|
19030
19997
|
# Long-running servers (their tool calls are logged), the reader itself
|
|
19031
19998
|
# (reading the log must not grow it) and help.
|
|
19032
|
-
SLOG_SKIP_CMDS = frozenset({'mcp', 'serve', 'log', 'help', '--help', '-h', 'bot-auth'})
|
|
19999
|
+
SLOG_SKIP_CMDS = frozenset({'mcp', 'serve', 'log', 'help', '--help', '-h', 'bot-auth', 'record', 'replay'})
|
|
20000
|
+
RECORD_SKIP_CMDS = frozenset({'record', 'replay', 'log', 'help', '--help', '-h',
|
|
20001
|
+
'version', '--version', '-v', 'status', 'health',
|
|
20002
|
+
'mcp', 'serve', 'bot-auth', 'setup', 'connect', 'disconnect'})
|
|
19033
20003
|
SLOG_CMD_ALIASES = {'open': 'go', '--version': 'version', '-v': 'version'}
|
|
19034
20004
|
SLOG_VALUE_CMDS = frozenset({'fill', 'type'}) # <selector> <value...>, flags anywhere
|
|
19035
20005
|
SLOG_VALUE_CMD_FLAGS = frozenset({'--no-heal'}) # their bare flags; --name=value is kept
|
|
@@ -19311,6 +20281,105 @@ def _slog_redact_tool_kv(kv, secrets):
|
|
|
19311
20281
|
return _slog_clip_mark(_slog_scrub_text(kv, secrets, echo=False), SLOG_ARG_CHARS)
|
|
19312
20282
|
|
|
19313
20283
|
|
|
20284
|
+
def _slog_secret_hits(cmd, args):
|
|
20285
|
+
"""Find positions and kinds of secrets that session logging would redact.
|
|
20286
|
+
|
|
20287
|
+
Returns a list of (index, kind, key) tuples where kind is in:
|
|
20288
|
+
{"flag_value", "word_value", "kv_value", "tokenish", "input_value"}
|
|
20289
|
+
and key is the flag / word / kv key name (or None).
|
|
20290
|
+
"""
|
|
20291
|
+
args = ["" if a is None else str(a) for a in (args or [])]
|
|
20292
|
+
hits = []
|
|
20293
|
+
done = set()
|
|
20294
|
+
|
|
20295
|
+
if cmd in SLOG_JS_CMDS or (cmd == "frame" and args[:1] == ["eval"]) or cmd == "eval-batch":
|
|
20296
|
+
return []
|
|
20297
|
+
|
|
20298
|
+
if cmd in SLOG_VALUE_CMDS:
|
|
20299
|
+
val_idxs = [i for i, a in enumerate(args) if not a.startswith("--")][1:]
|
|
20300
|
+
for idx in val_idxs:
|
|
20301
|
+
hits.append((idx, "input_value", None))
|
|
20302
|
+
done.add(idx)
|
|
20303
|
+
for i, a in enumerate(args):
|
|
20304
|
+
if a.startswith("--") and "=" not in a and a not in SLOG_VALUE_CMD_FLAGS:
|
|
20305
|
+
hits.append((i, "input_value", None))
|
|
20306
|
+
done.add(i)
|
|
20307
|
+
elif cmd in SLOG_LABEL_VALUE_CMDS or cmd == "assert-value":
|
|
20308
|
+
for idx in range(1, len(args)):
|
|
20309
|
+
hits.append((idx, "input_value", None))
|
|
20310
|
+
done.add(idx)
|
|
20311
|
+
elif cmd == "dialog" and args[:1] == ["prompt"]:
|
|
20312
|
+
for idx in range(1, len(args)):
|
|
20313
|
+
hits.append((idx, "input_value", None))
|
|
20314
|
+
done.add(idx)
|
|
20315
|
+
elif cmd == "assert-attr" and len(args) >= 3 and args[1].lower() == "value":
|
|
20316
|
+
for idx in range(2, len(args)):
|
|
20317
|
+
hits.append((idx, "input_value", None))
|
|
20318
|
+
done.add(idx)
|
|
20319
|
+
elif cmd == "tools" and args[:1] == ["call"] and len(args) >= 2:
|
|
20320
|
+
done.update({0, 1})
|
|
20321
|
+
i = 2
|
|
20322
|
+
while i < len(args):
|
|
20323
|
+
a = args[i]
|
|
20324
|
+
if a in ("--arg", "--frame") and i + 1 < len(args):
|
|
20325
|
+
done.add(i)
|
|
20326
|
+
if a == "--arg":
|
|
20327
|
+
done.add(i + 1)
|
|
20328
|
+
k, eq, v = args[i + 1].partition("=")
|
|
20329
|
+
if eq and v and _SLOG_SECRET_NAME_RE.search(k):
|
|
20330
|
+
hits.append((i + 1, "kv_value", k))
|
|
20331
|
+
i += 2
|
|
20332
|
+
continue
|
|
20333
|
+
if a.startswith("--arg="):
|
|
20334
|
+
done.add(i)
|
|
20335
|
+
k, eq, v = a.split("=", 1)[1].partition("=")
|
|
20336
|
+
if eq and v and _SLOG_SECRET_NAME_RE.search(k):
|
|
20337
|
+
hits.append((i, "kv_value", k))
|
|
20338
|
+
elif a[:1] in ("{", "["):
|
|
20339
|
+
done.add(i)
|
|
20340
|
+
i += 1
|
|
20341
|
+
|
|
20342
|
+
for i, arg in enumerate(args):
|
|
20343
|
+
if i in done:
|
|
20344
|
+
continue
|
|
20345
|
+
value_follows = False
|
|
20346
|
+
follow_key = None
|
|
20347
|
+
follow_kind = None
|
|
20348
|
+
if arg.startswith("-"):
|
|
20349
|
+
name, eq, val = arg.lstrip("-").partition("=")
|
|
20350
|
+
if _SLOG_SECRET_NAME_RE.search(name):
|
|
20351
|
+
done.add(i)
|
|
20352
|
+
if eq and val:
|
|
20353
|
+
key = arg[:len(arg) - len(val)]
|
|
20354
|
+
hits.append((i, "flag_value", key))
|
|
20355
|
+
elif not eq:
|
|
20356
|
+
value_follows = True
|
|
20357
|
+
follow_key = arg
|
|
20358
|
+
follow_kind = "flag_value"
|
|
20359
|
+
elif _SLOG_SECRET_WORD_RE.fullmatch(arg):
|
|
20360
|
+
done.add(i)
|
|
20361
|
+
value_follows = True
|
|
20362
|
+
follow_key = arg
|
|
20363
|
+
follow_kind = "word_value"
|
|
20364
|
+
if value_follows:
|
|
20365
|
+
if i + 1 < len(args) and i + 1 not in done and not args[i + 1].startswith("--"):
|
|
20366
|
+
hits.append((i + 1, follow_kind, follow_key))
|
|
20367
|
+
done.add(i + 1)
|
|
20368
|
+
continue
|
|
20369
|
+
if i not in done:
|
|
20370
|
+
if _re.match(r"[A-Za-z][A-Za-z0-9+.-]*:", arg) and (
|
|
20371
|
+
"://" in arg or arg[:11].lower().startswith(("data:", "javascript:"))):
|
|
20372
|
+
continue
|
|
20373
|
+
m = _SLOG_KV_ARG_RE.fullmatch(arg)
|
|
20374
|
+
if m and m.group(3) and (cmd == "cookies" or _SLOG_SECRET_NAME_RE.search(m.group(1))):
|
|
20375
|
+
hits.append((i, "kv_value", m.group(1)))
|
|
20376
|
+
done.add(i)
|
|
20377
|
+
elif _slog_tokenish(arg):
|
|
20378
|
+
hits.append((i, "tokenish", None))
|
|
20379
|
+
done.add(i)
|
|
20380
|
+
return hits
|
|
20381
|
+
|
|
20382
|
+
|
|
19314
20383
|
def _slog_redact_args(cmd, args):
|
|
19315
20384
|
"""Redact one command line for the session log. Pure: no I/O, no state.
|
|
19316
20385
|
|
|
@@ -19637,13 +20706,17 @@ def _slog_exit_code():
|
|
|
19637
20706
|
def _slog_begin(argv):
|
|
19638
20707
|
"""Start recording this CLI invocation (argv without the program name)."""
|
|
19639
20708
|
try:
|
|
19640
|
-
if not argv
|
|
20709
|
+
if not argv:
|
|
19641
20710
|
return
|
|
19642
20711
|
cmd = SLOG_CMD_ALIASES.get(argv[0], argv[0])
|
|
19643
|
-
if cmd in
|
|
19644
|
-
|
|
20712
|
+
if cmd in (WATCH_DAEMON_FLAG, BOT_AUTH_SIGNER_FLAG, BOT_AUTH_LABEL_FLAG, IDLE_WATCHER_FLAG):
|
|
20713
|
+
return
|
|
20714
|
+
slog_wants = _slog_enabled() and cmd not in SLOG_SKIP_CMDS
|
|
20715
|
+
rec_wants = _record_active() and cmd not in RECORD_SKIP_CMDS and os.environ.get("CDPILOT_REPLAY_ACTIVE") != "1"
|
|
20716
|
+
if not slog_wants and not rec_wants:
|
|
19645
20717
|
return
|
|
19646
20718
|
_SLOG.update(active=True, done=False, cmd=cmd, args=list(argv[1:]),
|
|
20719
|
+
slog_wants=slog_wants, rec_wants=rec_wants,
|
|
19647
20720
|
started=datetime.datetime.now().astimezone(), t0=time.monotonic(),
|
|
19648
20721
|
t0_wall=time.time(), lock=threading.Lock(), url=None, title=None,
|
|
19649
20722
|
via=os.environ.get(SLOG_VIA_ENV) or None, exit_exc=None,
|
|
@@ -19683,11 +20756,14 @@ def _slog_finish(forced_code=None, forced_error=None):
|
|
|
19683
20756
|
code = forced_code if forced_code is not None else _slog_exit_code()
|
|
19684
20757
|
out = st["stdout"].captured() if st.get("stdout") else ""
|
|
19685
20758
|
err = st["stderr"].captured() if st.get("stderr") else ""
|
|
19686
|
-
|
|
19687
|
-
|
|
19688
|
-
|
|
19689
|
-
|
|
19690
|
-
|
|
20759
|
+
if st.get("slog_wants"):
|
|
20760
|
+
_slog_append(_slog_build_entry(
|
|
20761
|
+
st["cmd"], st["args"], code, int((time.monotonic() - st["t0"]) * 1000),
|
|
20762
|
+
st["started"], url=st.get("url"), title=st.get("title"), stdout=out, stderr=err,
|
|
20763
|
+
error=forced_error or st.get("error"), files=_slog_files((out, err), st["t0_wall"]),
|
|
20764
|
+
via=st.get("via")))
|
|
20765
|
+
if st.get("rec_wants"):
|
|
20766
|
+
_record_hook_step(st["cmd"], st["args"], code)
|
|
19691
20767
|
except Exception as e:
|
|
19692
20768
|
_slog_warn(e)
|
|
19693
20769
|
finally:
|
|
@@ -19696,12 +20772,15 @@ def _slog_finish(forced_code=None, forced_error=None):
|
|
|
19696
20772
|
|
|
19697
20773
|
def _slog_record_external(argv, code, started, duration_ms, error, via):
|
|
19698
20774
|
"""Log a command whose own process could not (an MCP tool call killed on timeout)."""
|
|
19699
|
-
if not argv
|
|
20775
|
+
if not argv:
|
|
19700
20776
|
return
|
|
19701
20777
|
try:
|
|
19702
20778
|
cmd = SLOG_CMD_ALIASES.get(argv[0], argv[0])
|
|
19703
|
-
|
|
19704
|
-
|
|
20779
|
+
if _slog_enabled() and cmd not in SLOG_SKIP_CMDS:
|
|
20780
|
+
_slog_append(_slog_build_entry(cmd, argv[1:], code, duration_ms, started,
|
|
20781
|
+
error=error, via=via))
|
|
20782
|
+
if _record_active() and cmd not in RECORD_SKIP_CMDS and os.environ.get("CDPILOT_REPLAY_ACTIVE") != "1":
|
|
20783
|
+
_record_hook_step(cmd, argv[1:], code)
|
|
19705
20784
|
except Exception as e:
|
|
19706
20785
|
_slog_warn(e)
|
|
19707
20786
|
|
|
@@ -19901,9 +20980,900 @@ def cmd_log(*args):
|
|
|
19901
20980
|
_slog_print(_slog_format_table(entries, PROJECT_ID, days))
|
|
19902
20981
|
|
|
19903
20982
|
|
|
19904
|
-
# ───
|
|
20983
|
+
# ─── Record & Replay (issue #5) ───
|
|
19905
20984
|
|
|
19906
|
-
|
|
20985
|
+
RECORD_INPUT_CMDS = frozenset({'fill', 'type', 'smart-fill', 'smart-select', 'select', 'assert-value'})
|
|
20986
|
+
# Recorder-only widening of the session log's secret-name test, applied to the
|
|
20987
|
+
# selector / label of input commands: short words get boundaries so that
|
|
20988
|
+
# "#shipping-address" (pin), "#discard" (card) and "#lesson" (ssn) stay verbatim.
|
|
20989
|
+
_RECORD_SECRET_SEL_RE = _re.compile(
|
|
20990
|
+
_SLOG_SECRET_NAME_RE.pattern + r"|\bpw\b|\bpin\b|pincode|cvv|cvc|\bcard|iban|\bssn\b|tc(?:kn|kimlik)",
|
|
20991
|
+
_re.I,
|
|
20992
|
+
)
|
|
20993
|
+
|
|
20994
|
+
|
|
20995
|
+
def _record_slug(s):
|
|
20996
|
+
"""Derive a clean snake_case slug from a flag, key, or selector name."""
|
|
20997
|
+
if not s:
|
|
20998
|
+
return None
|
|
20999
|
+
slug = _re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_").lower()
|
|
21000
|
+
return slug or None
|
|
21001
|
+
|
|
21002
|
+
|
|
21003
|
+
def _record_dir():
|
|
21004
|
+
home = os.environ.get("CDPILOT_HOME") or CDPILOT_HOME
|
|
21005
|
+
return os.path.join(home, "record")
|
|
21006
|
+
|
|
21007
|
+
|
|
21008
|
+
def _record_active_path():
|
|
21009
|
+
return os.path.join(_record_dir(), "active.json")
|
|
21010
|
+
|
|
21011
|
+
|
|
21012
|
+
def _record_active():
|
|
21013
|
+
return os.path.isfile(_record_active_path())
|
|
21014
|
+
|
|
21015
|
+
|
|
21016
|
+
def _record_hook_step(cmd, args, code):
|
|
21017
|
+
"""Append a step to active recording if one is in progress."""
|
|
21018
|
+
if os.environ.get("CDPILOT_REPLAY_ACTIVE") == "1":
|
|
21019
|
+
return
|
|
21020
|
+
cmd = SLOG_CMD_ALIASES.get(cmd, cmd)
|
|
21021
|
+
if cmd in RECORD_SKIP_CMDS:
|
|
21022
|
+
return
|
|
21023
|
+
active_path = _record_active_path()
|
|
21024
|
+
if not os.path.isfile(active_path):
|
|
21025
|
+
return
|
|
21026
|
+
lock_path = active_path + ".lock"
|
|
21027
|
+
lock_file = None
|
|
21028
|
+
has_flock = False
|
|
21029
|
+
try:
|
|
21030
|
+
import fcntl
|
|
21031
|
+
lock_file = open(lock_path, "w")
|
|
21032
|
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
|
21033
|
+
has_flock = True
|
|
21034
|
+
except (ImportError, OSError):
|
|
21035
|
+
pass
|
|
21036
|
+
|
|
21037
|
+
try:
|
|
21038
|
+
if not os.path.isfile(active_path):
|
|
21039
|
+
return
|
|
21040
|
+
with open(active_path, "r", encoding="utf-8") as f:
|
|
21041
|
+
state = json.load(f)
|
|
21042
|
+
started_at = state.get("started_at")
|
|
21043
|
+
t_ms = 0
|
|
21044
|
+
if started_at:
|
|
21045
|
+
try:
|
|
21046
|
+
clean_iso = started_at.replace("Z", "+00:00")
|
|
21047
|
+
dt = datetime.datetime.fromisoformat(clean_iso)
|
|
21048
|
+
if dt.tzinfo is None:
|
|
21049
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
21050
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
21051
|
+
t_ms = max(0, int((now - dt).total_seconds() * 1000))
|
|
21052
|
+
except Exception:
|
|
21053
|
+
pass
|
|
21054
|
+
steps_list = state.setdefault("steps", [])
|
|
21055
|
+
step_num = len(steps_list) + 1
|
|
21056
|
+
vars_dict = state.setdefault("vars", {})
|
|
21057
|
+
redacted_args, vars_dict = _record_detect_placeholders(
|
|
21058
|
+
cmd, args, step_num=step_num, vars_dict=vars_dict
|
|
21059
|
+
)
|
|
21060
|
+
step = {
|
|
21061
|
+
"cmd": cmd,
|
|
21062
|
+
"args": redacted_args,
|
|
21063
|
+
"t_ms": t_ms,
|
|
21064
|
+
"exit": code if isinstance(code, int) else 0
|
|
21065
|
+
}
|
|
21066
|
+
steps_list.append(step)
|
|
21067
|
+
tmp_dir = os.path.dirname(active_path)
|
|
21068
|
+
import tempfile
|
|
21069
|
+
fd, tmp_file = tempfile.mkstemp(prefix=".active-", suffix=".tmp", dir=tmp_dir)
|
|
21070
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
21071
|
+
json.dump(state, f, indent=2, ensure_ascii=False)
|
|
21072
|
+
os.replace(tmp_file, active_path)
|
|
21073
|
+
except Exception as e:
|
|
21074
|
+
_slog_warn(e) # one stderr line, like a failed session-log write; never changes the command's exit code
|
|
21075
|
+
finally:
|
|
21076
|
+
if has_flock and lock_file:
|
|
21077
|
+
try:
|
|
21078
|
+
import fcntl
|
|
21079
|
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
21080
|
+
lock_file.close()
|
|
21081
|
+
except Exception:
|
|
21082
|
+
pass
|
|
21083
|
+
|
|
21084
|
+
|
|
21085
|
+
def _record_slug_from_selector(sel):
|
|
21086
|
+
"""Derive a clean variable slug from a selector or label."""
|
|
21087
|
+
if not sel:
|
|
21088
|
+
return None
|
|
21089
|
+
# 1. ID selector: #foo or [id="foo"] or [id=foo]
|
|
21090
|
+
m = _re.search(r"#([A-Za-z0-9_-]+)", sel)
|
|
21091
|
+
if m:
|
|
21092
|
+
return _re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_").lower()
|
|
21093
|
+
m = _re.search(r"""\[\s*id\s*=\s*["']?([^"'\]]+)["']?\s*\]""", sel)
|
|
21094
|
+
if m:
|
|
21095
|
+
return _re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_").lower()
|
|
21096
|
+
# 2. Name attribute: [name="foo"] or [name=foo]
|
|
21097
|
+
m = _re.search(r"""\[\s*name\s*=\s*["']?([^"'\]]+)["']?\s*\]""", sel)
|
|
21098
|
+
if m:
|
|
21099
|
+
return _re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_").lower()
|
|
21100
|
+
# 3. Type attribute: [type="password"] or [type=password]
|
|
21101
|
+
m = _re.search(r"""\[\s*type\s*=\s*["']?([^"'\]]+)["']?\s*\]""", sel)
|
|
21102
|
+
if m:
|
|
21103
|
+
t = _re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_").lower()
|
|
21104
|
+
if t in ("password", "pwd"):
|
|
21105
|
+
return "password"
|
|
21106
|
+
return t
|
|
21107
|
+
# 4. Clean free label (e.g. "API key" -> "api_key")
|
|
21108
|
+
clean = _re.sub(r"[^A-Za-z0-9]+", "_", sel).strip("_").lower()
|
|
21109
|
+
if clean and len(clean) <= 30 and not clean.startswith(("input", "button", "select")):
|
|
21110
|
+
return clean
|
|
21111
|
+
return None
|
|
21112
|
+
|
|
21113
|
+
|
|
21114
|
+
def _record_redact_js(src, step_num, vars_dict, next_v_name_fn, cmd="eval"):
|
|
21115
|
+
"""Find secret string literals in JS and replace their content with {{vN}}."""
|
|
21116
|
+
out, last = [], 0
|
|
21117
|
+
for m in _SLOG_JS_STR_RE.finditer(src):
|
|
21118
|
+
quote, body = m.group(0)[0], m.group(0)[1:-1]
|
|
21119
|
+
before = src[max(0, m.start() - 80):m.start()]
|
|
21120
|
+
if body and (_SLOG_JS_SECRET_CTX_RE.search(before) or _slog_tokenish(body)):
|
|
21121
|
+
v_name = next_v_name_fn()
|
|
21122
|
+
if v_name not in vars_dict:
|
|
21123
|
+
vars_dict[v_name] = {"step": step_num, "hint": f"{cmd} {v_name}"}
|
|
21124
|
+
new_body = f"{{{{{v_name}}}}}"
|
|
21125
|
+
else:
|
|
21126
|
+
new_body = body
|
|
21127
|
+
out.append(src[last:m.start()])
|
|
21128
|
+
out.append(quote + new_body + quote)
|
|
21129
|
+
last = m.end()
|
|
21130
|
+
out.append(src[last:])
|
|
21131
|
+
return "".join(out)
|
|
21132
|
+
|
|
21133
|
+
|
|
21134
|
+
def _record_detect_placeholders(cmd, args, step_num=1, vars_dict=None):
|
|
21135
|
+
"""Detect secrets in a step's args and replace with {{name}} placeholders.
|
|
21136
|
+
|
|
21137
|
+
Returns (new_args, vars_dict).
|
|
21138
|
+
"""
|
|
21139
|
+
if vars_dict is None:
|
|
21140
|
+
vars_dict = {}
|
|
21141
|
+
args = ["" if a is None else str(a) for a in (args or [])]
|
|
21142
|
+
if not args:
|
|
21143
|
+
return list(args), vars_dict
|
|
21144
|
+
|
|
21145
|
+
def next_v_name():
|
|
21146
|
+
n = 1
|
|
21147
|
+
while f"v{n}" in vars_dict:
|
|
21148
|
+
n += 1
|
|
21149
|
+
return f"v{n}"
|
|
21150
|
+
|
|
21151
|
+
# (c) JS commands: eval, eval-batch, frame eval, multi-eval
|
|
21152
|
+
if cmd in SLOG_JS_CMDS and args:
|
|
21153
|
+
if len(args) == 1:
|
|
21154
|
+
new_js = _record_redact_js(args[0], step_num, vars_dict, next_v_name, cmd=cmd)
|
|
21155
|
+
return [new_js], vars_dict
|
|
21156
|
+
new_js = _record_redact_js(" ".join(args), step_num, vars_dict, next_v_name, cmd=cmd)
|
|
21157
|
+
return [new_js], vars_dict
|
|
21158
|
+
if cmd == "frame" and args[:1] == ["eval"] and len(args) > 1:
|
|
21159
|
+
new_js = _record_redact_js(" ".join(args[1:]), step_num, vars_dict, next_v_name, cmd="frame eval")
|
|
21160
|
+
return ["eval", new_js], vars_dict
|
|
21161
|
+
if cmd == "eval-batch" and args:
|
|
21162
|
+
try:
|
|
21163
|
+
exprs = json.loads(args[0])
|
|
21164
|
+
except ValueError:
|
|
21165
|
+
exprs = None
|
|
21166
|
+
if isinstance(exprs, list):
|
|
21167
|
+
red_exprs = [
|
|
21168
|
+
_record_redact_js(e, step_num, vars_dict, next_v_name, cmd=cmd) if isinstance(e, str) else e
|
|
21169
|
+
for e in exprs
|
|
21170
|
+
]
|
|
21171
|
+
new_arg0 = json.dumps(red_exprs, ensure_ascii=False)
|
|
21172
|
+
else:
|
|
21173
|
+
new_arg0 = _record_redact_js(args[0], step_num, vars_dict, next_v_name, cmd=cmd)
|
|
21174
|
+
return [new_arg0] + args[1:], vars_dict
|
|
21175
|
+
|
|
21176
|
+
# (d) tools call <name> <json>
|
|
21177
|
+
if cmd == "tools" and args[:1] == ["call"] and len(args) >= 2:
|
|
21178
|
+
new_args = list(args)
|
|
21179
|
+
i = 2
|
|
21180
|
+
while i < len(new_args):
|
|
21181
|
+
a = new_args[i]
|
|
21182
|
+
if a in ("--arg", "--frame") and i + 1 < len(new_args):
|
|
21183
|
+
if a == "--arg":
|
|
21184
|
+
kv = new_args[i + 1]
|
|
21185
|
+
k, eq, v = kv.partition("=")
|
|
21186
|
+
if eq and v and _SLOG_SECRET_NAME_RE.search(k):
|
|
21187
|
+
slug = _record_slug(k) or next_v_name()
|
|
21188
|
+
if slug not in vars_dict:
|
|
21189
|
+
vars_dict[slug] = {"step": step_num, "hint": f"{cmd} {k}"}
|
|
21190
|
+
new_args[i + 1] = f"{k}={{{{{slug}}}}}"
|
|
21191
|
+
i += 2
|
|
21192
|
+
continue
|
|
21193
|
+
if a.startswith("--arg="):
|
|
21194
|
+
kv = a.split("=", 1)[1]
|
|
21195
|
+
k, eq, v = kv.partition("=")
|
|
21196
|
+
if eq and v and _SLOG_SECRET_NAME_RE.search(k):
|
|
21197
|
+
slug = _record_slug(k) or next_v_name()
|
|
21198
|
+
if slug not in vars_dict:
|
|
21199
|
+
vars_dict[slug] = {"step": step_num, "hint": f"{cmd} {k}"}
|
|
21200
|
+
new_args[i] = f"--arg={k}={{{{{slug}}}}}"
|
|
21201
|
+
i += 1
|
|
21202
|
+
continue
|
|
21203
|
+
if a[:1] in ("{", "["):
|
|
21204
|
+
try:
|
|
21205
|
+
obj = json.loads(a)
|
|
21206
|
+
except ValueError:
|
|
21207
|
+
obj = None
|
|
21208
|
+
if obj is not None:
|
|
21209
|
+
def _redact_json_rec(val):
|
|
21210
|
+
if isinstance(val, dict):
|
|
21211
|
+
out_dict = {}
|
|
21212
|
+
for k, v in val.items():
|
|
21213
|
+
if _SLOG_SECRET_NAME_RE.search(str(k)) and v is not None:
|
|
21214
|
+
slug = _record_slug(str(k)) or next_v_name()
|
|
21215
|
+
if slug not in vars_dict:
|
|
21216
|
+
vars_dict[slug] = {"step": step_num, "hint": f"{cmd} {k}"}
|
|
21217
|
+
out_dict[k] = f"{{{{{slug}}}}}"
|
|
21218
|
+
else:
|
|
21219
|
+
out_dict[k] = _redact_json_rec(v)
|
|
21220
|
+
return out_dict
|
|
21221
|
+
elif isinstance(val, list):
|
|
21222
|
+
return [_redact_json_rec(x) for x in val]
|
|
21223
|
+
return val
|
|
21224
|
+
red_obj = _redact_json_rec(obj)
|
|
21225
|
+
new_args[i] = json.dumps(red_obj, separators=(',', ':'), ensure_ascii=False)
|
|
21226
|
+
i += 1
|
|
21227
|
+
return new_args, vars_dict
|
|
21228
|
+
|
|
21229
|
+
# (b) Input commands
|
|
21230
|
+
new_args = list(args)
|
|
21231
|
+
if cmd in RECORD_INPUT_CMDS:
|
|
21232
|
+
if cmd in ('fill', 'type'):
|
|
21233
|
+
non_flags = [i for i, a in enumerate(args) if not a.startswith("--")]
|
|
21234
|
+
if non_flags:
|
|
21235
|
+
sel_idx = non_flags[0]
|
|
21236
|
+
sel = args[sel_idx]
|
|
21237
|
+
val_idxs = non_flags[1:]
|
|
21238
|
+
val_str = " ".join(args[i] for i in val_idxs) if val_idxs else ""
|
|
21239
|
+
is_secret_sel = bool(_RECORD_SECRET_SEL_RE.search(sel))
|
|
21240
|
+
is_tok = bool(val_idxs and _slog_tokenish(val_str))
|
|
21241
|
+
if (is_secret_sel or is_tok) and val_idxs:
|
|
21242
|
+
slug = (_record_slug_from_selector(sel) if is_secret_sel else None) or next_v_name()
|
|
21243
|
+
var_name = slug
|
|
21244
|
+
if var_name not in vars_dict:
|
|
21245
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} {sel}"}
|
|
21246
|
+
val_placed = False
|
|
21247
|
+
new_args = []
|
|
21248
|
+
for i, a in enumerate(args):
|
|
21249
|
+
if i in val_idxs:
|
|
21250
|
+
if not val_placed:
|
|
21251
|
+
new_args.append(f"{{{{{var_name}}}}}")
|
|
21252
|
+
val_placed = True
|
|
21253
|
+
else:
|
|
21254
|
+
new_args.append(a)
|
|
21255
|
+
return new_args, vars_dict
|
|
21256
|
+
elif cmd in ('smart-fill', 'smart-select', 'select', 'assert-value'):
|
|
21257
|
+
if len(args) >= 2:
|
|
21258
|
+
sel = args[0]
|
|
21259
|
+
val_str = " ".join(args[1:])
|
|
21260
|
+
is_secret_sel = bool(_RECORD_SECRET_SEL_RE.search(sel))
|
|
21261
|
+
is_tok = bool(_slog_tokenish(val_str))
|
|
21262
|
+
if is_secret_sel or is_tok:
|
|
21263
|
+
slug = (_record_slug_from_selector(sel) if is_secret_sel else None) or next_v_name()
|
|
21264
|
+
var_name = slug
|
|
21265
|
+
if var_name not in vars_dict:
|
|
21266
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} {sel}"}
|
|
21267
|
+
return [sel, f"{{{{{var_name}}}}}"], vars_dict
|
|
21268
|
+
|
|
21269
|
+
# (a) General hits from _slog_secret_hits
|
|
21270
|
+
hits = _slog_secret_hits(cmd, new_args)
|
|
21271
|
+
handled_indices = set()
|
|
21272
|
+
for idx, kind, key in hits:
|
|
21273
|
+
if kind == "input_value":
|
|
21274
|
+
continue
|
|
21275
|
+
if kind in ("flag_value", "word_value", "kv_value") and key:
|
|
21276
|
+
slug = _record_slug(key) or next_v_name()
|
|
21277
|
+
hint = f"{cmd} {key}"
|
|
21278
|
+
else:
|
|
21279
|
+
slug = next_v_name()
|
|
21280
|
+
hint = f"{cmd} {key}" if key else f"{cmd} {slug}"
|
|
21281
|
+
|
|
21282
|
+
if slug not in vars_dict:
|
|
21283
|
+
vars_dict[slug] = {"step": step_num, "hint": hint}
|
|
21284
|
+
|
|
21285
|
+
if kind == "kv_value":
|
|
21286
|
+
arg = new_args[idx]
|
|
21287
|
+
m = _SLOG_KV_ARG_RE.fullmatch(arg)
|
|
21288
|
+
if m:
|
|
21289
|
+
new_args[idx] = m.group(1) + m.group(2) + f"{{{{{slug}}}}}"
|
|
21290
|
+
else:
|
|
21291
|
+
new_args[idx] = f"{{{{{slug}}}}}"
|
|
21292
|
+
handled_indices.add(idx)
|
|
21293
|
+
elif kind == "flag_value":
|
|
21294
|
+
arg = new_args[idx]
|
|
21295
|
+
if "=" in arg and arg.startswith("-"):
|
|
21296
|
+
prefix, _, _ = arg.partition("=")
|
|
21297
|
+
new_args[idx] = f"{prefix}={{{{{slug}}}}}"
|
|
21298
|
+
else:
|
|
21299
|
+
new_args[idx] = f"{{{{{slug}}}}}"
|
|
21300
|
+
handled_indices.add(idx)
|
|
21301
|
+
elif kind == "word_value":
|
|
21302
|
+
new_args[idx] = f"{{{{{slug}}}}}"
|
|
21303
|
+
handled_indices.add(idx)
|
|
21304
|
+
elif kind == "tokenish":
|
|
21305
|
+
new_args[idx] = f"{{{{{slug}}}}}"
|
|
21306
|
+
handled_indices.add(idx)
|
|
21307
|
+
|
|
21308
|
+
# (e) Existing URL, Bearer, Token-prefix rules for unhandled indices
|
|
21309
|
+
for i, arg in enumerate(new_args):
|
|
21310
|
+
if i in handled_indices:
|
|
21311
|
+
continue
|
|
21312
|
+
# 1. Secret URL params or userinfo password
|
|
21313
|
+
if ("://" in arg or arg.startswith(("http:", "https:"))) and _slog_mask_url(arg) != arg:
|
|
21314
|
+
from urllib.parse import urlsplit, urlunsplit, unquote_plus
|
|
21315
|
+
try:
|
|
21316
|
+
parts = urlsplit(arg)
|
|
21317
|
+
netloc = parts.netloc
|
|
21318
|
+
if "@" in netloc:
|
|
21319
|
+
userinfo, _, host = netloc.rpartition("@")
|
|
21320
|
+
if ":" in userinfo:
|
|
21321
|
+
user, _, password = userinfo.partition(":")
|
|
21322
|
+
var_name = "password"
|
|
21323
|
+
if var_name in vars_dict and vars_dict[var_name].get("step") != step_num:
|
|
21324
|
+
var_name = f"password_{step_num}"
|
|
21325
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} password"}
|
|
21326
|
+
netloc = f"{user}:{{{{{var_name}}}}}@{host}"
|
|
21327
|
+
qs_parts = []
|
|
21328
|
+
for p in parts.query.split("&") if parts.query else []:
|
|
21329
|
+
k, sep, v = p.partition("=")
|
|
21330
|
+
if sep and v and _SLOG_SECRET_PARAM_RE.search(unquote_plus(k)):
|
|
21331
|
+
slug = _record_slug(k) or next_v_name()
|
|
21332
|
+
var_name = slug
|
|
21333
|
+
if var_name in vars_dict and vars_dict[var_name].get("step") != step_num:
|
|
21334
|
+
var_name = f"{slug}_{step_num}"
|
|
21335
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} {k}"}
|
|
21336
|
+
p = f"{k}={{{{{var_name}}}}}"
|
|
21337
|
+
qs_parts.append(p)
|
|
21338
|
+
new_q = "&".join(qs_parts)
|
|
21339
|
+
frag_parts = []
|
|
21340
|
+
for p in parts.fragment.split("&") if parts.fragment else []:
|
|
21341
|
+
k, sep, v = p.partition("=")
|
|
21342
|
+
if sep and v and _SLOG_SECRET_PARAM_RE.search(unquote_plus(k)):
|
|
21343
|
+
slug = _record_slug(k) or next_v_name()
|
|
21344
|
+
var_name = slug
|
|
21345
|
+
if var_name in vars_dict and vars_dict[var_name].get("step") != step_num:
|
|
21346
|
+
var_name = f"{slug}_{step_num}"
|
|
21347
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} {k}"}
|
|
21348
|
+
p = f"{k}={{{{{var_name}}}}}"
|
|
21349
|
+
frag_parts.append(p)
|
|
21350
|
+
new_frag = "&".join(frag_parts)
|
|
21351
|
+
new_args[i] = urlunsplit((parts.scheme, netloc, parts.path, new_q, new_frag))
|
|
21352
|
+
continue
|
|
21353
|
+
except Exception:
|
|
21354
|
+
pass
|
|
21355
|
+
|
|
21356
|
+
# 2. Bearer token
|
|
21357
|
+
m_bearer = _SLOG_BEARER_RE.search(arg)
|
|
21358
|
+
if m_bearer:
|
|
21359
|
+
var_name = next_v_name()
|
|
21360
|
+
vars_dict[var_name] = {"step": step_num, "hint": f"{cmd} {m_bearer.group(1)}"}
|
|
21361
|
+
new_args[i] = _SLOG_BEARER_RE.sub(
|
|
21362
|
+
lambda m: f"{m.group(1)}{m.group(2)}{{{{{var_name}}}}}", arg)
|
|
21363
|
+
continue
|
|
21364
|
+
|
|
21365
|
+
# 3. Token prefix
|
|
21366
|
+
m_tok = _SLOG_TOKEN_PREFIX_RE.search(arg)
|
|
21367
|
+
if m_tok:
|
|
21368
|
+
var_name = next_v_name()
|
|
21369
|
+
hint_str = f"{cmd} {args[0]}" if args else cmd
|
|
21370
|
+
vars_dict[var_name] = {"step": step_num, "hint": hint_str}
|
|
21371
|
+
new_args[i] = _SLOG_TOKEN_PREFIX_RE.sub(f"{{{{{var_name}}}}}", arg)
|
|
21372
|
+
continue
|
|
21373
|
+
|
|
21374
|
+
return new_args, vars_dict
|
|
21375
|
+
|
|
21376
|
+
|
|
21377
|
+
def cmd_record_start(*args):
|
|
21378
|
+
"""Start a recording session."""
|
|
21379
|
+
name = None
|
|
21380
|
+
i = 0
|
|
21381
|
+
while i < len(args):
|
|
21382
|
+
a = args[i]
|
|
21383
|
+
if a == "--name":
|
|
21384
|
+
if i + 1 >= len(args):
|
|
21385
|
+
print("cdpilot: --name requires a name", file=sys.stderr)
|
|
21386
|
+
sys.exit(2)
|
|
21387
|
+
name = args[i + 1]
|
|
21388
|
+
i += 2
|
|
21389
|
+
continue
|
|
21390
|
+
elif a.startswith("--name="):
|
|
21391
|
+
name = a.split("=", 1)[1]
|
|
21392
|
+
i += 1
|
|
21393
|
+
continue
|
|
21394
|
+
elif a.startswith("--"):
|
|
21395
|
+
print(f"cdpilot: unknown record option: {a}", file=sys.stderr)
|
|
21396
|
+
sys.exit(2)
|
|
21397
|
+
i += 1
|
|
21398
|
+
|
|
21399
|
+
active_path = _record_active_path()
|
|
21400
|
+
if os.path.isfile(active_path):
|
|
21401
|
+
name_found = "(unnamed)"
|
|
21402
|
+
try:
|
|
21403
|
+
with open(active_path, "r", encoding="utf-8") as f:
|
|
21404
|
+
state = json.load(f)
|
|
21405
|
+
name_found = state.get("name") or "(unnamed)"
|
|
21406
|
+
except Exception:
|
|
21407
|
+
pass
|
|
21408
|
+
print(f"cdpilot: recording already active: {name_found!r}", file=sys.stderr)
|
|
21409
|
+
sys.exit(1)
|
|
21410
|
+
|
|
21411
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
21412
|
+
if not name:
|
|
21413
|
+
name = f"flow-{now.strftime('%Y%m%d-%H%M%S')}"
|
|
21414
|
+
|
|
21415
|
+
rec_dir = _record_dir()
|
|
21416
|
+
os.makedirs(rec_dir, exist_ok=True)
|
|
21417
|
+
state = {
|
|
21418
|
+
"name": name,
|
|
21419
|
+
"started_at": now.isoformat(),
|
|
21420
|
+
"steps": [],
|
|
21421
|
+
"vars": {}
|
|
21422
|
+
}
|
|
21423
|
+
import tempfile
|
|
21424
|
+
fd, tmp_file = tempfile.mkstemp(prefix=".active-", suffix=".tmp", dir=rec_dir)
|
|
21425
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
21426
|
+
json.dump(state, f, indent=2, ensure_ascii=False)
|
|
21427
|
+
os.replace(tmp_file, active_path)
|
|
21428
|
+
print(f"Recording started: {name}")
|
|
21429
|
+
|
|
21430
|
+
|
|
21431
|
+
def cmd_record_status(*args):
|
|
21432
|
+
"""Report status of the active recording session."""
|
|
21433
|
+
active_path = _record_active_path()
|
|
21434
|
+
if not os.path.isfile(active_path):
|
|
21435
|
+
print("No recording active.")
|
|
21436
|
+
return
|
|
21437
|
+
try:
|
|
21438
|
+
with open(active_path, "r", encoding="utf-8") as f:
|
|
21439
|
+
state = json.load(f)
|
|
21440
|
+
name = state.get("name") or "(unnamed)"
|
|
21441
|
+
steps = state.get("steps") or []
|
|
21442
|
+
started_at = state.get("started_at") or ""
|
|
21443
|
+
print(f"Recording active: {name} ({len(steps)} steps, started {started_at})")
|
|
21444
|
+
except Exception as e:
|
|
21445
|
+
print(f"cdpilot: error reading active recording: {e}", file=sys.stderr)
|
|
21446
|
+
sys.exit(1)
|
|
21447
|
+
|
|
21448
|
+
|
|
21449
|
+
def cmd_record_cancel(*args):
|
|
21450
|
+
"""Cancel and discard the active recording session."""
|
|
21451
|
+
active_path = _record_active_path()
|
|
21452
|
+
if not os.path.isfile(active_path):
|
|
21453
|
+
print("No recording active.")
|
|
21454
|
+
return
|
|
21455
|
+
try:
|
|
21456
|
+
with open(active_path, "r", encoding="utf-8") as f:
|
|
21457
|
+
state = json.load(f)
|
|
21458
|
+
name = state.get("name") or "(unnamed)"
|
|
21459
|
+
os.remove(active_path)
|
|
21460
|
+
lock_path = active_path + ".lock"
|
|
21461
|
+
if os.path.isfile(lock_path):
|
|
21462
|
+
os.remove(lock_path)
|
|
21463
|
+
print(f"Recording cancelled: {name}")
|
|
21464
|
+
except Exception as e:
|
|
21465
|
+
print(f"cdpilot: error cancelling recording: {e}", file=sys.stderr)
|
|
21466
|
+
sys.exit(1)
|
|
21467
|
+
|
|
21468
|
+
|
|
21469
|
+
def cmd_record_stop(*args):
|
|
21470
|
+
"""Stop recording session and output flow JSON."""
|
|
21471
|
+
save_path = None
|
|
21472
|
+
i = 0
|
|
21473
|
+
while i < len(args):
|
|
21474
|
+
a = args[i]
|
|
21475
|
+
if a == "--save":
|
|
21476
|
+
if i + 1 >= len(args):
|
|
21477
|
+
print("cdpilot: --save requires a file path", file=sys.stderr)
|
|
21478
|
+
sys.exit(2)
|
|
21479
|
+
save_path = args[i + 1]
|
|
21480
|
+
i += 2
|
|
21481
|
+
continue
|
|
21482
|
+
elif a.startswith("--save="):
|
|
21483
|
+
save_path = a.split("=", 1)[1]
|
|
21484
|
+
i += 1
|
|
21485
|
+
continue
|
|
21486
|
+
elif a.startswith("--"):
|
|
21487
|
+
print(f"cdpilot: unknown record option: {a}", file=sys.stderr)
|
|
21488
|
+
sys.exit(2)
|
|
21489
|
+
i += 1
|
|
21490
|
+
|
|
21491
|
+
active_path = _record_active_path()
|
|
21492
|
+
if not os.path.isfile(active_path):
|
|
21493
|
+
print("cdpilot: no recording active", file=sys.stderr)
|
|
21494
|
+
sys.exit(1)
|
|
21495
|
+
|
|
21496
|
+
try:
|
|
21497
|
+
with open(active_path, "r", encoding="utf-8") as f:
|
|
21498
|
+
state = json.load(f)
|
|
21499
|
+
except Exception as e:
|
|
21500
|
+
print(f"cdpilot: error reading active recording: {e}", file=sys.stderr)
|
|
21501
|
+
sys.exit(1)
|
|
21502
|
+
|
|
21503
|
+
name = state.get("name") or "flow"
|
|
21504
|
+
all_steps = state.get("steps") or []
|
|
21505
|
+
recorded_vars = state.get("vars") or {}
|
|
21506
|
+
successful_steps = [s for s in all_steps if s.get("exit") == 0]
|
|
21507
|
+
dropped_count = len(all_steps) - len(successful_steps)
|
|
21508
|
+
if dropped_count > 0:
|
|
21509
|
+
print(f"dropped {dropped_count} failed steps", file=sys.stderr)
|
|
21510
|
+
|
|
21511
|
+
flow_steps = []
|
|
21512
|
+
for s in successful_steps:
|
|
21513
|
+
flow_steps.append({
|
|
21514
|
+
"cmd": s.get("cmd", ""),
|
|
21515
|
+
"args": s.get("args", []),
|
|
21516
|
+
"t_ms": s.get("t_ms", 0)
|
|
21517
|
+
})
|
|
21518
|
+
|
|
21519
|
+
var_pattern = _re.compile(r"\{\{([A-Za-z0-9_-]+)\}\}")
|
|
21520
|
+
first_seen_step = {}
|
|
21521
|
+
for step_idx, s in enumerate(flow_steps, 1):
|
|
21522
|
+
for arg in s.get("args", []):
|
|
21523
|
+
for m in var_pattern.finditer(str(arg)):
|
|
21524
|
+
v_name = m.group(1)
|
|
21525
|
+
if v_name not in first_seen_step:
|
|
21526
|
+
first_seen_step[v_name] = step_idx
|
|
21527
|
+
|
|
21528
|
+
final_vars = {}
|
|
21529
|
+
for v_name, first_step in first_seen_step.items():
|
|
21530
|
+
if v_name in recorded_vars:
|
|
21531
|
+
v_info = dict(recorded_vars[v_name])
|
|
21532
|
+
v_info["step"] = first_step
|
|
21533
|
+
final_vars[v_name] = v_info
|
|
21534
|
+
else:
|
|
21535
|
+
final_vars[v_name] = {"step": first_step, "hint": v_name}
|
|
21536
|
+
|
|
21537
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
21538
|
+
flow_data = {
|
|
21539
|
+
"cdpilot_flow": 1,
|
|
21540
|
+
"name": name,
|
|
21541
|
+
"recorded_at": now.isoformat(),
|
|
21542
|
+
"cdpilot_version": __version__,
|
|
21543
|
+
"steps": flow_steps,
|
|
21544
|
+
"vars": final_vars
|
|
21545
|
+
}
|
|
21546
|
+
|
|
21547
|
+
if save_path:
|
|
21548
|
+
try:
|
|
21549
|
+
save_dir = os.path.dirname(os.path.abspath(save_path))
|
|
21550
|
+
if save_dir:
|
|
21551
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
21552
|
+
fd = os.open(save_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
21553
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
21554
|
+
json.dump(flow_data, f, indent=2, ensure_ascii=False)
|
|
21555
|
+
f.write("\n")
|
|
21556
|
+
except Exception as e:
|
|
21557
|
+
print(f"cdpilot: could not write {save_path}: {e}", file=sys.stderr)
|
|
21558
|
+
sys.exit(1)
|
|
21559
|
+
|
|
21560
|
+
try:
|
|
21561
|
+
os.remove(active_path)
|
|
21562
|
+
lock_path = active_path + ".lock"
|
|
21563
|
+
if os.path.isfile(lock_path):
|
|
21564
|
+
os.remove(lock_path)
|
|
21565
|
+
except OSError:
|
|
21566
|
+
pass
|
|
21567
|
+
print(f"Saved flow to {save_path}")
|
|
21568
|
+
else:
|
|
21569
|
+
try:
|
|
21570
|
+
os.remove(active_path)
|
|
21571
|
+
lock_path = active_path + ".lock"
|
|
21572
|
+
if os.path.isfile(lock_path):
|
|
21573
|
+
os.remove(lock_path)
|
|
21574
|
+
except OSError:
|
|
21575
|
+
pass
|
|
21576
|
+
print(json.dumps(flow_data, indent=2, ensure_ascii=False))
|
|
21577
|
+
|
|
21578
|
+
|
|
21579
|
+
def cmd_record_dispatch(args):
|
|
21580
|
+
"""Dispatch cdpilot record subcommands."""
|
|
21581
|
+
if not args:
|
|
21582
|
+
print("Usage: cdpilot record <subcommand> [options]\n\n"
|
|
21583
|
+
"Subcommands:\n"
|
|
21584
|
+
" start [--name <n>] Start recording commands into active session\n"
|
|
21585
|
+
" status Show active recording status\n"
|
|
21586
|
+
" cancel Cancel and discard active recording\n"
|
|
21587
|
+
" stop [--save <file.json>] Stop recording and emit flow JSON\n",
|
|
21588
|
+
file=sys.stderr)
|
|
21589
|
+
sys.exit(2)
|
|
21590
|
+
|
|
21591
|
+
if args[0] in ('--help', '-h', 'help'):
|
|
21592
|
+
print("Usage: cdpilot record <subcommand> [options]\n\n"
|
|
21593
|
+
"Subcommands:\n"
|
|
21594
|
+
" start [--name <n>] Start recording commands into active session\n"
|
|
21595
|
+
" status Show active recording status\n"
|
|
21596
|
+
" cancel Cancel and discard active recording\n"
|
|
21597
|
+
" stop [--save <file.json>] Stop recording and emit flow JSON\n",
|
|
21598
|
+
file=sys.stderr)
|
|
21599
|
+
sys.exit(0)
|
|
21600
|
+
|
|
21601
|
+
sub = args[0].lower()
|
|
21602
|
+
rest = args[1:]
|
|
21603
|
+
if sub == 'start':
|
|
21604
|
+
cmd_record_start(*rest)
|
|
21605
|
+
elif sub == 'status':
|
|
21606
|
+
cmd_record_status(*rest)
|
|
21607
|
+
elif sub == 'cancel':
|
|
21608
|
+
cmd_record_cancel(*rest)
|
|
21609
|
+
elif sub == 'stop':
|
|
21610
|
+
cmd_record_stop(*rest)
|
|
21611
|
+
else:
|
|
21612
|
+
print(f"Unknown record subcommand: {sub}. Run: cdpilot record --help", file=sys.stderr)
|
|
21613
|
+
sys.exit(1)
|
|
21614
|
+
|
|
21615
|
+
|
|
21616
|
+
def cmd_replay(*args):
|
|
21617
|
+
"""Replay a recorded flow JSON file."""
|
|
21618
|
+
if not args:
|
|
21619
|
+
print("Usage: cdpilot replay <flow.json> [--var k=v ...] [--delay real|none|<ms>] [--dry-run] [--continue]",
|
|
21620
|
+
file=sys.stderr)
|
|
21621
|
+
sys.exit(2)
|
|
21622
|
+
|
|
21623
|
+
flow_path = None
|
|
21624
|
+
cli_vars = {}
|
|
21625
|
+
delay_mode = "real"
|
|
21626
|
+
delay_fixed_ms = 0
|
|
21627
|
+
dry_run = False
|
|
21628
|
+
continue_on_error = False
|
|
21629
|
+
|
|
21630
|
+
i = 0
|
|
21631
|
+
while i < len(args):
|
|
21632
|
+
a = args[i]
|
|
21633
|
+
if a == "--dry-run":
|
|
21634
|
+
dry_run = True
|
|
21635
|
+
i += 1
|
|
21636
|
+
elif a == "--continue":
|
|
21637
|
+
continue_on_error = True
|
|
21638
|
+
i += 1
|
|
21639
|
+
elif a == "--var":
|
|
21640
|
+
if i + 1 < len(args):
|
|
21641
|
+
kv = args[i + 1]
|
|
21642
|
+
k, sep, v = kv.partition("=")
|
|
21643
|
+
if sep:
|
|
21644
|
+
cli_vars[k.strip()] = v
|
|
21645
|
+
else:
|
|
21646
|
+
print(f"cdpilot: invalid --var format: {kv!r} (expected k=v)", file=sys.stderr)
|
|
21647
|
+
sys.exit(2)
|
|
21648
|
+
i += 2
|
|
21649
|
+
else:
|
|
21650
|
+
print("cdpilot: --var requires k=v", file=sys.stderr)
|
|
21651
|
+
sys.exit(2)
|
|
21652
|
+
elif a.startswith("--var="):
|
|
21653
|
+
kv = a.split("=", 1)[1]
|
|
21654
|
+
k, sep, v = kv.partition("=")
|
|
21655
|
+
if sep:
|
|
21656
|
+
cli_vars[k.strip()] = v
|
|
21657
|
+
else:
|
|
21658
|
+
print(f"cdpilot: invalid --var format: {kv!r} (expected k=v)", file=sys.stderr)
|
|
21659
|
+
sys.exit(2)
|
|
21660
|
+
i += 1
|
|
21661
|
+
elif a == "--delay":
|
|
21662
|
+
if i + 1 < len(args):
|
|
21663
|
+
val = args[i + 1].strip()
|
|
21664
|
+
if val == "real":
|
|
21665
|
+
delay_mode = "real"
|
|
21666
|
+
elif val == "none":
|
|
21667
|
+
delay_mode = "none"
|
|
21668
|
+
else:
|
|
21669
|
+
v = val[:-2] if val.endswith("ms") else val
|
|
21670
|
+
if v.isdigit():
|
|
21671
|
+
delay_mode = "fixed"
|
|
21672
|
+
delay_fixed_ms = int(v)
|
|
21673
|
+
else:
|
|
21674
|
+
print(f"cdpilot: invalid delay: {val} (expected 'real', 'none', or milliseconds)", file=sys.stderr)
|
|
21675
|
+
sys.exit(2)
|
|
21676
|
+
i += 2
|
|
21677
|
+
else:
|
|
21678
|
+
print("cdpilot: --delay requires a value ('real', 'none', or milliseconds)", file=sys.stderr)
|
|
21679
|
+
sys.exit(2)
|
|
21680
|
+
elif a.startswith("--delay="):
|
|
21681
|
+
val = a.split("=", 1)[1].strip()
|
|
21682
|
+
if val == "real":
|
|
21683
|
+
delay_mode = "real"
|
|
21684
|
+
elif val == "none":
|
|
21685
|
+
delay_mode = "none"
|
|
21686
|
+
else:
|
|
21687
|
+
v = val[:-2] if val.endswith("ms") else val
|
|
21688
|
+
if v.isdigit():
|
|
21689
|
+
delay_mode = "fixed"
|
|
21690
|
+
delay_fixed_ms = int(v)
|
|
21691
|
+
else:
|
|
21692
|
+
print(f"cdpilot: invalid delay: {val} (expected 'real', 'none', or milliseconds)", file=sys.stderr)
|
|
21693
|
+
sys.exit(2)
|
|
21694
|
+
i += 1
|
|
21695
|
+
elif a.startswith("--"):
|
|
21696
|
+
print(f"cdpilot: unknown replay option: {a}", file=sys.stderr)
|
|
21697
|
+
sys.exit(2)
|
|
21698
|
+
else:
|
|
21699
|
+
if flow_path is None:
|
|
21700
|
+
flow_path = a
|
|
21701
|
+
i += 1
|
|
21702
|
+
|
|
21703
|
+
if not flow_path:
|
|
21704
|
+
print("cdpilot: flow file path required", file=sys.stderr)
|
|
21705
|
+
sys.exit(2)
|
|
21706
|
+
|
|
21707
|
+
if not os.path.isfile(flow_path):
|
|
21708
|
+
print(f"cdpilot: file not found: {flow_path}", file=sys.stderr)
|
|
21709
|
+
sys.exit(2)
|
|
21710
|
+
|
|
21711
|
+
try:
|
|
21712
|
+
with open(flow_path, "r", encoding="utf-8") as f:
|
|
21713
|
+
data = json.load(f)
|
|
21714
|
+
except Exception as e:
|
|
21715
|
+
print(f"cdpilot: invalid JSON in flow file: {e}", file=sys.stderr)
|
|
21716
|
+
sys.exit(2)
|
|
21717
|
+
|
|
21718
|
+
if not isinstance(data, dict):
|
|
21719
|
+
print(f"cdpilot: invalid flow file shape (expected JSON object): {flow_path}", file=sys.stderr)
|
|
21720
|
+
sys.exit(2)
|
|
21721
|
+
|
|
21722
|
+
if "cdpilot_flow" not in data or data.get("cdpilot_flow") != 1:
|
|
21723
|
+
print(f"cdpilot: unknown or missing flow version: {data.get('cdpilot_flow')!r} (expected 1)", file=sys.stderr)
|
|
21724
|
+
sys.exit(2)
|
|
21725
|
+
|
|
21726
|
+
steps = data.get("steps")
|
|
21727
|
+
if not isinstance(steps, list):
|
|
21728
|
+
print("cdpilot: invalid flow file: 'steps' must be a list", file=sys.stderr)
|
|
21729
|
+
sys.exit(2)
|
|
21730
|
+
|
|
21731
|
+
for idx, s in enumerate(steps, 1):
|
|
21732
|
+
if (not isinstance(s, dict)
|
|
21733
|
+
or not isinstance(s.get("cmd"), str)
|
|
21734
|
+
or not s.get("cmd").strip()
|
|
21735
|
+
or not isinstance(s.get("args"), list)
|
|
21736
|
+
or not all(isinstance(x, str) for x in s.get("args"))):
|
|
21737
|
+
print(f"cdpilot: invalid flow file: step {idx} malformed", file=sys.stderr)
|
|
21738
|
+
sys.exit(2)
|
|
21739
|
+
|
|
21740
|
+
collected_vars = {}
|
|
21741
|
+
for env_k, env_v in os.environ.items():
|
|
21742
|
+
if env_k.startswith("CDPILOT_VAR_"):
|
|
21743
|
+
var_name = env_k[len("CDPILOT_VAR_"):].lower()
|
|
21744
|
+
collected_vars[var_name] = env_v
|
|
21745
|
+
collected_vars[env_k[len("CDPILOT_VAR_"):]] = env_v
|
|
21746
|
+
|
|
21747
|
+
collected_vars.update(cli_vars)
|
|
21748
|
+
|
|
21749
|
+
needed_vars = set(data.get("vars", {}).keys())
|
|
21750
|
+
for s in steps:
|
|
21751
|
+
if isinstance(s, dict):
|
|
21752
|
+
for a in s.get("args", []):
|
|
21753
|
+
for m in _re.finditer(r"\{\{([A-Za-z0-9_-]+)\}\}", str(a)):
|
|
21754
|
+
needed_vars.add(m.group(1))
|
|
21755
|
+
|
|
21756
|
+
def get_var_value(name):
|
|
21757
|
+
if name in collected_vars:
|
|
21758
|
+
return collected_vars[name]
|
|
21759
|
+
if name.lower() in collected_vars:
|
|
21760
|
+
return collected_vars[name.lower()]
|
|
21761
|
+
if name.upper() in collected_vars:
|
|
21762
|
+
return collected_vars[name.upper()]
|
|
21763
|
+
return None
|
|
21764
|
+
|
|
21765
|
+
missing = []
|
|
21766
|
+
for var_name in sorted(needed_vars):
|
|
21767
|
+
if get_var_value(var_name) is None:
|
|
21768
|
+
missing.append(var_name)
|
|
21769
|
+
|
|
21770
|
+
if missing:
|
|
21771
|
+
print(f"cdpilot: missing required variable(s): {', '.join(missing)}", file=sys.stderr)
|
|
21772
|
+
sys.exit(2)
|
|
21773
|
+
|
|
21774
|
+
total_steps = len(steps)
|
|
21775
|
+
if dry_run:
|
|
21776
|
+
for idx, step in enumerate(steps, 1):
|
|
21777
|
+
cmd = step.get("cmd", "")
|
|
21778
|
+
args_list = [str(a) for a in step.get("args", [])]
|
|
21779
|
+
args_str = " ".join(args_list)
|
|
21780
|
+
line = f"[{idx}/{total_steps}] {cmd} {args_str}".strip()
|
|
21781
|
+
print(line)
|
|
21782
|
+
sys.exit(0)
|
|
21783
|
+
|
|
21784
|
+
had_failure = False
|
|
21785
|
+
for idx, step in enumerate(steps, 1):
|
|
21786
|
+
if idx > 1:
|
|
21787
|
+
if delay_mode == "real":
|
|
21788
|
+
prev_t = steps[idx - 2].get("t_ms", 0) if idx >= 2 else 0
|
|
21789
|
+
curr_t = step.get("t_ms", 0)
|
|
21790
|
+
gap_ms = max(0, curr_t - prev_t)
|
|
21791
|
+
sleep_s = min(gap_ms, 10000) / 1000.0
|
|
21792
|
+
if sleep_s > 0:
|
|
21793
|
+
time.sleep(sleep_s)
|
|
21794
|
+
elif delay_mode == "fixed":
|
|
21795
|
+
if delay_fixed_ms > 0:
|
|
21796
|
+
time.sleep(delay_fixed_ms / 1000.0)
|
|
21797
|
+
|
|
21798
|
+
cmd = step.get("cmd", "")
|
|
21799
|
+
raw_args = step.get("args", [])
|
|
21800
|
+
sub_args = []
|
|
21801
|
+
for a in raw_args:
|
|
21802
|
+
s_val = str(a)
|
|
21803
|
+
sub = _re.sub(r"\{\{([A-Za-z0-9_-]+)\}\}",
|
|
21804
|
+
lambda m: str(get_var_value(m.group(1)) if get_var_value(m.group(1)) is not None else m.group(0)),
|
|
21805
|
+
s_val)
|
|
21806
|
+
sub_args.append(sub)
|
|
21807
|
+
|
|
21808
|
+
# Collect substituted variable values for placeholders in this step
|
|
21809
|
+
step_subs = {}
|
|
21810
|
+
for a in raw_args:
|
|
21811
|
+
for m in _re.finditer(r"\{\{([A-Za-z0-9_-]+)\}\}", str(a)):
|
|
21812
|
+
vname = m.group(1)
|
|
21813
|
+
vval = get_var_value(vname)
|
|
21814
|
+
if vval is not None and str(vval) != "":
|
|
21815
|
+
step_subs[vname] = str(vval)
|
|
21816
|
+
|
|
21817
|
+
def sanitize_output(text):
|
|
21818
|
+
if not text:
|
|
21819
|
+
return text
|
|
21820
|
+
# Replace longest value first; skip empty values
|
|
21821
|
+
sorted_subs = sorted(step_subs.items(), key=lambda kv: len(kv[1]), reverse=True)
|
|
21822
|
+
for vname, vval in sorted_subs:
|
|
21823
|
+
if vval:
|
|
21824
|
+
text = text.replace(vval, f"{{{{{vname}}}}}")
|
|
21825
|
+
return text
|
|
21826
|
+
|
|
21827
|
+
if cmd in RECORD_INPUT_CMDS and raw_args:
|
|
21828
|
+
target = next((a for a in raw_args if not a.startswith("--")), raw_args[0])
|
|
21829
|
+
step_label = f"{cmd} {target}"
|
|
21830
|
+
elif raw_args:
|
|
21831
|
+
step_label = f"{cmd} {' '.join(str(a) for a in raw_args)}"
|
|
21832
|
+
else:
|
|
21833
|
+
step_label = cmd
|
|
21834
|
+
|
|
21835
|
+
env = os.environ.copy()
|
|
21836
|
+
env["CDPILOT_REPLAY_ACTIVE"] = "1"
|
|
21837
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
21838
|
+
t0 = time.monotonic()
|
|
21839
|
+
child_cmd = [sys.executable, os.path.abspath(__file__), cmd] + sub_args
|
|
21840
|
+
res = subprocess.run(
|
|
21841
|
+
child_cmd,
|
|
21842
|
+
capture_output=True,
|
|
21843
|
+
encoding="utf-8",
|
|
21844
|
+
errors="replace",
|
|
21845
|
+
env=env
|
|
21846
|
+
)
|
|
21847
|
+
dur = time.monotonic() - t0
|
|
21848
|
+
|
|
21849
|
+
if res.returncode == 0:
|
|
21850
|
+
print(f"[{idx}/{total_steps}] {step_label} ... ok {dur:.1f}s")
|
|
21851
|
+
else:
|
|
21852
|
+
print(f"[{idx}/{total_steps}] {step_label} ... failed ({res.returncode}) {dur:.1f}s")
|
|
21853
|
+
raw_err = res.stderr.rstrip() if res.stderr else res.stdout.rstrip()
|
|
21854
|
+
err_msg = sanitize_output(raw_err)
|
|
21855
|
+
if err_msg:
|
|
21856
|
+
print(err_msg, file=sys.stderr)
|
|
21857
|
+
had_failure = True
|
|
21858
|
+
if not continue_on_error:
|
|
21859
|
+
sys.exit(1)
|
|
21860
|
+
|
|
21861
|
+
if had_failure:
|
|
21862
|
+
sys.exit(1)
|
|
21863
|
+
|
|
21864
|
+
|
|
21865
|
+
def cmd_replay_dispatch(args):
|
|
21866
|
+
"""Dispatch cdpilot replay command."""
|
|
21867
|
+
if not args or args[0] in ('--help', '-h', 'help'):
|
|
21868
|
+
print("Usage: cdpilot replay <flow.json> [--var k=v ...] [--delay real|none|<ms>] [--dry-run] [--continue]\n",
|
|
21869
|
+
file=sys.stderr)
|
|
21870
|
+
sys.exit(0 if (args and args[0] in ('--help', '-h', 'help')) else 2)
|
|
21871
|
+
cmd_replay(*args)
|
|
21872
|
+
|
|
21873
|
+
|
|
21874
|
+
# ─── CLI ───
|
|
21875
|
+
|
|
21876
|
+
if __name__ == "__main__":
|
|
19907
21877
|
# On Windows a pipe (an agent's shell, the MCP server, `> file`) gets the
|
|
19908
21878
|
# ANSI code page, so a Turkish page title or an emoji crashed the command
|
|
19909
21879
|
# with UnicodeEncodeError. Write UTF-8 unless the user chose an encoding.
|
|
@@ -19935,7 +21905,7 @@ if __name__ == "__main__":
|
|
|
19935
21905
|
# work in CI and for users who skip the Node wrapper entirely.
|
|
19936
21906
|
if cmd in ('--version', '-v'):
|
|
19937
21907
|
cmd = 'version'
|
|
19938
|
-
elif cmd in ('--help', '-h'):
|
|
21908
|
+
elif cmd in ('--help', '-h', 'help'):
|
|
19939
21909
|
print(__doc__)
|
|
19940
21910
|
sys.exit(0)
|
|
19941
21911
|
|
|
@@ -19955,6 +21925,17 @@ if __name__ == "__main__":
|
|
|
19955
21925
|
print(f"cdpilot: {_e}", file=sys.stderr)
|
|
19956
21926
|
sys.exit(2)
|
|
19957
21927
|
|
|
21928
|
+
if cmd == "network":
|
|
21929
|
+
for _i, _a in enumerate(args):
|
|
21930
|
+
if _a == "--":
|
|
21931
|
+
break
|
|
21932
|
+
if _a == "--har":
|
|
21933
|
+
if _i + 1 >= len(args):
|
|
21934
|
+
_har_usage_exit()
|
|
21935
|
+
elif _a.startswith("--har="):
|
|
21936
|
+
if not _a.split("=", 1)[1]:
|
|
21937
|
+
_har_usage_exit()
|
|
21938
|
+
|
|
19958
21939
|
# Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
|
|
19959
21940
|
# forks this process with the flag so the screencast consumer runs in
|
|
19960
21941
|
# the background while the foreground process returns immediately.
|
|
@@ -20025,6 +22006,8 @@ if __name__ == "__main__":
|
|
|
20025
22006
|
'watch': lambda: _dispatch_watch_cmd(args),
|
|
20026
22007
|
'bot-auth': lambda: cmd_bot_auth(*args),
|
|
20027
22008
|
'log': lambda: cmd_log(*args),
|
|
22009
|
+
'record': lambda: cmd_record_dispatch(args),
|
|
22010
|
+
'replay': lambda: cmd_replay_dispatch(args),
|
|
20028
22011
|
'connect': lambda: cmd_connect(*args),
|
|
20029
22012
|
'disconnect': cmd_disconnect,
|
|
20030
22013
|
}
|
|
@@ -20041,7 +22024,13 @@ if __name__ == "__main__":
|
|
|
20041
22024
|
sys.exit(0)
|
|
20042
22025
|
|
|
20043
22026
|
if cmd == "mcp":
|
|
20044
|
-
|
|
22027
|
+
_spec = None
|
|
22028
|
+
for _i, _a in enumerate(args):
|
|
22029
|
+
if _a == '--tools' and _i + 1 < len(args):
|
|
22030
|
+
_spec = args[_i + 1]
|
|
22031
|
+
elif _a.startswith('--tools='):
|
|
22032
|
+
_spec = _a.split('=', 1)[1]
|
|
22033
|
+
server = MCPServer(_spec)
|
|
20045
22034
|
server.run()
|
|
20046
22035
|
sys.exit(0)
|
|
20047
22036
|
|
|
@@ -20085,6 +22074,39 @@ if __name__ == "__main__":
|
|
|
20085
22074
|
print(f"Usage: {usage}")
|
|
20086
22075
|
sys.exit(1)
|
|
20087
22076
|
|
|
22077
|
+
def _dispatch_network(net_args):
|
|
22078
|
+
"""`network [url] [--har <file>]`; everything after `--` is positional
|
|
22079
|
+
(the MCP server passes the url that way), so an argument that merely
|
|
22080
|
+
looks like `--har=<path>` can never pick the output file."""
|
|
22081
|
+
url = None
|
|
22082
|
+
har_file = None
|
|
22083
|
+
i = 0
|
|
22084
|
+
while i < len(net_args):
|
|
22085
|
+
a = net_args[i]
|
|
22086
|
+
if a == "--":
|
|
22087
|
+
rest = net_args[i + 1:]
|
|
22088
|
+
if url is None and rest:
|
|
22089
|
+
url = rest[0]
|
|
22090
|
+
break
|
|
22091
|
+
if a == "--har":
|
|
22092
|
+
if i + 1 >= len(net_args):
|
|
22093
|
+
_har_usage_exit()
|
|
22094
|
+
har_file = net_args[i + 1]
|
|
22095
|
+
i += 2
|
|
22096
|
+
elif a.startswith("--har="):
|
|
22097
|
+
val = a.split("=", 1)[1]
|
|
22098
|
+
if not val:
|
|
22099
|
+
_har_usage_exit()
|
|
22100
|
+
har_file = val
|
|
22101
|
+
i += 1
|
|
22102
|
+
elif a.startswith("--"):
|
|
22103
|
+
i += 1
|
|
22104
|
+
else:
|
|
22105
|
+
if url is None:
|
|
22106
|
+
url = a
|
|
22107
|
+
i += 1
|
|
22108
|
+
return cmd_network(url=url, har=har_file)
|
|
22109
|
+
|
|
20088
22110
|
async_map = {
|
|
20089
22111
|
"go": lambda: (require_args(1, "go <url>"), cmd_go(args[0]))[1] if not args else cmd_go(args[0]),
|
|
20090
22112
|
"content": cmd_content,
|
|
@@ -20126,7 +22148,7 @@ if __name__ == "__main__":
|
|
|
20126
22148
|
),
|
|
20127
22149
|
"wait": lambda: (require_args(1, "wait <selector>"), None)[1] if not args else cmd_wait(args[0], int(args[1]) if len(args) > 1 else 5),
|
|
20128
22150
|
"tabs": cmd_tabs,
|
|
20129
|
-
"network": lambda:
|
|
22151
|
+
"network": lambda: _dispatch_network(args),
|
|
20130
22152
|
"console": lambda: cmd_console(args[0] if args else None),
|
|
20131
22153
|
"cookies": lambda: cmd_cookies(*args),
|
|
20132
22154
|
"storage": cmd_storage,
|