cdpilot 0.9.6 → 0.9.7

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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- cdpilot — Zero-dependency browser automation from your terminal.
3
+ cdpilot — Zero-npm-dependency browser automation from your terminal.
4
4
 
5
5
  Controls any Chromium-based browser (Brave, Chrome, Chromium) via the
6
6
  Chrome DevTools Protocol (CDP). No Puppeteer, no Playwright, no Selenium.
@@ -39,7 +39,8 @@ Environment:
39
39
  CDPILOT_PROFILE Isolated browser profile directory
40
40
  CDPILOT_LOG=0 Do not write the session log
41
41
  CDPILOT_VAR_<NAME> Value for flow placeholder {{name}} in replay / agent run
42
- CDPILOT_VAR_HOSTS Hosts (comma-separated) an agent flow may type CDPILOT_VAR_* values on
42
+ CDPILOT_VAR_HOSTS Hosts (comma-separated; host:port for a non-default port) an agent
43
+ flow or a replayed input step may type CDPILOT_VAR_* values on (https)
43
44
  CDPILOT_TIMEOUT Default for --timeout (the flag wins)
44
45
  CDPILOT_DAEMON=1 Keep one warm Python process per project and send page
45
46
  commands to it over a local socket (fresh process when
@@ -91,17 +92,23 @@ Record & replay:
91
92
  cdpilot record cancel Cancel and discard active recording
92
93
  cdpilot record stop [--save <file.json>] Stop recording and emit flow JSON
93
94
  cdpilot replay <file.json> [--var k=v] [--delay real|none|<ms>] [--dry-run] [--continue]
94
- Replay a recorded flow with variable substitution
95
+ Replay a recorded flow with variable substitution. --var fills any
96
+ {{name}}; CDPILOT_VAR_<NAME> fills only the value of an input step
97
+ (fill/type/smart-fill/…) on an https page whose host CDPILOT_VAR_HOSTS
98
+ names, checked before and while typing, and only in a flow with no
99
+ eval/run/batch-like step; go/eval and other commands take --var (exit 2)
95
100
 
96
101
  Exit codes: 0 ok, 1 error (batch / run: a step failed), 124 --timeout;
102
+ 2 agent: unknown flow, missing or refused variable, {{placeholder}} in --text/--url;
97
103
  exit 3 from click / smart-click (batch / run: one, and no failed step):
98
104
  pressed, release missed the target, not clicked (a real mouse click whose
99
105
  target moved or got covered under the press; see the stderr note). A target
100
106
  the page replaced or navigated away from on mousedown prints
101
- "Pressed (the page replaced or left it ...)" and exits 0.
107
+ "Pressed (the page replaced or left it ...)" and exits 0;
108
+ 4 agent run: drift (page observation on stdout).
102
109
  """
103
110
 
104
- __version__ = "0.9.6"
111
+ __version__ = "0.9.7"
105
112
 
106
113
  import asyncio
107
114
  import atexit
@@ -1702,9 +1709,39 @@ def _autolaunch_if_down():
1702
1709
  return False
1703
1710
 
1704
1711
 
1712
+ def _external_override_message():
1713
+ """If CDP_PORT in the environment overrides an external browser's port in
1714
+ the registry, return the override advice message; otherwise None."""
1715
+ project_id = globals().get("PROJECT_ID")
1716
+ if not project_id:
1717
+ return None
1718
+ registry = _load_registry()
1719
+ entry = registry.get(project_id)
1720
+ if not isinstance(entry, dict) or not entry.get("external"):
1721
+ return None
1722
+ reg_port = _as_port(entry.get("port"))
1723
+ if reg_port is None:
1724
+ return None
1725
+ env_port_str = os.environ.get("CDP_PORT")
1726
+ if not env_port_str or env_port_str == "0":
1727
+ return None
1728
+ env_port = _as_port(env_port_str)
1729
+ if env_port is None:
1730
+ return None
1731
+ cur_port = globals().get("CDP_PORT")
1732
+ if cur_port != env_port or cur_port == reg_port:
1733
+ return None
1734
+ return (f"cdpilot: CDP_PORT={env_port} in the environment overrides the "
1735
+ f"connected browser on port {reg_port}; unset CDP_PORT or set it to {reg_port}")
1736
+
1737
+
1705
1738
  def _external_gone_exit():
1706
1739
  """The connected (external) browser stopped answering: say so, exit 1.
1707
1740
  Never launches a replacement."""
1741
+ override = _external_override_message()
1742
+ if override:
1743
+ print(override, file=sys.stderr)
1744
+ sys.exit(1)
1708
1745
  print(f"cdpilot: {EXTERNAL_GONE}", file=sys.stderr)
1709
1746
  sys.exit(1)
1710
1747
 
@@ -4868,6 +4905,10 @@ def _frame_aware(kind="css"):
4868
4905
  async def wrapper(target, *args, **kwargs):
4869
4906
  if _FRAME_ROUTE.get() is not None or not isinstance(target, str):
4870
4907
  return await fn(target, *args, **kwargs)
4908
+ if _replay_strict_rule() is not None and (_FRAME_FLAG or FRAME_SEP in target):
4909
+ # a replayed environment value never goes into a frame (no lookup either)
4910
+ _replay_strict_refuse(fn.__name__.replace('cmd_', '').replace('_', '-'),
4911
+ 'never inside a frame (>>>, --frame)')
4871
4912
  try:
4872
4913
  flag_hops = _parse_frame_spec(_FRAME_FLAG) if _FRAME_FLAG else []
4873
4914
  except ValueError as e:
@@ -7108,6 +7149,10 @@ def cmd_launch(auto=False, idle_close=None):
7108
7149
  print(f'Connected browser in use on port {CDP_PORT} (external); '
7109
7150
  'no cdpilot browser launched. `cdpilot disconnect` to use one.')
7110
7151
  return
7152
+ override = _external_override_message()
7153
+ if override:
7154
+ print(override, file=sys.stderr)
7155
+ sys.exit(1)
7111
7156
  print(EXTERNAL_GONE, file=sys.stderr)
7112
7157
  sys.exit(1)
7113
7158
  # This command's own flags (sys.argv minus --timeout); none for auto-launch.
@@ -7299,7 +7344,8 @@ def cmd_launch(auto=False, idle_close=None):
7299
7344
  print(f' WebMCP: on (--enable-features={WEBMCP_FEATURE})')
7300
7345
 
7301
7346
  try:
7302
- proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
7347
+ # Nothing reads the pipe; a browser outliving a long-lived (warm) process must not block on a full or closed pipe.
7348
+ proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
7303
7349
  except OSError as e:
7304
7350
  # Windows' FileNotFoundError ("[WinError 2] The system cannot find the
7305
7351
  # file specified") does not name the binary; say which one, on every OS,
@@ -7755,7 +7801,7 @@ async def cmd_eval_batch(exprs_json):
7755
7801
  observations (e.g. read 12 DOM values to fill a report), N sequential
7756
7802
  `eval` commands cost N × roundtrip. This packs them into one IIFE that
7757
7803
  runs all N expressions and returns the result array — one roundtrip
7758
- total. Typical speedup: 5-30x for batches of 5+ expressions.
7804
+ total. Run N JS expressions in one process and one CDP roundtrip instead of N.
7759
7805
  """
7760
7806
  try:
7761
7807
  exprs = json.loads(exprs_json) if isinstance(exprs_json, str) else exprs_json
@@ -8394,6 +8440,15 @@ async def cmd_click(selector, ladder=None, no_heal=False, entropy=None):
8394
8440
 
8395
8441
  @_frame_aware("css")
8396
8442
  async def cmd_fill(selector, value, ladder=None, no_heal=False, entropy=None):
8443
+ strict = _replay_strict_rule()
8444
+ if strict is not None:
8445
+ # A replayed environment value: never inside a frame, never typed key
8446
+ # by key (--entropy=on: the keys follow focus wherever it moves).
8447
+ if _FRAME_ROUTE.get() is not None:
8448
+ _replay_strict_refuse('fill', 'never inside a frame (>>>, --frame)')
8449
+ if entropy:
8450
+ _replay_strict_refuse('fill', 'never with humanized typing (--entropy=on)')
8451
+ entropy = False
8397
8452
  ws, _ = get_page_ws()
8398
8453
  if entropy is None:
8399
8454
  try:
@@ -8411,8 +8466,27 @@ async def cmd_fill(selector, value, ladder=None, no_heal=False, entropy=None):
8411
8466
  if len(tried) > 1 or (tried and not tried[0]["hit"]):
8412
8467
  _log_heal("fill", selector, tried, dur, no_heal)
8413
8468
  safe_sel = json.dumps(res_sel)
8414
- safe_value = json.dumps(value)
8415
8469
  wait_ms = get_auto_wait_ms()
8470
+ if strict is not None:
8471
+ find_js = f"""
8472
+ (function() {{
8473
+ {WAIT_AND_QUERY_JS}
8474
+ return __cdpilot_waitFor({safe_sel}, {wait_ms}).then(function(el) {{
8475
+ /* cdpilot-var-find */
8476
+ if (!el) return 'Timeout waiting for: ' + {safe_sel};
8477
+ if (el.hasAttribute('data-cdpilot-tmp')) el.removeAttribute('data-cdpilot-tmp');
8478
+ return el;
8479
+ }});
8480
+ }})()"""
8481
+ out = await _replay_strict_act(ws, find_js, _replay_strict_value('fill', value), strict, 'fill')
8482
+ if out.get('refused'):
8483
+ _replay_strict_refuse('fill', out['refused'])
8484
+ if not out.get('ok'):
8485
+ print(out.get('missing') or f"Error: selector '{selector}' not resolved.", file=sys.stderr)
8486
+ sys.exit(1)
8487
+ print(f"Filled: {out.get('tag', '?')} (value not shown)")
8488
+ return
8489
+ safe_value = json.dumps(value)
8416
8490
  if entropy:
8417
8491
  # Focus element, field-focus pause, then humanize typing
8418
8492
  js_focus = f"""
@@ -13907,6 +13981,14 @@ def _do_connect(port, ws_url, version_info, browser_name):
13907
13981
  print(f" Tabs: {tab_count}")
13908
13982
  print(" Commands now run in YOUR browser; cdpilot will never close it.")
13909
13983
 
13984
+ env_port_str = os.environ.get("CDP_PORT")
13985
+ if env_port_str and env_port_str != "0":
13986
+ env_port = _as_port(env_port_str)
13987
+ if env_port is not None and env_port != port:
13988
+ print(f"cdpilot: CDP_PORT={env_port} in the environment overrides the "
13989
+ f"connected browser on port {port}; unset CDP_PORT or set it to {port}",
13990
+ file=sys.stderr)
13991
+
13910
13992
 
13911
13993
  def _forget_project_tabs():
13912
13994
  """Drop this project's session and tab records. A session made before
@@ -14494,7 +14576,8 @@ async def cmd_smart_click(text):
14494
14576
  """Click element by visible text — fuzzy matching, no CSS selector needed.
14495
14577
 
14496
14578
  Like Stagehand's act("Click login") but without LLM.
14497
- Searches: button text, link text, aria-label, placeholder, title, value.
14579
+ Searches: button text, link text, aria-label, placeholder, title, the value of a
14580
+ button-like <input> (never a text field's typed value).
14498
14581
 
14499
14582
  Usage:
14500
14583
  cdpilot smart-click "Login"
@@ -14574,12 +14657,16 @@ async def cmd_smart_click(text):
14574
14657
  var style = window.getComputedStyle(el);
14575
14658
  if (style.display === 'none' || style.visibility === 'hidden') return;
14576
14659
 
14660
+ // .value is a label only on a button-like <input>; a text field caught by
14661
+ // [tabindex] holds what was typed (round-2 review: smart-click matched it).
14662
+ var labelValue = el.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(el.type || '')
14663
+ ? (el.value || '') : '';
14577
14664
  var texts = [
14578
14665
  el.textContent || '',
14579
14666
  el.getAttribute('aria-label') || '',
14580
14667
  el.getAttribute('title') || '',
14581
14668
  el.getAttribute('placeholder') || '',
14582
- el.value || '',
14669
+ labelValue,
14583
14670
  el.getAttribute('alt') || ''
14584
14671
  ];
14585
14672
 
@@ -14902,9 +14989,14 @@ async def cmd_smart_fill(text, value):
14902
14989
  cdpilot smart-fill "Password" "secret123"
14903
14990
  cdpilot smart-fill "Search" "cdpilot"
14904
14991
  """
14992
+ strict = _replay_strict_rule()
14993
+ if strict is not None and _FRAME_ROUTE.get() is not None:
14994
+ _replay_strict_refuse('smart-fill', 'never inside a frame (>>>, --frame)')
14905
14995
  ws_url, _ = get_page_ws()
14906
14996
  safe_text = json.dumps(text.lower())
14907
- safe_value = json.dumps(value)
14997
+ # A replayed environment value is not part of the script: the script
14998
+ # returns the field and _VAR_TARGET_FN checks and fills it.
14999
+ safe_value = 'null' if strict is not None else json.dumps(value)
14908
15000
  js = f"""
14909
15001
  (function() {{
14910
15002
  // Shadow DOM traversal — same helper as smart-click. Custom-element
@@ -15015,6 +15107,7 @@ async def cmd_smart_fill(text, value):
15015
15107
  var best = candidates[0];
15016
15108
  // Frame-search probe (_smart_wrap probe=True): report the hit, never type.
15017
15109
  if (typeof __cdpilotProbe !== 'undefined' && __cdpilotProbe) return JSON.stringify({{found: true, probe: true, score: best.score}});
15110
+ {'/* cdpilot-var-find */ return best.el;' if strict is not None else ''}
15018
15111
 
15019
15112
  // React-compatible value setting
15020
15113
  var nativeSetter = Object.getOwnPropertyDescriptor(
@@ -15040,6 +15133,15 @@ async def cmd_smart_fill(text, value):
15040
15133
  }});
15041
15134
  }})()
15042
15135
  """
15136
+ if strict is not None: # the top-level page only: no frame search
15137
+ out = await _replay_strict_act(ws_url, js, _replay_strict_value('smart-fill', value), strict, 'fill')
15138
+ if out.get('refused'):
15139
+ _replay_strict_refuse('smart-fill', out['refused'])
15140
+ if not out.get('ok'):
15141
+ print(f'No input found matching: "{text}"', file=sys.stderr)
15142
+ sys.exit(1)
15143
+ print(f'Filled: {out.get("tag", "?")} "{text}" (value not shown)')
15144
+ return
15043
15145
  raw = await _smart_eval(ws_url, js, "smart-fill", same_origin_only=True)
15044
15146
  try:
15045
15147
  data = json.loads(raw)
@@ -15063,9 +15165,13 @@ async def cmd_smart_select(text, option_text):
15063
15165
  cdpilot smart-select "Country" "Turkey"
15064
15166
  cdpilot smart-select "Size" "Large"
15065
15167
  """
15168
+ strict = _replay_strict_rule()
15169
+ if strict is not None and _FRAME_ROUTE.get() is not None:
15170
+ _replay_strict_refuse('smart-select', 'never inside a frame (>>>, --frame)')
15066
15171
  ws_url, _ = get_page_ws()
15067
15172
  safe_text = json.dumps(text.lower())
15068
- safe_option = json.dumps(option_text.lower())
15173
+ # A replayed environment value (the option) is not part of the script, as in smart-fill.
15174
+ safe_option = 'null' if strict is not None else json.dumps(option_text.lower())
15069
15175
  js = f"""
15070
15176
  (function() {{
15071
15177
  // Shadow DOM + locale-aware comparison — same conventions as smart-click
@@ -15136,6 +15242,7 @@ async def cmd_smart_select(text, option_text):
15136
15242
  }}
15137
15243
  // Frame-search probe (_smart_wrap probe=True): report the hit, never select.
15138
15244
  if (typeof __cdpilotProbe !== 'undefined' && __cdpilotProbe) return JSON.stringify({{found: true, probe: true, score: bestScore}});
15245
+ {'/* cdpilot-var-find */ return best;' if strict is not None else ''}
15139
15246
 
15140
15247
  // Find matching option
15141
15248
  var options = Array.from(best.options);
@@ -15152,6 +15259,17 @@ async def cmd_smart_select(text, option_text):
15152
15259
  return JSON.stringify({{found: true, optionFound: true, selected: match.text, value: match.value}});
15153
15260
  }})()
