[eric] browser: submit picker below-composer scope + container tier; readonly guard sealed

This commit is contained in:
ciregenz
2026-07-21 21:02:55 -07:00
parent bfc6a350dd
commit 10f9c12a8a
7 changed files with 231 additions and 23 deletions
+31 -10
View File
@@ -62,6 +62,8 @@ P_WRAPUP_NUDGE = (
from backend.apps.agents.browser import browser_batch_replay
from backend.apps.agents.browser import browser_extract
from backend.apps.agents.browser import browser_metrics
from backend.apps.agents.browser import browser_send_script
from backend.apps.agents.browser import browser_submit_click
from backend.apps.agents.browser import browser_playbook
from backend.apps.agents.browser import browser_save
from backend.apps.agents.browser import browser_meta_playbook
@@ -525,11 +527,8 @@ P_HINT_SEND_LABELS = frozenset({"send", "send now", "send message"})
# send-path COMPLETE on X/IG/FB/Threads/YouTube. Safe ONLY there because the send-script re-verifies
# the composer cleared the exact payload, so an opener-vs-submit mismatch fails safe, never a false
# send. button-only (P_SEND_ROW_RE) + exact match keeps "Post" from matching "Post a job" etc.
P_SEND_LABELS = frozenset({
"send", "send now", "send message", # LinkedIn / Gmail / DMs
"post", "post all", "tweet", "reply", # X / Threads compose + reply
"publish", "comment", "share", # articles / YouTube+FB comments / shares
})
# Defined in browser_submit_click so the container-scoped JS tier shares the exact same set.
P_SEND_LABELS = browser_submit_click.SEND_LABELS
def send_index_in_state(state_text: str):
@@ -542,13 +541,16 @@ def send_index_in_state(state_text: str):
return None
def send_submit_index_in_state(state_text: str):
def send_submit_index_in_state(state_text: str, after_index: int = -1):
"""(index, name) of a submit button across the popular composers (Post/Reply/Tweet/...), for the
receipt-gated SEND-SCRIPT only. Broader than the hint matcher; safe because the send-script
verifies the composer cleared afterward, so a wrong match aborts, never sends."""
verifies the composer cleared afterward, so a wrong match aborts, never sends. `after_index`
scopes the scan to buttons BELOW the filled composer: every real submit follows its editor in
the listing, while a compose OPENER (X's sidebar "Post") sits above it, and clicking the opener
posts nothing (measured live: 0/2 X deliveries the day X shipped its opener as a button)."""
for line in (state_text or "").splitlines():
m = P_SEND_ROW_RE.search(line)
if m and m.group(2).strip().lower() in P_SEND_LABELS:
if m and m.group(2).strip().lower() in P_SEND_LABELS and int(m.group(1)) > after_index:
return int(m.group(1)), m.group(2)
return None
@@ -615,6 +617,25 @@ def fill_text_of(tool_name: str, tool_input: dict) -> str:
return ""
def fill_index_of(tool_name: str, tool_input: dict) -> int:
"""The listing index a composer-fill action typed into, -1 when unknown. Mirrors fill_text_of."""
ti = tool_input or {}
if tool_name in ("BrowserClickIndex", "BrowserType"):
try:
return int(ti.get("index", -1))
except (TypeError, ValueError):
return -1
if tool_name == "BrowserBatch":
for a in (ti.get("actions") or []):
p = a.get("params") or {}
if a.get("type") in ("type", "click_index") and str(p.get("text") or "").strip():
try:
return int(p.get("index", -1))
except (TypeError, ValueError):
return -1
return -1
def payload_in_textbox(state_text: str, payload: str) -> bool:
"""True if any listed textbox VALUE carries the typed payload (fill committed).
Matches on a prefix because long payloads truncate in the list."""
@@ -1446,7 +1467,6 @@ async def run_browser_agent(
batch_guard_blocks = 0
# Staged-send script: prestage left a ready composer + the task quotes its payload -> code runs the fill/verify/send/verify tail the model spends 4-5 turns on. Success skips the loop entirely (turns=0); any pre-click ambiguity falls through untouched.
from backend.apps.agents.browser import browser_send_script
p_script = None
# A removal task ("delete the post that says 'X'") is also task_is_send (the classifier keys
# on the verb), so the send-script must stand down or it TYPES the target into a composer and
@@ -2335,7 +2355,8 @@ async def run_browser_agent(
and browser_send_script.autosend_enabled()):
p_cs = await browser_send_script.complete_send(
composer_committed_payload, p_auto_state or "", browser_id, tab_id,
execute_browser_tool, send_submit_index_in_state)
execute_browser_tool, send_submit_index_in_state,
composer_index=fill_index_of(tu.name, tool_input))
if p_cs.get("clicked"):
send_confirmed = True
action_log.extend(p_cs.get("log") or [])
@@ -117,6 +117,11 @@ def entry_url_from_brief(brief: str) -> str:
return m.group(1).rstrip(".,;)") if m else ""
# Opens the advisory-brief section of a composed task; consumers strip everything after it when a
# check must apply only to the human's words (the brief once false-flagged a real send read-only).
BRIEF_MARKER = "[routing brief"
def compose_task(prompt: str, brief: str) -> str:
"""User's words first and authoritative; the brief is advisory routing.
Skill replay keys on the parent's user message, so brief variance is safe."""
@@ -124,7 +129,7 @@ def compose_task(prompt: str, brief: str) -> str:
return prompt
return (
f"{prompt}\n\n"
"[routing brief from a fast pre-pass; follow it unless the live page disagrees]\n"
f"{BRIEF_MARKER} from a fast pre-pass; follow it unless the live page disagrees]\n"
f"{brief}"
)
@@ -13,16 +13,18 @@ the untouched model path; ambiguity AFTER the click hands the model a truthful
"""
import asyncio
import json
import logging
import os
import re
import time
from typing import Awaitable, Callable, Dict
from backend.apps.agents.browser import browser_verified_action
from backend.apps.agents.browser import browser_fast_path, browser_submit_click, browser_verified_action
logger = logging.getLogger(__name__)
# Double quotes are unambiguous. Single quotes only delimit when the opener is at a word boundary (start/space/colon), so an in-word apostrophe like "chen's" is never mistaken for a payload quote, that mispairing was silently corrupting the canonical "text him '...'" errand.
P_QUOTED_DQ_RE = re.compile(r'"([^"]{4,300})"')
P_QUOTED_SQ_RE = re.compile(r"(?:^|[\s:>])'([^']{4,300})'")
@@ -79,7 +81,8 @@ def autosend_enabled() -> bool:
async def complete_send(
payload: str, state_committed: str, browser_id: str, tab_id: str,
execute_tool: ToolRunner, send_index_in_state: Callable[[str], object],
execute_tool: ToolRunner, send_index_in_state: Callable[[str, int], object],
composer_index: int = -1,
) -> Dict[str, object]:
"""Send tail for a composer that ALREADY holds `payload` (visible in state_committed): find the
Send control (ranked index first, else click-by-name over the full DOM), click it once, and
@@ -97,15 +100,29 @@ async def complete_send(
except Exception:
return ""
send_btn = send_index_in_state(state_committed)
send_btn = send_index_in_state(state_committed, composer_index)
via = "index"
if send_btn:
r_send = await execute_tool("BrowserClickIndex", {"index": send_btn[0]}, browser_id, tab_id)
send_name = send_btn[1]
else:
r_send = await execute_tool("BrowserClickByName", {"name": "Send", "role": "button"}, browser_id, tab_id)
send_name = "Send (by-name)"
# No submit listed below the composer (the capped listing can starve a modal of its own
# button): click the submit inside the composer's OWN container, then last-resort by-name.
r_send = await execute_tool(
"BrowserEvaluate",
{"expression": browser_submit_click.container_submit_expression(payload)}, browser_id, tab_id)
p_v = browser_submit_click.parse_eval_value(r_send)
if isinstance(p_v, dict) and p_v.get("ok"):
send_name = str(p_v.get("name") or "submit")
via = "container"
else:
p_why = p_v.get("why") if isinstance(p_v, dict) else "unreadable eval"
logger.info(f"[browser-sendscript] container submit miss ({p_why}); by-name fallback")
r_send = await execute_tool("BrowserClickByName", {"name": "Send", "role": "button"}, browser_id, tab_id)
send_name = "Send (by-name)"
via = "by-name"
clicked = isinstance(r_send, dict) and "error" not in r_send
log.append({"tool": "send click", "input": {"via": "index" if send_btn else "by-name"},
log.append({"tool": "send click", "input": {"via": via},
"ok": clicked, "result_summary": f"send click {send_name!r}"[:200],
"elapsed_ms": 0, "clicked_role": "button", "clicked_name": send_name})
if not clicked:
@@ -228,10 +245,12 @@ async def run_send_script(
else:
logger.info(f"[browser-sendscript] decline: no composer or opener after poll ({current_url[:50]!r})")
return None
# Key read-only on the user's OWN words, not the advisory brief composed into `task`: the aux
# brief wrote "do not submit/post it" for a plain "start a post", which falsely read-only-flagged
# a real send. Fall back to task only when the raw prompt isn't threaded through.
if P_READONLY_RE.search(payload_source or task):
# Key read-only on words a HUMAN wrote: the task minus the aux routing brief (the brief wrote
# "do not submit it" for a plain "start a post", falsely read-only-flagging a real send) PLUS
# the raw prompt when threaded through. The task text itself must keep declining regardless: a
# read-only VERIFY probe arrives as the task, and one once delivered a real message (r243).
task_sans_brief = task.split(browser_fast_path.BRIEF_MARKER, 1)[0]
if P_READONLY_RE.search(task_sans_brief) or (payload_source and P_READONLY_RE.search(payload_source)):
logger.info("[browser-sendscript] decline: read-only directive in user request")
return None
if looks_like_login_wall(current_url, state_text):
@@ -349,12 +368,13 @@ async def run_send_script(
# doing the outward send. Everything up to here ran (surface gate passed, composer
# found, fill committed); we stop before the irreversible click and report readiness.
if os.environ.get("OSW_SENDSCRIPT_DRYRUN") == "1":
send_ready = bool(send_index_in_state(state2))
send_ready = bool(send_index_in_state(state2, composer[0]))
logger.info(f"[browser-sendscript] DRYRUN: WOULD send (fill committed, send_button_listed={send_ready}); not clicking")
return {"sent": False, "payload": payload, "log": log,
"note": "DRYRUN: filled + ready to send, stopped before the irreversible click"}
# 3+4: the irreversible click + two-sided receipt, shared with the mid-loop takeover. A click error hands back to the model (fill committed, not sent); a clicked-but-unverified send returns sent=False so the caller never claims delivery.
r = await complete_send(payload, state2, browser_id, tab_id, execute_tool, send_index_in_state)
r = await complete_send(payload, state2, browser_id, tab_id, execute_tool, send_index_in_state,
composer_index=composer[0])
log.extend(r["log"])
if not r["clicked"]:
logger.info("[browser-sendscript] send click errored; handing to model (fill committed, NOT sent)")
@@ -0,0 +1,69 @@
"""Container-scoped submit click for the receipt-gated send path. Exists because the ranked
interactives listing caps at 60 rows and a composer's own submit can fall off it (X's compose
modal: covered feed rows behind the overlay ate the cap, so no "Post" row ever reached the index
picker, measured live 0/2 deliveries). Scope = the dialog/form ancestor of the editable holding
the payload when there is one, else a bounded nearest-scope-first upward walk, so a page-level
opener with the same label can never be chosen. A wrong resolution still fails the send receipt
downstream, never a false delivery claim."""
import json
from typing import Any, Dict, Optional
# BROAD submit vocabulary shared by the index picker (browser_agent) and the JS below, one source
# so the two tiers can never drift apart.
SEND_LABELS = frozenset({
"send", "send now", "send message", # LinkedIn / Gmail / DMs
"post", "post all", "tweet", "reply", # X / Threads compose + reply
"publish", "comment", "share", # articles / YouTube+FB comments / shares
})
P_CONTAINER_SUBMIT_JS = r"""(() => {
const PAYLOAD = %s;
const LABELS = new Set(%s);
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const vis = (el) => !!el && el.getClientRects().length > 0 && el.offsetParent !== null;
const enabled = (el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true';
const labelOf = (el) => norm(el.getAttribute('aria-label') || el.innerText || '');
const holds = (el) => ((el.value || el.textContent || '').indexOf(PAYLOAD) !== -1);
const ed = [...document.querySelectorAll('[contenteditable="true"],textarea,input')]
.find((e) => vis(e) && holds(e));
if (!ed) return { ok: false, why: 'no editable holding the payload' };
const submitIn = (root) => [...root.querySelectorAll('button,[role="button"]')]
.find((b) => vis(b) && enabled(b) && LABELS.has(labelOf(b)));
const scope = ed.closest('[role="dialog"],[role="alertdialog"],form');
let btn = null;
if (scope) {
btn = submitIn(scope);
} else {
// Nearest-scope-first walk: X's inline submit shares an ancestor 20 hops above the Draft.js
// editable while foreign tweets' buttons only enter at 28 (measured live), so 24 finds the
// composer's own submit and stops before any wider scope could.
let node = ed.parentElement;
for (let hop = 0; node && node !== document.body && hop < 24; hop++, node = node.parentElement) {
btn = submitIn(node);
if (btn) break;
}
}
if (!btn) return { ok: false, why: 'no submit control in the composer container' };
btn.click();
return { ok: true, name: labelOf(btn) };
})()"""
def container_submit_expression(payload: str) -> str:
"""The container-scoped submit click for a composer holding `payload` (prefix-matched, same
24-char truncation the fill verifier uses)."""
return P_CONTAINER_SUBMIT_JS % (json.dumps((payload or "")[:24]), json.dumps(sorted(SEND_LABELS)))
def parse_eval_value(res: object) -> Optional[Dict[str, Any]]:
"""The dict a BrowserEvaluate returned, or None. Unreadable shapes are None (honest miss)."""
val: object = None
if isinstance(res, dict) and "error" not in res:
val = res.get("value")
if val is None and isinstance(res.get("text"), str):
try:
val = json.loads(res["text"])
except (json.JSONDecodeError, ValueError):
val = None
return val if isinstance(val, dict) else None
+13
View File
@@ -1570,6 +1570,19 @@ def test_send_submit_matcher_broad_but_hint_matcher_tight():
assert send_index_in_state('[3]<button "Share">') is None
def test_send_submit_scoped_below_composer():
# X ships its sidebar compose OPENER as button "Post" ABOVE the composer; picking it posts
# nothing (live 0/2). after_index scopes the scan to buttons BELOW the filled composer.
from backend.apps.agents.browser.browser_agent import send_submit_index_in_state
x_home = '[25]<button "Post">\n[35]<textbox "Post text" value="check two">\n[58]<button "Post">'
assert send_submit_index_in_state(x_home, 35) == (58, "Post")
# no submit below the composer = None (falls to by-name, never the opener above)
opener_only = '[25]<button "Post">\n[35]<textbox "Post text" value="check two">'
assert send_submit_index_in_state(opener_only, 35) is None
# unscoped callers keep the old first-match behavior
assert send_submit_index_in_state(x_home) == (25, "Post")
def test_strip_lone_surrogates():
from backend.apps.agents.browser.browser_agent import strip_lone_surrogates, format_tool_result
# an orphan UTF-16 surrogate (half an emoji from the webview) is what crashes the turn at .encode('utf-8'); it must be swapped, not carried through
+16
View File
@@ -208,6 +208,22 @@ async def test_composed_task_brief_quotes_fire_via_payload_source():
assert r["payload"] == "[test] hello world r9-os"
@pytest.mark.asyncio
async def test_brief_readonly_wording_does_not_disarm():
"""The aux brief wrote 'do not submit it' for a plain send once, false-flagging it read-only;
the brief section is stripped before the read-only check, so the send still fires."""
composed = (
f"{TASK}\n\n"
"[routing brief from a fast pre-pass; follow it unless the live page disagrees]\n"
"STEPS: type the text into the composer, do not submit anything else, read-only elsewhere"
)
ex, calls = make_exec([COMPOSER_FILLED, COMPOSER_FILLED, COMPOSER_SENT])
r = await ss.run_send_script(composed, "b1", "", COMPOSER_EMPTY, ex,
send_submit_index_in_state, payload_in_textbox,
payload_source=TASK, current_url=THREAD_URL)
assert r is not None and r["sent"] is True
def test_dryrun_report_encodes_the_gate_funnel():
"""The coverage harness greps this one line for gate attribution: a staged composer
reports composer=1, a bare page reports zeros, and armed/filled ride the booleans."""
@@ -0,0 +1,64 @@
"""Container-scoped submit tier: the middle rung between the below-composer index pick and the
by-name 'Send' last resort, plus the expression builder's escaping. The JS itself is proven live
(X modal resolves tweetButton, inline resolves tweetButtonInline at hop 20); these tests pin the
tier ORDER and the fail-safe parse."""
import pytest
from backend.apps.agents.browser import browser_send_script as ss
from backend.apps.agents.browser import browser_submit_click as sc
from backend.apps.agents.browser.browser_agent import send_submit_index_in_state
# Committed fill whose submit ranks OUT of the capped listing (the shape that starves the picker).
COMPOSER_FILLED_NO_SEND = '[1]<textbox "I\'m looking for…">\n[24]<textbox "Write a message" value="[test] hello world r9-os">'
COMPOSER_SENT = '[2]<textbox "Write a message">\n[9]<button "Attach">'
def make_exec(eval_result):
calls = {"clicks": []}
async def execute(tool, params, bid, tid):
if tool == "BrowserListInteractives":
return {"text": COMPOSER_SENT}
calls["clicks"].append((tool, params))
if tool == "BrowserEvaluate":
return eval_result
return {"ok": True}
return execute, calls
@pytest.mark.asyncio
async def test_container_submit_tier_between_index_and_by_name():
"""X's compose modal: covered feed rows behind the overlay eat the 60-row cap, so the modal's
own Post never reaches the picker (live 0/2 deliveries). The container-scoped JS tier clicks
the submit inside the composer's own container; by-name stays the last resort."""
execute, calls = make_exec({"value": {"ok": True, "name": "post"}})
r = await ss.complete_send("[test] hello world r9-os", COMPOSER_FILLED_NO_SEND,
"b1", "", execute, send_submit_index_in_state, composer_index=24)
assert r["clicked"] is True and r["sent"] is True
tools = [c[0] for c in calls["clicks"]]
assert "BrowserEvaluate" in tools and "BrowserClickByName" not in tools
@pytest.mark.asyncio
async def test_container_submit_miss_falls_to_by_name():
execute, calls = make_exec({"value": {"ok": False, "why": "no submit control in the composer container"}})
r = await ss.complete_send("[test] hello world r9-os", COMPOSER_FILLED_NO_SEND,
"b1", "", execute, send_submit_index_in_state, composer_index=24)
assert r["clicked"] is True
tools = [c[0] for c in calls["clicks"]]
assert tools.index("BrowserEvaluate") < tools.index("BrowserClickByName")
def test_container_submit_expression_escapes_and_truncates():
expr = sc.container_submit_expression('he said "hi" ' + "x" * 60)
assert '\\"hi\\"' in expr # payload lands as a JS string literal, quotes escaped
assert "x" * 30 not in expr # truncated to the 24-char prefix the fill verifier uses
assert '"post"' in expr and '"send"' in expr
def test_parse_eval_value_reads_value_text_and_garbage():
assert sc.parse_eval_value({"value": {"ok": True}}) == {"ok": True}
assert sc.parse_eval_value({"text": '{"ok": false, "why": "x"}'}) == {"ok": False, "why": "x"}
assert sc.parse_eval_value({"text": "not json"}) is None
assert sc.parse_eval_value({"error": "boom"}) is None
assert sc.parse_eval_value("weird") is None