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/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 (same-origin or cross-origin; nest with >>>):
12
- cdpilot click "iframe#card >>> input[name=cardnumber]" # or --frame <sel|index|url>
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.4"
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
- WAIT_AND_QUERY_JS = """
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 el = document.querySelector(selector);
508
- if (el) { resolve(el); return; }
509
- var obs = new MutationObserver(function() {
510
- var found = document.querySelector(selector);
511
- if (found) { obs.disconnect(); resolve(found); }
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
- setTimeout(function() { obs.disconnect(); resolve(null); }, timeout || 5000);
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 (lsof
2560
- there scans every process and took >30 s), `ss -tnp` on Linux (lsof as a
2561
- fallback), `netstat -ano` on Windows."""
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 run(argv):
2726
+ def _default_run(argv):
2565
2727
  r = subprocess.run(argv, capture_output=True, text=True, timeout=5)
2566
- return r.stdout if r.returncode == 0 else None
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
- return None if out is None else _cdp_client_pids_bsd_netstat(out, port)
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
- r = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:ESTABLISHED", "-F", "pn"],
2582
- capture_output=True, text=True, timeout=5)
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 len(parts) < 6 or not parts[0].startswith("tcp") or parts[5] != "ESTABLISHED":
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 not first_hop_is_frame:
3953
- return [], target, f"note: '{names[0]}' matched no iframe; used the selector as written"
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-* text, reads as a
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
- found = False
4511
- if cand is not None:
4512
- first = _parse_frame_hop(cand[0][0], implicit=True)
4513
- found = await _frame_route_push(route, first, missing_ok=True)
4514
- hops, selector, note = _frame_selector_plan(target, found, kind)
4515
- for hop in hops[1:]:
4516
- await _frame_route_push(route, hop)
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
- async def navigate_collect(ws_url, url, network=False, console=False, glow=True):
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
- var el = document.querySelector({safe_sel});
7190
- if (!el) return null;
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
- const form = document.querySelector({safe_sel});
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"""new Promise((resolve) => {{
8074
- const el = document.querySelector({safe_sel});
8075
- if (el) return resolve('Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim());
8076
- const obs = new MutationObserver(() => {{
8077
- const el = document.querySelector({safe_sel});
8078
- if (el) {{ obs.disconnect(); resolve('Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim()); }}
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
- obs.observe(document.body, {{childList:true, subtree:true}});
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
- print(r.get(1, {}).get("result", {}).get("value", "?"))
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
- async def cmd_network(url=None):
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
- var el = document.querySelector({json.dumps(selector)});
13141
- if (!el) return null;
13142
- var r = el.getBoundingClientRect();
13143
- return {{x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2)}};
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 el = document.querySelector(sel);
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
- return 'PASS: ' + tag + (txt ? ' "' + txt + '"' : '') + ' (' + sel + ')';
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
14463
- print(result)
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 el = document.querySelector(sel);
14475
- if (el) return resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"');
14476
- var obs = new MutationObserver(function() {{
14477
- var el = document.querySelector(sel);
14478
- if (el) {{ obs.disconnect(); resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"'); }}
14479
- }});
14480
- obs.observe(document.body, {{childList: true, subtree: true}});
14481
- setTimeout(function() {{ obs.disconnect(); resolve('TIMEOUT: ' + sel + ' not found after {timeout_ms}ms'); }}, {timeout_ms});
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
14487
- print(result)
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
- var el = document.querySelector({safe_sel});
14565
- if (!el) return 'FAIL: Not found: ' + {safe_sel};
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
- return 'PASS: ' + el.tagName + ' (' + {safe_sel} + ')';
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
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
- var el = document.querySelector({safe_sel});
14637
- if (!el) return 'FAIL: Element not found: ' + {safe_sel};
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
- if (val === expected) return 'PASS: Value matches \"' + expected + '\"';
14641
- return 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
14645
- print(result)
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
- var el = document.querySelector({safe_sel});
14656
- if (!el) return 'FAIL: Element not found: ' + {safe_sel};
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
- if (val.indexOf(expected) !== -1) return 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"';
14660
- return 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
14664
- print(result)
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
- var el = document.querySelector({safe_sel});
14675
- if (!el) return 'FAIL: Element not found: ' + {safe_sel};
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
- if (isVisible === expectVisible) return 'PASS: ' + {safe_sel} + ' is {expect_label}';
14686
- return 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
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
- result = r.get(1, {}).get("result", {}).get("value", "ERROR")
14690
- print(result)
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
- var el=document.querySelector({json.dumps(selector)});
16171
- if(!el) return null;
16172
- var r=el.getBoundingClientRect();
16173
- return {{top: r.top, height: r.height}};
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(){{ var el=document.querySelector({json.dumps(selector)}); if(!el) return false; el.scrollIntoView({{behavior:'instant',block:'center'}}); return true; }})()"
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
- ok = res.get(1, {}).get("result", {}).get("value", False)
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
- let tools;
16661
- try {
16662
- tools = await document.modelContext.getTools();
16663
- } catch (e) {
16664
- return { facts, error: { name: String(e && e.name), message: String(e && e.message) } };
16665
- }
16666
- const out = [];
16667
- for (const t of tools) {
16668
- let schema = t.inputSchema;
16669
- if (typeof schema === 'string') {
16670
- try { schema = JSON.parse(schema); } catch (e) { /* keep the string */ }
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
- let frame = null, form = null;
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
- if (t.window !== window) frame = t.window.location.href;
16675
- form = t.window.document.querySelector('form[toolname="' + CSS.escape(t.name) + '"]');
16676
- } catch (e) { /* not readable: leave frame/form unknown */ }
16677
- const rec = { name: t.name, title: t.title || '', description: t.description,
16678
- inputSchema: schema === undefined ? null : schema,
16679
- origin: t.origin, frame: frame, declarative: !!form };
16680
- if (form) rec.autosubmit = form.hasAttribute('toolautosubmit');
16681
- if (t.annotations) rec.annotations = t.annotations;
16682
- out.push(rec);
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 mc = document.modelContext;
16692
- if (!mc || typeof mc.executeTool !== 'function') return { status: 'unavailable' };
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
- const tools = await mc.getTools();
16695
- const tool = tools.find((t) => {
16696
- if (t.name !== name) return false;
16697
- try { return frame === null ? t.window === window : t.window.location.href === frame; }
16698
- catch (e) { return false; }
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
- def __init__(self):
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 base tools, plus the WebMCP ones while the mode is on."""
17060
- return self.tools + (self.WEBMCP_TOOLS if get_webmcp_config() else [])
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 (cross-origin too): 'iframe#card >>> button#pay'"}}, "required": ["selector"]}},
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 (cross-origin too): 'iframe#card >>> input[name=cardnumber]'"}, "text": {"type": "string", "description": "Text value to type into the element"}}, "required": ["selector", "text"]}},
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 (cross-origin too): 'iframe#card >>> input[name=cardnumber]'"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
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
- return self._execute_tool(req_id, params.get("name", ""), params.get("arguments", {}))
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
- "browser_network": lambda a: ["network"] + ([a["url"]] if a.get("url") else []),
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
- sys.stdout.flush()
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 or not _slog_enabled():
20709
+ if not argv:
19641
20710
  return