15154
15261
  """
15262
+ if strict is not None: # the top-level page only: no frame search
15263
+ out = await _replay_strict_act(ws_url, js, _replay_strict_value('smart-select', option_text),
15264
+ strict, 'select')
15265
+ if out.get('refused'):
15266
+ _replay_strict_refuse('smart-select', out['refused'])
15267
+ if not out.get('ok'):
15268
+ print(f'No select found matching: "{text}"' if not out.get('missing', '').startswith('no option')
15269
+ else f'Option not found in the select matching "{text}" (option not shown)', file=sys.stderr)
15270
+ sys.exit(1)
15271
+ print(f'Selected an option in "{text}" (value not shown)')
15272
+ return
15155
15273
  raw = await _smart_eval(ws_url, js, "smart-select", same_origin_only=True)
15156
15274
  try:
15157
15275
  data = json.loads(raw)
@@ -15508,6 +15626,27 @@ async def cmd_assert_value(selector, expected_value):
15508
15626
  """Assert an input/textarea/select element's value equals expected_value."""
15509
15627
  ws_url, _ = get_page_ws()
15510
15628
  safe_sel = json.dumps(selector)
15629
+ strict = _replay_strict_rule()
15630
+ if strict is not None:
15631
+ # A replayed environment value: compared inside _VAR_TARGET_FN, never
15632
+ # put in a script, never printed (nor the field's actual value).
15633
+ find_js = f"""(function() {{
15634
+ {_SHADOW_QUERY_JS}
15635
+ /* cdpilot-var-find */
15636
+ var res = __cdpilot_resolveSelector(document, {safe_sel}, true);
15637
+ return res && res.el ? res.el : 'FAIL: Element not found: ' + {safe_sel};
15638
+ }})()"""
15639
+ out = await _replay_strict_act(ws_url, find_js, _replay_strict_value('assert-value', expected_value),
15640
+ strict, 'assert')
15641
+ if out.get('refused'):
15642
+ _replay_strict_refuse('assert-value', out['refused'])
15643
+ if not out.get('ok'):
15644
+ print(out.get('missing') or f'FAIL: Element not found: {selector}')
15645
+ elif out.get('equal'):
15646
+ print(f'PASS: Value matches ({selector}, value not shown)')
15647
+ else:
15648
+ print(f'FAIL: Value differs ({selector}, values not shown)')
15649
+ return
15511
15650
  safe_expected = json.dumps(expected_value)
