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/README.md +42 -24
- package/bin/cdpilot.js +25 -3
- package/package.json +3 -3
- package/src/cdpilot.py +1068 -85
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15910
|
-
|
|
15911
|
-
|
|
15912
|
-
|
|
15913
|
-
|
|
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
|
|
16366
|
-
|
|
16367
|
-
|
|
16368
|
-
|
|
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
|
-
|
|
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) —
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
22592
|
-
|
|
22593
|
-
|
|
22594
|
-
|
|
22595
|
-
|
|
22596
|
-
|
|
22597
|
-
|
|
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
|
-
|
|
22614
|
-
|
|
22615
|
-
|
|
22616
|
-
|
|
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
|
-
|
|
22619
|
-
|
|
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
|
-
|
|
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
|
|
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})
|
|
22701
|
-
|
|
22702
|
-
|
|
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.
|