19642
20711
  cmd = SLOG_CMD_ALIASES.get(argv[0], argv[0])
19643
- if cmd in SLOG_SKIP_CMDS or cmd in (WATCH_DAEMON_FLAG, BOT_AUTH_SIGNER_FLAG,
19644
- BOT_AUTH_LABEL_FLAG):
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
- _slog_append(_slog_build_entry(
19687
- st["cmd"], st["args"], code, int((time.monotonic() - st["t0"]) * 1000),
19688
- st["started"], url=st.get("url"), title=st.get("title"), stdout=out, stderr=err,
19689
- error=forced_error or st.get("error"), files=_slog_files((out, err), st["t0_wall"]),
19690
- via=st.get("via")))
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 or not _slog_enabled():
20775
+ if not argv:
19700
20776
  return
19701
20777
  try:
19702
20778
  cmd = SLOG_CMD_ALIASES.get(argv[0], argv[0])
19703
- _slog_append(_slog_build_entry(cmd, argv[1:], code, duration_ms, started,
19704
- error=error, via=via))
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
- # ─── CLI ───
20983
+ # ─── Record & Replay (issue #5) ───
19905
20984
 
19906
- if __name__ == "__main__":
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
- server = MCPServer()
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: cmd_network(args[0] if args else None),
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,