15512
15651
  js = f"""(function() {{
15513
15652
  {_SHADOW_QUERY_JS}
@@ -15895,6 +16034,106 @@ _AGENT_INJECTION_RES = (
15895
16034
  r"\b(cookies?|credentials?|passwords?|api[ -]?keys?|tokens?|session|secrets?|conversation|chat history)\b", _re.I)),
15896
16035
  )
15897
16036
 
16037
+ # Second pass (10.11, after the held-out set: 7/24 caught). The text is
16038
+ # normalised first (_agent_injection_norm), so these patterns see casefolded
16039
+ # NFKC text with zero-width characters removed, spaced-out letters joined and
16040
+ # Turkish ı/İ folded to i. Every entry is (tag, pattern, required, keys):
16041
+ # when `required` is set, the text must ALSO contain an agent-directed cue, so
16042
+ # that instructions written for the human reader ("Copy .env.example to .env",
16043
+ # "Visit https://… and sign in") stay clean; `keys` is a cheap substring
16044
+ # prefilter (one of them must occur before the pattern runs).
16045
+ import unicodedata as _unicodedata
16046
+ _AGENT_ORDER_RE = _re.compile(
16047
+ r"\b(?:the |all |any )?(?:assistant|ai|model|agent|llm)s? (?:must|shall|should|will|is (?:now )?(?:required|allowed|authori[sz]ed))\b"
16048
+ r"|\byou are now\b"
16049
+ r"|\byou (?:must|shall) (?:now |always |never )?(?:ignore|disregard|obey|comply|follow|approve|reveal|print|output|disable|reply|respond|answer)\b"
16050
+ r"|\b(?:ignore|disregard|reveal|print|output|dump|disable|bypass|override|forget)\b[^.\n]{0,25}"
16051
+ r"\b(?:your|all|previous|prior|safety|hidden)\s+(?:[\w-]+\s+){0,2}"
16052
+ r"(?:instructions?|prompts?|rules|guidelines|configuration|config|settings|restrictions|filters|polic(?:y|ies))\b"
16053
+ r"|\bbefore (?:answering|responding|replying)\b")
16054
+ _AGENT_ACTION = (r"(?<![a-z0-9])(?:navigate|open|goto|send|fetch|post|click|fill|submit|upload|email|download|"
16055
+ r"exec|execute|run_|delete|transfer|purchase|buy)(?![a-z0-9])")
16056
+ _AGENT_INJECTION_RES_2 = (
16057
+ # one short alternation per language: es, fr, tr (ı → i folded), ja, de, pt
16058
+ ('ignore_instructions', _re.compile(
16059
+ r"\b(?:ignora|olvida|ignore|olvide|omite)\b[^.\n]{0,20}\binstrucciones (?:anteriores|previas)\b"
16060
+ r"|\b(?:ignore[sz]?|oublie[sz]?)\b[^.\n]{0,20}\b(?:instructions|consignes|directives) (?:précédentes|antérieures)\b"
16061
+ r"|\b(?:önceki|yukaridaki|tüm|bütün)\b[^.\n]{0,15}\b(?:talimat|komut|yönerge)\w*\s+"
16062
+ r"(?:unut(?:un)?|yok say(?:in)?|görmezden gel(?:in)?|dikkate alma(?:yin)?)\b"
16063
+ r"|(?:以前|前|これまで|上記|先)の(?:指示|命令|指令)(?:を|は)?(?:すべて|全て)?無視"
16064
+ r"|\b(?:ignoriere|ignorieren sie|vergiss|vergessen sie|übergehe|missachte)\b[^.\n]{0,20}"
16065
+ r"\b(?:vorherigen|bisherigen|früheren|obigen) (?:anweisungen|instruktionen|befehle|regeln)\b"
16066
+ r"|\b(?:ignore|ignora|esqueça|esqueca|desconsidere)\b[^.\n]{0,20}\binstruções (?:anteriores|prévias)\b"), None,
16067
+ ('instru', 'consign', 'directiv', 'talimat', 'komut', 'yönerge', '指示', '命令', '指令', 'anweis', 'befehl', 'regeln')),
16068
+ ('addresses_ai', _re.compile(
16069
+ r"\b(?:ignore|disregard|forget)\b (?:everything|anything|all|whatever)(?: (?:that|else))? "
16070
+ r"you(?:'ve| have| were| had)? (?:been )?(?:told|taught|instructed|given|programmed)\b"
16071
+ r"|\b(?:ai|llm) (?:assistants?|agents?|models?|systems?)\b[^.\n]{0,20}\b(?:reading|visiting|processing|browsing|parsing) this\b"
16072
+ r"|\b(?:llms|large language models|language models|autonomous (?:ai )?agents?)\b[^.\n]{0,20}"
16073
+ r"\b(?:reading|visiting|processing|browsing|parsing) this\b"
16074
+ r"|\b(?:message|note|notice|attention|instructions?) (?:for|to) (?:all |any |the )?autonomous (?:ai )?agents?\b"
16075
+ r"|\bautonomous (?:ai )?agents?\s*:"
16076
+ r"|\b(?:the|your) (?:user|human|owner|operator)\b[^.\n]{0,10}\b(?:would|will|'d)(?: really| definitely| surely)? "
16077
+ r"(?:want|like|prefer) (?:you|the (?:assistant|agent|ai|model)) to\b"
16078
+ r"|\b(?:the|your) (?:user|human) (?:really )?wants (?:you|the (?:assistant|agent|ai|model)) to\b"), None,
16079
+ ('you', 'this', 'autonomous')),
16080
+ ('exfiltrate', _re.compile(
16081
+ r"\b(?:include|append|copy|read|paste|post|send|upload|attach|insert|output|print|dump|leak)\b[^.\n]{0,60}"
16082
+ r"(?:document\.cookie|\blocalstorage\b|\bsessionstorage\b|\bform (?:fields?|contents|data|values)\b"
16083
+ r"|\b(?:every|each|all) (?:form )?fields?\b|~/\.ssh|\.ssh/|\bid_(?:rsa|ed25519|ecdsa)\b|(?<![\w.])\.env\b"
16084
+ r"|\b(?:session|bearer|auth|access) tokens?\b|\bsaved (?:passwords|addresses|cards|credit cards|payment)\b"
16085
+ r"|\bprivate keys?\b)"),
16086
+ _re.compile(r"\byour (?:next )?(?:reply|replies|answer|response|output|summary)\b(?! headers?| body| object)"
16087
+ r"|\binto (?:the|this) (?:comment|search|chat|text|message|input|feedback) (?:box|field|form)\b"
16088
+ r"|\bthe user (?:has )?(?:filled|entered|typed|saved|provided)\b"
16089
+ r"|\bfrom (?:the|this) (?:local|user's|host) (?:machine|computer|disk)\b"
16090
+ r"|\byou (?:have access to|can access|can see|can read)\b"
16091
+ r"|\bbefore (?:answering|responding|replying)\b"),
16092
+ ('cookie', 'storage', 'field', 'form ', 'ssh', 'id_', '.env', 'token', 'saved ', 'private key')),
16093
+ # a line starting with SYSTEM: / [SYSTEM] / <system> / "developer message:", or "debug mode enabled",
16094
+ # followed by an order to the agent ("System requirements: …", "Developer mode lets you …" stay clean)
16095
+ ('fake_system', _re.compile(
16096
+ r"(?m)^[ \t>*#-]*(?:\[(?:system|sys|admin)\]|<\|?system\|?>|<\|im_start\|>system"
16097
+ r"|(?:system|developer message|admin(?:istrator)? message)\s*:)"
16098
+ r"|\b(?:developer|debug|admin|god|jailbreak|maintenance) mode\b[^.\n]{0,15}"
16099
+ r"\b(?:enabled|activated|engaged|unlocked|is on|turned on)\b"), _AGENT_ORDER_RE,
16100
+ ('system', 'developer message', 'admin', ' mode')),
16101
+ # JSON / XML imitating a tool call that names an action (a docs example calling "search" stays clean)
16102
+ ('tool_call', _re.compile(
16103
+ r"\"(?:tool|tool_name|function|name|action)\"\s*:\s*\"[^\"\n]{0,40}?" + _AGENT_ACTION + r"[^\"\n]{0,40}\""
16104
+ r"[\s\S]{0,200}?\"(?:arguments|parameters|params|args|input)\"\s*:"
16105
+ r"|<(?:tool_call|function_calls?|invoke|tool_use)\b[\s\S]{0,300}?" + _AGENT_ACTION), None, ('"', '<')),
16106
+ # open/visit a URL + an agent-directed cue; plain "Visit https://… and sign in" for the reader stays clean
16107
+ ('navigate_order', _re.compile(
16108
+ r"\b(?:open|navigate to|go to|visit|browse to|load)\s+(?:the (?:url|link|page|site)\s+)?(?:https?://|www\.)\S"),
16109
+ _re.compile(r"\b(?:ai|llm|browsing|autonomous|web) (?:agents?|assistants?|models?)\b"
16110
+ r"|\b(?:chatgpt|claude|gemini|copilot|assistant|agent|ai|llm)\s*[,:]"
16111
+ r"|\b(?:the |you,? )assistant (?:must|should|will|needs)\b"
16112
+ r"|\bbefore (?:answering|responding|replying)\b"
16113
+ r"|\binstead of (?:on |using |at )?(?:this|the current|the original) (?:site|page|website|domain)\b"
16114
+ r"|\bwithout (?:telling|asking|informing|notifying) the user\b|\byour (?:task|instructions)\b"),
16115
+ ('http', 'www.')),
16116
+ )
16117
+ _AGENT_ZW_RE = _re.compile('[\u200b-\u200d\u2060\ufeff\u00ad]')
16118
+ # one substring test for the whole second pass (skips it for most nodes)
16119
+ _AGENT_KEYS_RE = _re.compile('|'.join(_re.escape(k) for e in _AGENT_INJECTION_RES_2 for k in e[3]))
16120
+ # >= 5 single letters separated by one space or dot: "i g n o r e" -> "ignore"; words are not joined
16121
+ _AGENT_SPACED_RE = _re.compile(r"(?<!\w)(?:[^\W\d_][ .]){4,}[^\W\d_](?!\w)")
16122
+ _AGENT_SPACED_HINT = _re.compile(r"\b[^\W\d_][ .][^\W\d_][ .][^\W\d_][ .][^\W\d_][ .][^\W\d_]\b") # cheaper test
16123
+
16124
+
16125
+ def _agent_injection_norm(name):
16126
+ """Matching form of an accessible name (the original stays in the output)."""
16127
+ if name.isascii(): # no zero-width characters, no ı/İ; casefold == lower
16128
+ t = name.lower()
16129
+ else:
16130
+ t = _unicodedata.normalize('NFKC', _AGENT_ZW_RE.sub('', name)).casefold()
16131
+ if 'ı' in t or '\u0307' in t: # casefold turns İ into i + U+0307
16132
+ t = t.replace('ı', 'i').replace('\u0307', '')
16133
+ if _AGENT_SPACED_HINT.search(t):
16134
+ t = _AGENT_SPACED_RE.sub(lambda m: m.group(0).replace(' ', '').replace('.', ''), t)
16135
+ return t
16136
+
15898
16137
 
15899
16138
  def _agent_injection_scan(nodes, limit=5):
15900
16139
  """Nodes whose accessible name reads like instructions to an AI agent:
@@ -15906,11 +16145,15 @@ def _agent_injection_scan(nodes, limit=5):
15906
16145
  name = node.get('name', {}).get('value', '') or ''
15907
16146
  if len(name) < 12 or name in seen:
15908
16147
  continue
15909
- for tag, rx in _AGENT_INJECTION_RES:
15910
- if rx.search(name):
15911
- seen.add(name)
15912
- out.append({'text': name[:160], 'match': tag})
15913
- break
16148
+ norm = _agent_injection_norm(name)
16149
+ tag = next((t for t, rx in _AGENT_INJECTION_RES if rx.search(norm)), None)
16150
+ if tag is None and _AGENT_KEYS_RE.search(norm):
16151
+ tag = next((t for t, rx, need, keys in _AGENT_INJECTION_RES_2
16152
+ if any(k in norm for k in keys) and rx.search(norm)
16153
+ and (need is None or need.search(norm))), None)
16154
+ if tag is not None:
16155
+ seen.add(name)
16156
+ out.append({'text': name[:160], 'match': tag})
15914
16157
  if len(out) >= limit:
15915
16158
  break
15916
16159
  return out
@@ -16157,6 +16400,38 @@ async def _agent_perform(ref, action, text, old_map):
16157
16400
  sys.exit(1)
16158
16401
 
16159
16402
 
16403
+ async def _agent_type_placeholder(ref, value, origin, old_map, from_env=False):
16404
+ """`agent run`: set a placeholder's value on the node `ref` names, in one
16405
+ Runtime.callFunctionOn that requires the node in the top-level page, an
16406
+ input / textarea / contenteditable (never an iframe), and the page's origin
16407
+ equal to `origin` (the recorded one; for a flow saved with a host only, the
16408
+ page's own, whose host matched). An environment value (`from_env`) never
16409
+ goes into a search or url field. None when set, else why (no value)."""
16410
+ if not origin:
16411
+ return 'the page has an opaque origin'
16412
+ bid = old_map[ref]['backend_node_id']
16413
+ ws_url, _ = get_page_ws()
16414
+ global _WS_POOL_ENABLED
16415
+ was, _WS_POOL_ENABLED = _WS_POOL_ENABLED, True # an objectId lives in one CDP session
16416
+ try:
16417
+ await cdp_send(ws_url, [(1, 'DOM.enable', {})])
16418
+ res_r = await cdp_send(ws_url, [(2, 'DOM.resolveNode', {
16419
+ 'backendNodeId': bid, 'objectGroup': _VAR_TARGET_GROUP})])
16420
+ oid = res_r.get(2, {}).get('object', {}).get('objectId')
16421
+ if not oid:
16422
+ return 'the field is gone'
16423
+ out = await _var_target_call(ws_url, oid, value,
16424
+ {'kind': 'fill', 'origin': origin, 'hosts': None, 'ce': True,
16425
+ 'noUrlFields': bool(from_env)})
16426
+ finally:
16427
+ try:
16428
+ await cdp_send(ws_url, [(3, 'Runtime.releaseObjectGroup', {'objectGroup': _VAR_TARGET_GROUP})])
16429
+ except Exception:
16430
+ pass
16431
+ _WS_POOL_ENABLED = was
16432
+ return None if out.get('ok') else (out.get('refused') or out.get('missing') or 'not set')
16433
+
16434
+
16160
16435
  async def cmd_agent_act(ref=None, url=None, action='click', text=None):
16161
16436
  """Perform action, return diff observation."""
16162
16437
  state = _load_agent_state()
@@ -16360,12 +16635,47 @@ def _agent_origin(url):
16360
16635
  AGENT_VAR_HOSTS_ENV = 'CDPILOT_VAR_HOSTS'
16361
16636
 
16362
16637
 
16638
+ def _var_host_entries(raw=None):
16639
+ """CDPILOT_VAR_HOSTS (or `raw`) as [(host, port or None)]. An entry is `host`
16640
+ (the scheme's default port; a listed host covers its subdomains) or
16641
+ `host:port` / `[v6]:port` (that port only). Lower-case, leading dots dropped;
16642
+ an entry with a port that is not a number names nothing."""
16643
+ raw = os.environ.get(AGENT_VAR_HOSTS_ENV, '') if raw is None else raw
16644
+ out = []
16645
+ for e in raw.split(','):
16646
+ e = e.strip().lower()
16647
+ if not e:
16648
+ continue
16649
+ host, port = e, None
16650
+ if e.startswith('['):
16651
+ host, _, rest = e[1:].partition(']')
16652
+ if rest:
16653
+ if not (rest.startswith(':') and rest[1:].isdigit()):
16654
+ continue
16655
+ port = int(rest[1:])
16656
+ elif e.count(':') == 1:
16657
+ host, _, p = e.partition(':')
16658
+ if not p.isdigit():
16659
+ continue
16660
+ port = int(p)
16661
+ host = host.lstrip('.')
16662
+ if host and not host.isascii(): # compared with the browser's punycode form
16663
+ try:
16664
+ host = host.encode('idna').decode('ascii')
16665
+ except UnicodeError:
16666
+ continue
16667
+ if host:
16668
+ out.append((host, port))
16669
+ return out
16670
+
16671
+
16363
16672
  def _agent_env_var_hosts():
16364
16673
  """Hosts on which CDPILOT_VAR_<NAME> values may be typed (CDPILOT_VAR_HOSTS,
16365
- comma-separated; a listed host covers its subdomains). Empty = never: a
16366
- flow a prompt-injected model records on another site must not be able to
16367
- draw on the environment's secrets (2026-11-10 review)."""
16368
- return [h.strip().lower() for h in os.environ.get(AGENT_VAR_HOSTS_ENV, '').split(',') if h.strip()]
16674
+ comma-separated; a listed host covers its subdomains; the port of a
16675
+ `host:port` entry is checked against the live page, see _var_url_allowed).
16676
+ Empty = never: a flow a prompt-injected model records on another site must
16677
+ not be able to draw on the environment's secrets (2026-11-10 review)."""
16678
+ return [h for h, _p in _var_host_entries()]
16369
16679
 
16370
16680
 
16371
16681
  def _agent_host_allowed(host, allowed):
@@ -16373,6 +16683,244 @@ def _agent_host_allowed(host, allowed):
16373
16683
  return bool(host) and any(host == a or host.endswith('.' + a.lstrip('.')) for a in allowed)
16374
16684
 
16375
16685
 
16686
+ _VAR_LOOPBACK_HOSTS = frozenset({'localhost', '127.0.0.1', '::1'})
16687
+
16688
+
16689
+ def _var_url_allowed(url, entries, subject='the active page'):
16690
+ """None when an environment value may be typed on the page at `url`, else why
16691
+ (names only, never a value). https on a host the entries name; http only on
16692
+ a loopback host (localhost, 127.0.0.1, [::1], *.localhost); the scheme's
16693
+ default port unless the entry is written `host:port` (2026-11-11 review: a
16694
+ host-only check let http://bank.example and bank.example:8443 through).
16695
+ `subject` names the URL in the reason (the active page, a go URL).
16696
+ A URL with a backslash, a control character or whitespace anywhere, or `@`
16697
+ / `%` in its authority, is refused: Python and the browser read such a URL
16698
+ differently (`https://evil.example\\@bank.example/` is bank.example to
16699
+ urlparse, evil.example to Chrome; round-2 review). A non-ASCII host is
16700
+ compared in its IDNA (punycode) form, as the browser reports it."""
16701
+ from urllib.parse import urlparse
16702
+ raw = url or ''
16703
+ if '\\' in raw or any(ord(c) < 0x21 or ord(c) == 0x7f or c.isspace() for c in raw):
16704
+ return (f'{subject} has a backslash, a control character or whitespace '
16705
+ '(the browser and cdpilot could read it as different hosts)')
16706
+ try:
16707
+ u = urlparse(raw)
16708
+ scheme, host, port = (u.scheme or '').lower(), (u.hostname or '').lower(), u.port
16709
+ except ValueError:
16710
+ return f'{subject} has an unreadable URL'
16711
+ if '@' in u.netloc or '%' in u.netloc:
16712
+ return f'{subject} has user info or an escape (@, %) before its path'
16713
+ if host and not host.isascii():
16714
+ try:
16715
+ host = host.encode('idna').decode('ascii')
16716
+ except UnicodeError:
16717
+ return f'{subject} has a host that is not a valid domain name'
16718
+ if not host:
16719
+ if subject == 'the active page':
16720
+ return 'the active page could not be read or has no host (about:blank, data:, browser down)'
16721
+ return f'{subject} has no host'
16722
+ shown = f'[{host}]' if ':' in host else host
16723
+ loopback = host in _VAR_LOOPBACK_HOSTS or host.endswith('.localhost')
16724
+ if scheme != 'https' and not (scheme == 'http' and loopback):
16725
+ return (f'{subject} is {scheme or "?"}://{shown}; an environment value is typed only '
16726
+ 'over https (http only on a loopback host)')
16727
+ default = 443 if scheme == 'https' else 80
16728
+ eff = default if port is None else port
16729
+ for h, p in entries:
16730
+ if (host == h or host.endswith('.' + h)) and eff == (default if p is None else p):
16731
+ return None
16732
+ if port is not None and port != default:
16733
+ return (f'{subject} is on {shown}:{port}; {AGENT_VAR_HOSTS_ENV} does not name that '
16734
+ f'port (write the entry as host:{port})')
16735
+ return f'{subject} is on {shown}; {AGENT_VAR_HOSTS_ENV} does not name it'
16736
+
16737
+
16738
+ # One Runtime.callFunctionOn on the resolved field, for a step that types a
16739
+ # CDPILOT_VAR_<NAME> value (replay) or a flow placeholder (agent run): it
16740
+ # checks the page, then the element, then sets the value, so no navigation
16741
+ # can come between the check and the typing (2026-11-11 review: TOCTOU,
16742
+ # focus moved into an iframe, Input.insertText into whatever had focus).
16743
+ # The value is an argument, never part of a script's source text. The page
16744
+ # check runs first and uses only what a page cannot replace (location's own
16745
+ # fields, ===, string indexing): until it passes the page may be hostile.
16746
+ # rule: {kind: fill|select|assert, origin: exact origin or null,
16747
+ # hosts: [[host, port or null], ...] or null (both checked when both set),
16748
+ # noUrlFields: true to refuse input type=search/url,
16749
+ # ce: true to accept a contenteditable element (agent run only: a
16750
+ # replayed flow could read its text back with wait-for-text / text=)}.
16751
+ _VAR_TARGET_FN = r"""function (value, rule) {
16752
+ 'use strict';
16753
+ /* cdpilot-var-target */
16754
+ var proto = location.protocol, host = location.hostname, port = location.port, i;
16755
+ function tail(h, d) {
16756
+ if (h === d) return true;
16757
+ var n = h.length, m = d.length, k;
16758
+ if (n <= m + 1 || h[n - m - 1] !== '.') return false;
16759
+ for (k = 0; k < m; k++) if (h[n - m + k] !== d[k]) return false;
16760
+ return true;
16761
+ }
16762
+ if (host.length > 1 && host[0] === '[') {
16763
+ var bare = '';
16764
+ for (i = 1; i < host.length - 1; i++) bare += host[i];
16765
+ host = bare;
16766
+ }
16767
+ var hasOrigin = rule.origin !== null && rule.origin !== undefined;
16768
+ if (!hasOrigin && (rule.hosts === null || rule.hosts === undefined))
16769
+ return {refused: 'no page rule'}; // fail closed
16770
+ if (hasOrigin && location.origin !== rule.origin)
16771
+ return {refused: 'the page is ' + location.origin + ', not ' + rule.origin};
16772
+ if (rule.hosts !== null && rule.hosts !== undefined) {
16773
+ var loop = host === 'localhost' || host === '127.0.0.1' || host === '::1' || tail(host, 'localhost');
16774
+ if (!(proto === 'https:' || (proto === 'http:' && loop)))
16775
+ return {refused: 'the page is ' + proto + '//' + host +
16776
+ '; an environment value is typed only over https (http only on a loopback host)'};
16777
+ var dflt = proto === 'https:' ? '443' : '80', eff = port === '' ? dflt : port, ok = false;
16778
+ var hosts = rule.hosts || [];
16779
+ for (i = 0; i < hosts.length && !ok; i++) {
16780
+ var ep = hosts[i][1] === null ? dflt : '' + hosts[i][1];
16781
+ if (tail(host, hosts[i][0]) && eff === ep) ok = true;
16782
+ }
16783
+ if (!ok) return {refused: 'the page is on ' + host + (port ? ':' + port : '') +
16784
+ ', not a host the step may type on'};
16785
+ }
16786
+ var el = this, kind = rule.kind;
16787
+ if (window.top !== window || el.ownerDocument !== document)
16788
+ return {refused: 'the field is not in the top-level page'};
16789
+ var tag = el.tagName, type = tag === 'INPUT' ? el.type : '';
16790
+ if (tag === 'IFRAME' || tag === 'FRAME' || tag === 'OBJECT' || tag === 'EMBED')
16791
+ return {refused: 'the target is an ' + tag + ', not a field'};
16792
+ // search and url fields put what they hold into a URL (rule.noUrlFields: replay and
16793
+ // environment values; round-2 review)
16794
+ var textLike = tag === 'INPUT' && (type === 'text' || type === 'password' || type === 'email' ||
16795
+ type === 'tel' || type === 'number' || (rule.noUrlFields !== true && (type === 'url' || type === 'search')));
16796
+ var what = tag === 'INPUT' ? 'an input of type ' + type : 'a ' + tag;
16797
+ if (kind === 'select') {
16798
+ if (tag !== 'SELECT') return {refused: 'the target is ' + what + ', not a <select>'};
16799
+ } else if (kind === 'assert') {
16800
+ if (!textLike && tag !== 'TEXTAREA' && tag !== 'SELECT')
16801
+ return {refused: 'the target is ' + what + ', not a field'};
16802
+ return {ok: true, tag: tag, equal: el.value === value};
16803
+ } else if (!textLike && tag !== 'TEXTAREA' &&
16804
+ !(rule.ce === true && tag !== 'INPUT' && tag !== 'SELECT' && el.isContentEditable)) {
16805
+ return {refused: 'the target is ' + what + ', not an editable field'};
16806
+ }
16807
+ if (typeof el.focus === 'function') el.focus();
16808
+ if (kind === 'select') {
16809
+ var norm = function (s) {
16810
+ return ('' + (s == null ? '' : s)).toLocaleLowerCase().replace(/[\s ]+/g, ' ').trim();
16811
+ };
16812
+ var want = norm(value), opts = el.options, pick = null;
16813
+ for (i = 0; i < opts.length && !pick; i++) if (norm(opts[i].text) === want) pick = opts[i];
16814
+ for (i = 0; i < opts.length && !pick; i++) if (norm(opts[i].text).indexOf(want) !== -1) pick = opts[i];
16815
+ if (!pick) return {missing: 'no option of the <select> matches'};
16816
+ el.value = pick.value;
16817
+ el.dispatchEvent(new Event('change', {bubbles: true}));
16818
+ return {ok: true, tag: tag};
16819
+ }
16820
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
16821
+ var d = Object.getOwnPropertyDescriptor(
16822
+ tag === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype, 'value');
16823
+ if (d && d.set) d.set.call(el, value); else el.value = value;
16824
+ } else {
16825
+ el.textContent = value;
16826
+ }
16827
+ el.dispatchEvent(new Event('input', {bubbles: true}));
16828
+ el.dispatchEvent(new Event('change', {bubbles: true}));
16829
+ return {ok: true, tag: tag};
16830
+ }"""
16831
+ _VAR_TARGET_GROUP = 'cdpilot-var-group'
16832
+
16833
+
16834
+ async def _var_target_call(ws_url, object_id, value, rule):
16835
+ """Run _VAR_TARGET_FN on `object_id`; its verdict dict. Anything else (the
16836
+ page navigated, the object is gone, no reply) is a refusal."""
16837
+ r = await cdp_send(ws_url, [(1, 'Runtime.callFunctionOn', {
16838
+ 'objectId': object_id, 'functionDeclaration': _VAR_TARGET_FN,
16839
+ 'arguments': [{'value': value}, {'value': rule}], 'returnByValue': True})])
16840
+ out = r.get(1, {}).get('result', {}).get('value')
16841
+ if isinstance(out, dict) and (out.get('ok') or out.get('refused') or out.get('missing')):
16842
+ return out
16843
+ return {'refused': 'the page did not confirm the field (it may have navigated)'}
16844
+
16845
+
16846
+ REPLAY_STRICT_ENV = 'CDPILOT_REPLAY_STRICT_TARGET'
16847
+ REPLAY_ALLOWED_HOSTS_ENV = 'CDPILOT_REPLAY_ALLOWED_HOSTS'
16848
+ REPLAY_ORIGIN_ENV = 'CDPILOT_REPLAY_ORIGIN' # the exact origin replay read before the step
16849
+ # An environment value reaches the child in its environment, never in argv
16850
+ # (2026-11-11 review: argv is readable by every local user): argv carries
16851
+ # @@cdpilot-value-<run token>-<slot>@@, the child reads REPLAY_VALUE_ENV<slot>
16852
+ # and REPLAY_TOKEN_ENV once at start (and drops them from its environment, so
16853
+ # nothing it starts inherits them) and swaps the value in right before the
16854
+ # checked call. The token is random per replay run: a flow file cannot write a
16855
+ # sentinel that matches, and a sentinel without its value fails closed.
16856
+ REPLAY_VALUE_ENV = 'CDPILOT_REPLAY_VALUE_'
16857
+ REPLAY_TOKEN_ENV = 'CDPILOT_REPLAY_TOKEN'
16858
+ _REPLAY_SENTINEL_RE = _re.compile(r'@@cdpilot-value-([0-9a-f]+)-(\d+)@@')
16859
+ _REPLAY_VALUES = {} # this process's REPLAY_VALUE_ENV* / REPLAY_TOKEN_ENV, taken at start
16860
+
16861
+
16862
+ def _replay_take_values():
16863
+ """Move REPLAY_VALUE_ENV* and REPLAY_TOKEN_ENV out of os.environ (CLI start)."""
16864
+ _REPLAY_VALUES.clear()
16865
+ for k in [k for k in os.environ if k.startswith(REPLAY_VALUE_ENV) or k == REPLAY_TOKEN_ENV]:
16866
+ _REPLAY_VALUES[k] = os.environ.pop(k)
16867
+
16868
+
16869
+ def _replay_strict_value(cmd, value):
16870
+ """`value` with each replay sentinel swapped for its environment value;
16871
+ exit 2 for a sentinel whose token or value is missing (no value printed)."""
16872
+ tok = _REPLAY_VALUES.get(REPLAY_TOKEN_ENV, '')
16873
+
16874
+ def put(m):
16875
+ v = _REPLAY_VALUES.get(REPLAY_VALUE_ENV + m.group(2))
16876
+ if not tok or not secrets.compare_digest(m.group(1), tok) or v is None:
16877
+ _replay_strict_refuse(cmd, 'a replay value placeholder has no value (sentinel without its value)')
16878
+ return v
16879
+ return _REPLAY_SENTINEL_RE.sub(put, value)
16880
+
16881
+
16882
+ def _replay_strict_rule():
16883
+ """The page rule for a step `replay` runs with a CDPILOT_VAR_<NAME> value
16884
+ (it sets REPLAY_STRICT_ENV and REPLAY_ALLOWED_HOSTS_ENV for that child
16885
+ only), or None. fill/type/smart-fill/smart-select/assert-value then act
16886
+ only through _VAR_TARGET_FN: top-level page, an editable field, the host
16887
+ rule checked in the same call that types."""
16888
+ if os.environ.get(REPLAY_STRICT_ENV) != '1':
16889
+ return None
16890
+ hosts = _var_host_entries(os.environ.get(REPLAY_ALLOWED_HOSTS_ENV, ''))
16891
+ return {'origin': os.environ.get(REPLAY_ORIGIN_ENV) or None, 'hosts': [[h, p] for h, p in hosts],
16892
+ 'ce': False, 'noUrlFields': True}
16893
+
16894
+
16895
+ def _replay_strict_refuse(cmd, why):
16896
+ """Exit 2: a replayed step with an environment value may not act (no value printed)."""
16897
+ print(f"Error: {cmd} refused (environment value): {why}", file=sys.stderr)
16898
+ sys.exit(2)
16899
+
16900
+
16901
+ async def _replay_strict_act(ws_url, find_js, value, rule, kind):
16902
+ """Find the field with `find_js` (its result is the element; the value is
16903
+ not in it), then check and act in one _VAR_TARGET_FN call. Returns the
16904
+ verdict, or {'missing': <find_js's string result>} when nothing matched."""
16905
+ global _WS_POOL_ENABLED
16906
+ was, _WS_POOL_ENABLED = _WS_POOL_ENABLED, True # an objectId lives in one CDP session
16907
+ try:
16908
+ r = await cdp_send(ws_url, [(1, 'Runtime.evaluate', {
16909
+ 'expression': find_js, 'awaitPromise': True, 'returnByValue': False,
16910
+ 'objectGroup': _VAR_TARGET_GROUP})])
16911
+ found = r.get(1, {}).get('result', {})
16912
+ oid = found.get('objectId') if found.get('subtype') not in ('null', 'error') else None
16913
+ if not oid:
16914
+ return {'missing': found.get('value') if isinstance(found.get('value'), str) else ''}
16915
+ return await _var_target_call(ws_url, oid, value, dict(rule, kind=kind))
16916
+ finally:
16917
+ try:
16918
+ await cdp_send(ws_url, [(2, 'Runtime.releaseObjectGroup', {'objectGroup': _VAR_TARGET_GROUP})])
16919
+ except Exception:
16920
+ pass
16921
+ _WS_POOL_ENABLED = was
16922
+
16923
+
16376
16924
  def _agent_trail_push(state, entry):
16377
16925
  trail = state.setdefault('trail', [])
16378
16926
  trail.append(entry)
@@ -16650,8 +17198,26 @@ async def cmd_agent_run(name, var_values=None, dry_run=False):
16650
17198
  drift(step, f"a placeholder value is typed only where it was recorded "
16651
17199
  f"({recorded or 'no recorded host'}); this page is {here or 'an opaque origin'}",
16652
17200
  page, out_actions, text, state, _agent_injection_scan(last_nodes))
17201
+ if step['n'] in env_by_step: # an environment value: https on a listed host, as replay
17202
+ why = _var_url_allowed(page.get('url', ''), _var_host_entries())
17203
+ if why:
17204
+ print(json.dumps({'error': f"step {step['n']}: environment value refused: {why}",
17205
+ 'flow': slug, 'step': step['n']}), file=sys.stderr)
17206
+ sys.exit(2)
16653
17207
  text_in = subst(step['text'], step['n']) if step.get('text') is not None else None
16654
- await _agent_perform(match['ref'], step.get('action', 'click'), text_in, amap)
17208
+ if (step.get('text') and _AGENT_VAR_RE.search(step['text'])
17209
+ and step.get('action', 'click') in ('type', 'fill')):
17210
+ # The value is set on the resolved node in one call that checks the
17211
+ # node and its page first (_VAR_TARGET_FN): no focus + insertText,
17212
+ # which typed into whatever had focus (2026-11-11 review).
17213
+ why = await _agent_type_placeholder(match['ref'], text_in, _agent_origin(page.get('url', '')), amap,
17214
+ from_env=step['n'] in env_by_step)
17215
+ if why:
17216
+ print(json.dumps({'error': f"step {step['n']}: placeholder value refused: {why}",
17217
+ 'flow': slug, 'step': step['n']}), file=sys.stderr)
17218
+ sys.exit(2)
17219
+ else:
17220
+ await _agent_perform(match['ref'], step.get('action', 'click'), text_in, amap)
16655
17221
  page2, actions2, out2, text2, state = await snapshot()
16656
17222
  _agent_trail_push(state, {'kind': 'act', 'action': step.get('action', 'click'), 'anchor': anchor,
16657
17223
  **({'text': step['text']} if step.get('text') is not None else {}),
@@ -18815,7 +19381,7 @@ class MCPServer:
18815
19381
  "inputSchema": {"type": "object", "properties": {}}},
18816
19382
  {"name": "browser_eval", "description": "Execute arbitrary JavaScript code in the browser page context and return the result. Use for custom DOM queries, data extraction, or page manipulation that other tools don't cover. Expression is evaluated via Runtime.evaluate.",
18817
19383
  "inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression to evaluate (e.g. 'document.title', 'document.querySelectorAll(\"a\").length')"}}, "required": ["expression"]}},
18818
- {"name": "browser_eval_batch", "description": "Evaluate N JavaScript expressions in a SINGLE round-trip. Returns an array of {ok, value} or {ok:false, error} objects, one per expression — a failure in one does not abort the batch. Use this when you need many small observations (read 10 DOM values, query multiple selectors, build a report) — collapses N×roundtrip into 1×roundtrip, typically 5-30x faster than calling browser_eval repeatedly.",
19384
+ {"name": "browser_eval_batch", "description": "Evaluate N JavaScript expressions in a SINGLE round-trip. Returns an array of {ok, value} or {ok:false, error} objects, one per expression — a failure in one does not abort the batch. Use this when you need many small observations (read 10 DOM values, query multiple selectors, build a report) — run N JS expressions in one process and one CDP roundtrip instead of N.",
18819
19385
  "inputSchema": {"type": "object", "properties": {"expressions": {"type": "array", "items": {"type": "string"}, "description": "Array of JavaScript expression strings. Each runs in its own try/catch."}}, "required": ["expressions"]}},
18820
19386
  {"name": "browser_tabs", "description": "List all open browser tabs with their IDs, URLs, and titles. Use this to see what pages are open and get tab IDs for switching between them with other navigation commands.",
18821
19387
  "inputSchema": {"type": "object", "properties": {}}},
@@ -21830,7 +22396,10 @@ def cmd_log(*args):
21830
22396
 
21831
22397
  # ─── Record & Replay (issue #5) ───
21832
22398
 
21833
- RECORD_INPUT_CMDS = frozenset({'fill', 'type', 'smart-fill', 'smart-select', 'select', 'assert-value'})
22399
+ # The commands whose argument is a typed value (record masks it; replay lets a
22400
+ # CDPILOT_VAR_<NAME> value fill it). Real dispatcher commands only: `select`
22401
+ # was listed here but is no command (2026-11-11 review).
22402
+ RECORD_INPUT_CMDS = frozenset({'fill', 'type', 'smart-fill', 'smart-select', 'assert-value'})
21834
22403
  # Recorder-only widening of the session log's secret-name test, applied to the
21835
22404
  # selector / label of input commands: short words get boundaries so that
21836
22405
  # "#shipping-address" (pin), "#discard" (card) and "#lesson" (ssn) stay verbatim.
@@ -22101,7 +22670,7 @@ def _record_detect_placeholders(cmd, args, step_num=1, vars_dict=None):
22101
22670
  else:
22102
22671
  new_args.append(a)
22103
22672
  return new_args, vars_dict
22104
- elif cmd in ('smart-fill', 'smart-select', 'select', 'assert-value'):
22673
+ elif cmd in ('smart-fill', 'smart-select', 'assert-value'):
22105
22674
  if len(args) >= 2:
22106
22675
  sel = args[0]
22107
22676
  val_str = " ".join(args[1:])
@@ -22461,6 +23030,265 @@ def cmd_record_dispatch(args):
22461
23030
  sys.exit(1)
22462
23031
 
22463
23032
 
23033
+ _REPLAY_VAR_RE = _re.compile(r"\{\{([A-Za-z0-9_-]+)\}\}")
23034
+
23035
+
23036
+ # Options of the input commands whose value is the next token: the global
23037
+ # --timeout, --frame (frame-aware commands), --entropy / --ladder written with
23038
+ # a space. A bare --flag the command does not know is read the same way, so a
23039
+ # placeholder right after it is never taken for the typed value; only
23040
+ # --no-heal stands alone (2026-11-11 review: `fill "#pw" --timeout "{{PW}}"`
23041
+ # counted the timeout's value as the typed one).
23042
+ _REPLAY_BOOL_FLAGS = frozenset({'--no-heal'})
23043
+
23044
+
23045
+ def _replay_option_values(args):
23046
+ """Indexes of `args` that are the value of an option (`--opt <v>`)."""
23047
+ out, i = set(), 0
23048
+ while i < len(args):
23049
+ a = str(args[i])
23050
+ if a.startswith('--') and '=' not in a and a not in _REPLAY_BOOL_FLAGS:
23051
+ if i + 1 < len(args):
23052
+ out.add(i + 1)
23053
+ i += 2
23054
+ continue
23055
+ i += 1
23056
+ return frozenset(out)
23057
+
23058
+
23059
+ def _replay_value_positions(cmd, args):
23060
+ """Indexes of `args` that carry the typed value of an input step, by the
23061
+ layout the recorder writes (_record_redact_args) and the dispatcher reads,
23062
+ after the options and their values are set aside (_replay_option_values):
23063
+ fill / type / smart-fill / smart-select
23064
+ <selector|label> <value...>: every positional
23065
+ token after the first;
23066
+ assert-value <selector> <value>: the second only.
23067
+ A token starting with -- or following a bare --option is never a value
23068
+ position. The positions are a subset of the child's, so a selector or a
23069
+ label is never taken for a value. Every other command (go, eval, run,
23070
+ network…): none, so a CDPILOT_VAR_<NAME> environment value never fills it."""
23071
+ if cmd not in RECORD_INPUT_CMDS:
23072
+ return frozenset()
23073
+ opt_vals = _replay_option_values(args)
23074
+ pos = [i for i, a in enumerate(args) if not str(a).startswith('--') and i not in opt_vals]
23075
+ if cmd == 'assert-value':
23076
+ return frozenset(pos[1:2])
23077
+ return frozenset(pos[1:])
23078
+
23079
+
23080
+ def _replay_entropy_asked(args):
23081
+ """True when a step (or CDPILOT_ENTROPY) asks for humanized typing."""
23082
+ if any(str(a) == '--entropy' or (str(a).startswith('--entropy=') and str(a) != '--entropy=off')
23083
+ for a in args):
23084
+ return True
23085
+ return os.environ.get('CDPILOT_ENTROPY', '').lower() in ('1', 'true', 'yes', 'on')
23086
+
23087
+
23088
+ def _replay_env_refusal(cmd, j, value_idxs, args, env_hosts):
23089
+ """Why a CDPILOT_VAR_<NAME> value may not fill a placeholder in args[j] of a
23090
+ `cmd` step, or None when it may (subject to the flow rule, _replay_env_flow_refusal,
23091
+ and the live page check before the step). Names only: the value itself is
23092
+ never part of the reason."""
23093
+ if cmd in ('go', 'open', 'new-tab'):
23094
+ return 'an environment value never fills a URL; pass --var'
23095
+ if cmd in SLOG_JS_CMDS or cmd == 'eval-batch':
23096
+ return 'an environment value never fills a script; pass --var'
23097
+ if cmd not in RECORD_INPUT_CMDS:
23098
+ return ('an environment value fills only the value of an input step ('
23099
+ + ', '.join(sorted(RECORD_INPUT_CMDS)) + '); pass --var')
23100
+ if str(args[j]).startswith('--'):
23101
+ return 'an environment value never fills a flag; pass --var'
23102
+ if j in _replay_option_values(args):
23103
+ return f'an environment value never fills the value of an option ({args[j - 1]}); pass --var'
23104
+ # A frame-routed step (`sel >>> inner`, --frame) types into an iframe whose
23105
+ # origin the live-host check (top-level page) does not see.
23106
+ if any(FRAME_SEP in str(a) for a in args) or any(
23107
+ str(a) == '--frame' or str(a).startswith('--frame=') for a in args):
23108
+ return 'an environment value never fills a step routed into a frame; pass --var'
23109
+ # Humanized typing sends keys one by one for seconds; the keys follow focus
23110
+ # (into an iframe, onto the next page after a redirect).
23111
+ if _replay_entropy_asked(args):
23112
+ return ('an environment value is never typed with humanized input (--entropy=on, '
23113
+ 'CDPILOT_ENTROPY=on); drop it for this step or pass --var')
23114
+ if j not in value_idxs:
23115
+ return 'an environment value never fills a selector or label; pass --var'
23116
+ if not env_hosts:
23117
+ return (f'{AGENT_VAR_HOSTS_ENV} is empty; name the host(s) the value may be '
23118
+ 'typed on, or pass --var')
23119
+ return None
23120
+
23121
+
23122
+ # The commands a flow may use at all when one of its steps takes a
23123
+ # CDPILOT_VAR_<NAME> value (2026-11-11 review: the check covered the typing
23124
+ # step only, so `eval` after `fill #password {{PASSWORD}}` read the field and
23125
+ # sent it anywhere, or a listener installed before it did). Each runs no
23126
+ # script the flow supplies, reads no page data back to the flow, and changes no
23127
+ # cdpilot, browser or network setting; replay withholds a child's stdout in
23128
+ # such a flow. They still act on the page as a user would: a click follows a
23129
+ # link, submit sends a form, and the page's own scripts run, may set cookies or
23130
+ # start a download (round-2 review: the old wording claimed no file or cookie
23131
+ # is ever written). Left out, among
23132
+ # others: eval, multi-eval, eval-batch, batch, run, frame, tools, agent
23133
+ # (scripts or other commands); content, html, extract, a11y*, observe,
23134
+ # describe, shot*, pdf, console, network, perf, storage, cookies (page data,
23135
+ # files); assert, assert-url, assert-title, assert-attr, check (echo actual
23136
+ # page values); intercept, proxy, block, dialog, download, upload, permission,
23137
+ # geo, throttle, emulate (browser or network settings, local files); new-tab,
23138
+ # close-tab, switch-tab, tabs (which page the next step acts on); drag,
23139
+ # rightclick, press-hold (text drag, context menu); captcha*, friction.
23140
+ REPLAY_ENV_FLOW_CMDS = frozenset({
23141
+ 'go', 'open', # http(s) URLs only
23142
+ 'click', 'dblclick', 'hover', 'smart-click', 'submit', 'scroll-to', 'dismiss',
23143
+ 'keys', # no Ctrl/Alt/Meta combination
23144
+ 'fill', 'type', 'smart-fill', 'smart-select',
23145
+ 'wait', 'wait-for', 'wait-for-text',
23146
+ 'assert-count', 'assert-visible', 'assert-hidden',
23147
+ })
23148
+
23149
+
23150
+ def _replay_child_args(args):
23151
+ """`args` as the child's dispatcher sees them after the global --timeout."""
23152
+ out, i = [], 0
23153
+ while i < len(args):
23154
+ a = str(args[i])
23155
+ if a == '--timeout':
23156
+ i += 2
23157
+ continue
23158
+ if not a.startswith('--timeout='):
23159
+ out.append(a)
23160
+ i += 1
23161
+ return out
23162
+
23163
+
23164
+ # Selector pieces that react to a typed value (2026-11-11 review, H2): an
23165
+ # attribute selector on `value` (React copies the property to the attribute),
23166
+ # XPath @value, the selector ladder's `value=<text>` form, and pseudo-classes
23167
+ # that change with the value. A step that waits on or clicks such a selector,
23168
+ # then a navigation, would let a server time the value out character by
23169
+ # character. Matched after CSS escapes are decoded (`[v\61 lue` is `[value`).
23170
+ _REPLAY_VALUE_PROBE_RE = _re.compile(
23171
+ r"\[\s*(?:[\w*-]*\|)?\s*value\b|@\s*value\b|^\s*value\s*=|"
23172
+ r":(?:-[a-z]+-)?(?:placeholder-shown|valid|invalid|in-range|out-of-range|user-valid|user-invalid|blank"
23173
+ r"|checked|default|indeterminate|autofill)\b",
23174
+ _re.I)
23175
+ # CSS comments split a selector anywhere (`[/**/value`); an unclosed one runs to the end.
23176
+ _CSS_COMMENT_RE = _re.compile(r"/\*.*?(?:\*/|$)", _re.S)
23177
+
23178
+
23179
+ def _replay_value_probe(a):
23180
+ """True when selector text `a` could react to a typed value: an XPath (the
23181
+ ladder evaluates a selector starting with / or .// as one; `(`, ../ and
23182
+ xpath= forms too) or a _REPLAY_VALUE_PROBE_RE piece, read raw, without CSS
23183
+ comments, CSS-unescaped, and once more without comments (round-2 review)."""
23184
+ bare = _CSS_COMMENT_RE.sub('', a)
23185
+ unesc = _css_unescape(bare)
23186
+ return any(_REPLAY_VALUE_PROBE_RE.search(t)
23187
+ or t.lstrip().lower().startswith(('/', './', '../', '(', 'xpath='))
23188
+ for t in (a, bare, unesc, _CSS_COMMENT_RE.sub('', unesc)))
23189
+
23190
+
23191
+ def _css_unescape(s):
23192
+ """`s` with CSS escapes decoded (\\76 alue -> value); a bad code point is U+FFFD."""
23193
+ def rep(m):
23194
+ if m.group(1):
23195
+ try:
23196
+ return chr(int(m.group(1), 16))
23197
+ except (ValueError, OverflowError):
23198
+ return '\ufffd'
23199
+ return m.group(2)
23200
+ return _re.sub(r'\\(?:([0-9a-fA-F]{1,6})\s?|(.))', rep, s, flags=_re.S)
23201
+
23202
+
23203
+ def _replay_env_flow_refusal(idx, cmd, args, env_steps, fill_cli):
23204
+ """Why step `idx` may not run in a flow that types an environment value
23205
+ (env_steps: the indexes of the steps that take one), or None. `fill_cli`
23206
+ puts the --var values in (a go URL or a selector may carry one)."""
23207
+ if cmd == 'assert-value' and idx in env_steps:
23208
+ return None # compared inside the page, nothing printed (_replay_strict_rule)
23209
+ if cmd not in REPLAY_ENV_FLOW_CMDS:
23210
+ if cmd == 'assert-value':
23211
+ return ("assert-value prints the field's actual value unless the step itself "
23212
+ "takes the environment value")
23213
+ return (f'{cmd} is not one of the commands such a flow may use ('
23214
+ + ', '.join(sorted(REPLAY_ENV_FLOW_CMDS)) + ')')
23215
+ rest = _replay_child_args(args)
23216
+ if cmd in ('go', 'open'):
23217
+ # Every page such a flow opens is one an environment value may be typed
23218
+ # on (https, a listed host and port): no step can navigate the tab to a
23219
+ # server that would time or read what the flow does (H2).
23220
+ why = _var_url_allowed(fill_cli(rest[0]) if rest else '', _var_host_entries(), f'the {cmd} URL')
23221
+ if why:
23222
+ return f'{cmd} opens a page an environment value may not be typed on: {why}'
23223
+ return None
23224
+ typed = _replay_value_positions(cmd, args)
23225
+ for j, a in enumerate(args):
23226
+ if j in typed:
23227
+ continue # typed text, not a selector
23228
+ if _replay_value_probe(fill_cli(str(a))):
23229
+ return (f'{cmd} names an XPath or a selector that reacts to a typed value ([value...], '
23230
+ '@value, value=, :placeholder-shown, :valid, :checked...); a page could time the '
23231
+ 'value out')
23232
+ if cmd == 'keys' and rest:
23233
+ if set(rest[0].lower().split('+')) & {'ctrl', 'control', 'alt', 'meta'}:
23234
+ return 'keys takes no Ctrl/Alt/Meta combination here (copy and paste carry a typed value)'
23235
+ return None
23236
+
23237
+
23238
+ def _replay_scrub(text, values):
23239
+ """`text` with every form of each value replaced by {{name}}: the value, its
23240
+ repr (with and without quotes), its JSON-escaped and URL-encoded forms
23241
+ (2026-11-11 review: a child that echoed repr(value) got past an exact-match
23242
+ scrub). One pass, longest form first; empty values are skipped."""
23243
+ if not text:
23244
+ return text
23245
+ from urllib.parse import quote, quote_plus
23246
+ names = {}
23247
+ for name, v in values.items():
23248
+ v = str(v)
23249
+ if not v:
23250
+ continue
23251
+ r, j = repr(v), json.dumps(v)
23252
+ for f in (v, r, r[1:-1], ascii(v)[1:-1], j, j[1:-1], json.dumps(v, ensure_ascii=False)[1:-1],
23253
+ quote(v, safe=''), quote(v), quote_plus(v)):
23254
+ if f:
23255
+ names.setdefault(f, name)
23256
+ if not names:
23257
+ return text
23258
+ pat = _re.compile('|'.join(_re.escape(f) for f in sorted(names, key=len, reverse=True)))
23259
+ return pat.sub(lambda m: '{{' + names[m.group(0)] + '}}', text)
23260
+
23261
+
23262
+ def _replay_live_url():
23263
+ """URL of the page the next replay step acts on, read now from the CDP
23264
+ target list (no cache): the target get_page_ws() picks for a child command
23265
+ (CDPILOT_TARGET pin, then the session tab). '' when the browser does not
23266
+ answer. A navigation between this read and the child's own call is caught
23267
+ there: a step with an environment value checks the page again in the same
23268
+ call that types (_VAR_TARGET_FN)."""
23269
+ cdp_cache_invalidate()
23270
+ if cdp_get("/json", no_cache=True) is None:
23271
+ return ''
23272
+ try:
23273
+ with contextlib.redirect_stdout(io.StringIO()):
23274
+ _ws, page = get_page_ws()
23275
+ except SystemExit:
23276
+ return ''
23277
+ cdp_cache_invalidate()
23278
+ url = (page or {}).get("url", "")
23279
+ for t in cdp_get("/json", no_cache=True) or []:
23280
+ if t.get("id") and t.get("id") == (page or {}).get("id"):
23281
+ url = t.get("url", "")
23282
+ break
23283
+ return url or ''
23284
+
23285
+
23286
+ def _replay_live_host():
23287
+ """Host of _replay_live_url() ('' for none)."""
23288
+ url = _replay_live_url()
23289
+ return _agent_host_path(url)[0] if url else ''
23290
+
23291
+
22464
23292
  def cmd_replay(*args):
22465
23293
  """Replay a recorded flow JSON file."""
22466
23294
  if not args:
@@ -22585,40 +23413,98 @@ def cmd_replay(*args):
22585
23413
  print(f"cdpilot: invalid flow file: step {idx} malformed", file=sys.stderr)
22586
23414
  sys.exit(2)
22587
23415
 
22588
- collected_vars = {}
23416
+ # Values: --var (the caller holds them) fills any placeholder, as before.
23417
+ # CDPILOT_VAR_<NAME> fills only the value of an input step, and only on a
23418
+ # host named in CDPILOT_VAR_HOSTS, checked live right before that step
23419
+ # (2026-11-10 review: a crafted flow file could send an environment secret
23420
+ # to any host through go / eval / a fill after `go https://evil`).
23421
+ # The two sources stay in separate dicts; nothing here prints a value.
23422
+ env_vars = {}
22589
23423
  for env_k, env_v in os.environ.items():
22590
- if env_k.startswith("CDPILOT_VAR_"):
22591
- var_name = env_k[len("CDPILOT_VAR_"):].lower()
22592
- collected_vars[var_name] = env_v
22593
- collected_vars[env_k[len("CDPILOT_VAR_"):]] = env_v
22594
-
22595
- collected_vars.update(cli_vars)
22596
-
22597
- needed_vars = set(data.get("vars", {}).keys())
22598
- for s in steps:
22599
- if isinstance(s, dict):
22600
- for a in s.get("args", []):
22601
- for m in _re.finditer(r"\{\{([A-Za-z0-9_-]+)\}\}", str(a)):
22602
- needed_vars.add(m.group(1))
22603
-
22604
- def get_var_value(name):
22605
- if name in collected_vars:
22606
- return collected_vars[name]
22607
- if name.lower() in collected_vars:
22608
- return collected_vars[name.lower()]
22609
- if name.upper() in collected_vars:
22610
- return collected_vars[name.upper()]
23424
+ if env_k.startswith("CDPILOT_VAR_") and env_k != AGENT_VAR_HOSTS_ENV:
23425
+ env_vars[env_k[len("CDPILOT_VAR_"):]] = env_v
23426
+ env_vars.setdefault(env_k[len("CDPILOT_VAR_"):].lower(), env_v)
23427
+
23428
+ def _lookup(src, name):
23429
+ for k in (name, name.lower(), name.upper()):
23430
+ if k in src:
23431
+ return src[k]
22611
23432
  return None
22612
23433
 
22613
- missing = []
22614
- for var_name in sorted(needed_vars):
22615
- if get_var_value(var_name) is None:
22616
- missing.append(var_name)
23434
+ env_hosts = _agent_env_var_hosts()
23435
+ # Settled statically, before any step runs (exit 2 otherwise):
23436
+ # env_by_step: step index -> {arg index -> names the environment may fill there}
23437
+ env_by_step, missing, refused = {}, [], []
23438
+ needed_vars = set((data.get("vars") or {}).keys())
23439
+ for idx, s in enumerate(steps, 1):
23440
+ cmd = s.get("cmd", "")
23441
+ value_idxs = _replay_value_positions(cmd, s.get("args", []))
23442
+ for j, a in enumerate(s.get("args", [])):
23443
+ for m in _REPLAY_VAR_RE.finditer(str(a)):
23444
+ k = m.group(1)
23445
+ needed_vars.add(k)
23446
+ if _lookup(cli_vars, k) is not None:
23447
+ continue
23448
+ if _lookup(env_vars, k) is None:
23449
+ if k not in missing:
23450
+ missing.append(k)
23451
+ continue
23452
+ why = _replay_env_refusal(cmd, j, value_idxs, s.get("args", []), env_hosts)
23453
+ if why:
23454
+ refused.append({"var": k, "step": idx, "cmd": cmd, "why": why})
23455
+ else:
23456
+ env_by_step.setdefault(idx, {}).setdefault(j, set()).add(k)
23457
+ for k in sorted(needed_vars): # catalogued but used in no step: a value must still exist
23458
+ if k not in missing and _lookup(cli_vars, k) is None and _lookup(env_vars, k) is None:
23459
+ missing.append(k)
22617
23460
 
22618
- if missing:
22619
- print(f"cdpilot: missing required variable(s): {', '.join(missing)}", file=sys.stderr)
23461
+ hint = (f"--var name=value, or CDPILOT_VAR_<NAME> with {AGENT_VAR_HOSTS_ENV}=<host,...> "
23462
+ "for the value of an input step (" + ", ".join(sorted(RECORD_INPUT_CMDS)) + ") "
23463
+ "in a flow made only of: " + ", ".join(sorted(REPLAY_ENV_FLOW_CMDS)))
23464
+ if missing or refused:
23465
+ parts = []
23466
+ if missing:
23467
+ parts.append(f"missing required variable(s): {', '.join(sorted(set(missing)))}")
23468
+ if refused:
23469
+ parts.append("environment value(s) refused: "
23470
+ + ", ".join(sorted({r['var'] for r in refused})))
23471
+ err = {"error": "; ".join(parts), "missing": sorted(set(missing)), "hint": hint}
23472
+ if refused:
23473
+ err["refused"] = refused
23474
+ print(json.dumps(err), file=sys.stderr)
22620
23475
  sys.exit(2)
22621
23476
 
23477
+ def _fill_cli(s):
23478
+ return _REPLAY_VAR_RE.sub(lambda m: str(_lookup(cli_vars, m.group(1)))
23479
+ if _lookup(cli_vars, m.group(1)) is not None else m.group(0), s)
23480
+
23481
+ # A flow that types an environment value runs only steps that cannot read
23482
+ # the page or move the value elsewhere (REPLAY_ENV_FLOW_CMDS), settled
23483
+ # before the first step, --dry-run and --continue alike.
23484
+ if env_by_step:
23485
+ env_names = sorted({k for js in env_by_step.values() for ks in js.values() for k in ks})
23486
+ for idx, s in enumerate(steps, 1):
23487
+ why = _replay_env_flow_refusal(idx, s["cmd"], s["args"], env_by_step, _fill_cli)
23488
+ if why:
23489
+ why = (f"step {idx} ({s['cmd']}) may not run in a flow that types an environment "
23490
+ f"value: {why}; pass --var, or split the flow")
23491
+ print(json.dumps({"error": "environment value(s) refused: " + ", ".join(env_names)
23492
+ + f"; step {idx} ({s['cmd']}) may not run in this flow",
23493
+ "missing": [],
23494
+ "refused": [{"var": k, "step": idx, "cmd": s["cmd"], "why": why}
23495
+ for k in env_names],
23496
+ "hint": hint}), file=sys.stderr)
23497
+ sys.exit(2)
23498
+
23499
+ # Every value the flow can put in a step, scrubbed from any output it prints.
23500
+ flow_values = {}
23501
+ for k in sorted(needed_vars):
23502
+ if _lookup(cli_vars, k) is not None:
23503
+ flow_values[k] = _lookup(cli_vars, k)
23504
+ elif any(k in ks for js in env_by_step.values() for ks in js.values()):
23505
+ flow_values[k] = _lookup(env_vars, k)
23506
+ env_flow = bool(env_by_step)
23507
+
22622
23508
  total_steps = len(steps)
22623
23509
  if dry_run:
22624
23510
  for idx, step in enumerate(steps, 1):
@@ -22630,6 +23516,18 @@ def cmd_replay(*args):
22630
23516
  sys.exit(0)
22631
23517
 
22632
23518
  had_failure = False
23519
+ typed_at = 0 # the last step that took an environment value (0: none yet)
23520
+ pinned = '' # the exact origin the first environment value was typed on
23521
+ run_token = secrets.token_hex(16) # per run: a flow file cannot write a matching sentinel
23522
+
23523
+ def _page_why(url):
23524
+ """Why an environment value may not be near the page at `url`, or None:
23525
+ the host rule, and once a value is typed, that page's exact origin."""
23526
+ why = _var_url_allowed(url, _var_host_entries())
23527
+ if why is None and pinned and _agent_origin(url) != pinned:
23528
+ why = (f"the active page is {_agent_origin(url) or 'an opaque origin'}, not {pinned} "
23529
+ "where the environment value was typed")
23530
+ return why
22633
23531
  for idx, step in enumerate(steps, 1):
22634
23532
  if idx > 1:
22635
23533
  if delay_mode == "real":
@@ -22645,32 +23543,6 @@ def cmd_replay(*args):
22645
23543
 
22646
23544
  cmd = step.get("cmd", "")
22647
23545
  raw_args = step.get("args", [])
22648
- sub_args = []
22649
- for a in raw_args:
22650
- s_val = str(a)
22651
- sub = _re.sub(r"\{\{([A-Za-z0-9_-]+)\}\}",
22652
- lambda m: str(get_var_value(m.group(1)) if get_var_value(m.group(1)) is not None else m.group(0)),
22653
- s_val)
22654
- sub_args.append(sub)
22655
-
22656
- # Collect substituted variable values for placeholders in this step
22657
- step_subs = {}
22658
- for a in raw_args:
22659
- for m in _re.finditer(r"\{\{([A-Za-z0-9_-]+)\}\}", str(a)):
22660
- vname = m.group(1)
22661
- vval = get_var_value(vname)
22662
- if vval is not None and str(vval) != "":
22663
- step_subs[vname] = str(vval)
22664
-
22665
- def sanitize_output(text):
22666
- if not text:
22667
- return text
22668
- # Replace longest value first; skip empty values
22669
- sorted_subs = sorted(step_subs.items(), key=lambda kv: len(kv[1]), reverse=True)
22670
- for vname, vval in sorted_subs:
22671
- if vval:
22672
- text = text.replace(vval, f"{{{{{vname}}}}}")
22673
- return text
22674
23546
 
22675
23547
  if cmd in RECORD_INPUT_CMDS and raw_args:
22676
23548
  target = next((a for a in raw_args if not a.startswith("--")), raw_args[0])
@@ -22680,9 +23552,73 @@ def cmd_replay(*args):
22680
23552
  else:
22681
23553
  step_label = cmd
22682
23554
 
23555
+ env_here = env_by_step.get(idx, {}) # arg index -> names the environment may fill
23556
+ live = ''
23557
+ if env_here or typed_at:
23558
+ # Checked now (never cached across steps), before a step that types
23559
+ # an environment value and before every step after one: the page
23560
+ # must be https (http on loopback) on a host CDPILOT_VAR_HOSTS
23561
+ # names, on the port the entry allows, and once a value is typed,
23562
+ # on the exact origin it was typed on (round-2 review: a host
23563
+ # suffix let a later step run on another subdomain). The child
23564
+ # checks again in the call that types (REPLAY_STRICT_ENV). A
23565
+ # refusal always stops the replay, --continue or not, and no value
23566
+ # is in any argv.
23567
+ live = _replay_live_url()
23568
+ why = _page_why(live)
23569
+ if why and not env_here:
23570
+ # A click followed a link off that origin after the value was
23571
+ # typed (H2): nothing more runs on that page.
23572
+ print(f"[{idx}/{total_steps}] {step_label} ... refused")
23573
+ print(json.dumps({"error": f"step {idx} ({cmd}) not run: the page left {pinned} "
23574
+ f"after step {typed_at} typed an environment value",
23575
+ "missing": [], "step": idx, "cmd": cmd, "why": why,
23576
+ "hint": "keep every step after the value on the page's origin, "
23577
+ "or pass --var name=value"}), file=sys.stderr)
23578
+ sys.exit(2)
23579
+ if why:
23580
+ names = sorted({k for ks in env_here.values() for k in ks})
23581
+ print(f"[{idx}/{total_steps}] {step_label} ... refused")
23582
+ print(json.dumps({"error": "environment value(s) refused: " + ", ".join(names),
23583
+ "missing": [],
23584
+ "refused": [{"var": k, "step": idx, "cmd": cmd, "why": why} for k in names],
23585
+ "hint": f"open an https page on a host named in {AGENT_VAR_HOSTS_ENV} "
23586
+ "(host:port for another port) first, or pass --var name=value"}),
23587
+ file=sys.stderr)
23588
+ sys.exit(2)
23589
+
23590
+ child_values = {} # REPLAY_VALUE_ENV<slot> -> environment value, for this child only
23591
+
23592
+ def _fill(m, j):
23593
+ k = m.group(1)
23594
+ v = _lookup(cli_vars, k)
23595
+ if v is None and k in env_here.get(j, ()):
23596
+ slot = sorted({n for ns in env_here.values() for n in ns}).index(k)
23597
+ child_values[f"{REPLAY_VALUE_ENV}{slot}"] = str(_lookup(env_vars, k))
23598
+ return f"@@cdpilot-value-{run_token}-{slot}@@" # the value goes in the child's environment
23599
+ if v is None:
23600
+ return m.group(0)
23601
+ return str(v)
23602
+
23603
+ sub_args = [_REPLAY_VAR_RE.sub(lambda m, j=j: _fill(m, j), str(a)) for j, a in enumerate(raw_args)]
23604
+
22683
23605
  env = os.environ.copy()
22684
23606
  env["CDPILOT_REPLAY_ACTIVE"] = "1"
22685
23607
  env["PYTHONIOENCODING"] = "utf-8"
23608
+ # The strict-target pair is replay's own, set for a step that takes an
23609
+ # environment value and removed for every other (a flow file sets no
23610
+ # environment; an inherited pair never reaches an unrelated step).
23611
+ for k in [k for k in env if k in (REPLAY_STRICT_ENV, REPLAY_ALLOWED_HOSTS_ENV, REPLAY_TOKEN_ENV,
23612
+ REPLAY_ORIGIN_ENV) or k.startswith(REPLAY_VALUE_ENV)]:
23613
+ del env[k]
23614
+ if env_here:
23615
+ pinned = pinned or _agent_origin(live)
23616
+ env[REPLAY_STRICT_ENV] = "1"
23617
+ env[REPLAY_ALLOWED_HOSTS_ENV] = os.environ.get(AGENT_VAR_HOSTS_ENV, "")
23618
+ env[REPLAY_ORIGIN_ENV] = pinned # the child types only on this exact origin
23619
+ env[REPLAY_TOKEN_ENV] = run_token
23620
+ env.update(child_values)
23621
+ typed_at = idx
22686
23622
  t0 = time.monotonic()
22687
23623
  child_cmd = [sys.executable, os.path.abspath(__file__), cmd] + sub_args
22688
23624
  res = subprocess.run(
@@ -22692,16 +23628,42 @@ def cmd_replay(*args):
22692
23628
  errors="replace",
22693
23629
  env=env
22694
23630
  )
22695
- dur = time.monotonic() - t0
23631
+ # In a flow that types an environment value no step's duration is shown:
23632
+ # a wait that ends early or times out would tell the reader about the
23633
+ # page (round-2 review).
23634
+ dur = "" if env_flow else f" {time.monotonic() - t0:.1f}s"
22696
23635
 
22697
23636
  if res.returncode == 0:
22698
- print(f"[{idx}/{total_steps}] {step_label} ... ok {dur:.1f}s")
23637
+ print(f"[{idx}/{total_steps}] {step_label} ... ok{dur}")
23638
+ if env_flow and cmd in ("go", "open"):
23639
+ # The page the browser committed, read back: a redirect, or a URL
23640
+ # cdpilot and the browser parse differently, ends here (round-2).
23641
+ why = _page_why(_replay_live_url())
23642
+ if why:
23643
+ print(json.dumps({"error": f"step {idx} ({cmd}) landed on a page an environment "
23644
+ "value may not be typed on; nothing more runs",
23645
+ "missing": [], "step": idx, "cmd": cmd, "why": why,
23646
+ "hint": f"list the host the page lands on in {AGENT_VAR_HOSTS_ENV}, "
23647
+ "or pass --var name=value"}), file=sys.stderr)
23648
+ sys.exit(2)
23649
+ elif env_here and res.returncode == 2:
23650
+ # The child refused to type the environment value (the page or the
23651
+ # field failed its check): a refusal, so the replay stops with 2.
23652
+ print(f"[{idx}/{total_steps}] {step_label} ... refused (2){dur}")
23653
+ err_msg = _replay_scrub(res.stderr.rstrip(), flow_values)
23654
+ if err_msg:
23655
+ print(err_msg, file=sys.stderr)
23656
+ sys.exit(2)
22699
23657
  else:
22700
- print(f"[{idx}/{total_steps}] {step_label} ... failed ({res.returncode}) {dur:.1f}s")
22701
- raw_err = res.stderr.rstrip() if res.stderr else res.stdout.rstrip()
22702
- err_msg = sanitize_output(raw_err)
23658
+ print(f"[{idx}/{total_steps}] {step_label} ... failed ({res.returncode}){dur}")
23659
+ # In a flow that types an environment value a step's stdout (page
23660
+ # labels, a field's text) is never shown, only its scrubbed stderr.
23661
+ raw_err = res.stderr.rstrip() if res.stderr else ("" if env_flow else res.stdout.rstrip())
23662
+ err_msg = _replay_scrub(raw_err, flow_values)
22703
23663
  if err_msg:
22704
23664
  print(err_msg, file=sys.stderr)
23665
+ elif env_flow:
23666
+ print("(step output withheld: the flow types an environment value)", file=sys.stderr)
22705
23667
  had_failure = True
22706
23668
  if not continue_on_error:
22707
23669
  sys.exit(1)
@@ -22713,7 +23675,13 @@ def cmd_replay(*args):
22713
23675
  def cmd_replay_dispatch(args):
22714
23676
  """Dispatch cdpilot replay command."""
22715
23677
  if not args or args[0] in ('--help', '-h', 'help'):
22716
- print("Usage: cdpilot replay <flow.json> [--var k=v ...] [--delay real|none|<ms>] [--dry-run] [--continue]\n",
23678
+ print("Usage: cdpilot replay <flow.json> [--var k=v ...] [--delay real|none|<ms>] [--dry-run] [--continue]\n"
23679
+ " --var k=v fills any {{k}}. CDPILOT_VAR_<NAME> fills only the value of an input step\n"
23680
+ " (" + ", ".join(sorted(RECORD_INPUT_CMDS)) + ") on an https page whose host\n"
23681
+ " CDPILOT_VAR_HOSTS names (host:port for another port; http only on loopback), checked\n"
23682
+ " before the step and in the call that types; go/eval and every other command take\n"
23683
+ " --var only. A flow with such a value may use only: "
23684
+ + ", ".join(sorted(REPLAY_ENV_FLOW_CMDS)) + ".\n",
22717
23685
  file=sys.stderr)
22718
23686
  sys.exit(0 if (args and args[0] in ('--help', '-h', 'help')) else 2)
22719
23687
  cmd_replay(*args)
@@ -22816,6 +23784,18 @@ def _daemon_send(conn, obj):
22816
23784
  pass # the client went away; the command still runs to its end
22817
23785
 
22818
23786
 
23787
+ def _daemon_hangup(conn):
23788
+ """End the conversation for the peer, whatever else still holds the socket.
23789
+ close() alone only drops this process's reference: on Linux a thread blocked
23790
+ in recv() on it (the client watcher, up to its 10 s timeout) keeps the
23791
+ connection open, the client sees no EOF and every warm call took 10 s there
23792
+ (K-0027, CI 42810141487). shutdown() sends the EOF now and wakes that thread."""
23793
+ try:
23794
+ conn.shutdown(socket.SHUT_RDWR)
23795
+ except OSError:
23796
+ pass # already gone
23797
+
23798
+
22819
23799
  def _daemon_recv_line(conn, limit=8 * 1024 * 1024):
22820
23800
  """One newline-terminated JSON object, or None (closed, too long, invalid)."""
22821
23801
  chunks, size = [], 0
@@ -23188,6 +24168,8 @@ def _daemon_serve_entry(args):
23188
24168
  stop.append(True)
23189
24169
  except OSError:
23190
24170
  pass
24171
+ finally:
24172
+ _daemon_hangup(conn)
23191
24173
  finally:
23192
24174
  asyncio.run = _DAEMON["asyncio_run"]
23193
24175
  try:
@@ -23208,6 +24190,7 @@ def _cli_main():
23208
24190
  runs it per request with sys.argv, the streams and atexit swapped in.
23209
24191
  Exits by SystemExit like any script; never returns a code."""
23210
24192
  global _FRAME_FLAG
24193
+ _replay_take_values() # a replayed environment value never stays in os.environ
23211
24194
  # On Windows a pipe (an agent's shell, the MCP server, `> file`) gets the
23212
24195
  # ANSI code page, so a Turkish page title or an emoji crashed the command
23213
24196
  # with UnicodeEncodeError. Write UTF-8 unless the user chose an encoding.