diff --git a/backend/apps/agents/browser/browser_send_parse.py b/backend/apps/agents/browser/browser_send_parse.py index df114d56..e6041bbf 100644 --- a/backend/apps/agents/browser/browser_send_parse.py +++ b/backend/apps/agents/browser/browser_send_parse.py @@ -138,15 +138,25 @@ def looks_signed_out(state_text: str) -> bool: P_POST_INTENT_RE = re.compile(r"\b(post|tweet|publish|share)\b", re.I) P_COMMENT_INTENT_RE = re.compile(r"\b(comment|reply|respond)\b", re.I) P_COMMENT_SURFACE_RE = re.compile(r"\b(comment|reply)\b", re.I) +# The private-message box is the worst wrong surface there is, because getting it wrong is not a +# failed action, it is the user's words delivered privately to a named stranger. Live sweep, dry +# run: 'write a comment on the first post' walked to instagram.com//, took that profile's +# 'Message' opener and filled 'Message...'. Armed, it would have DM'd them. +P_DM_SURFACE_RE = re.compile(r"\b(message|messages|dm)\b", re.I) +P_DM_INTENT_RE = re.compile(r"\b(dm|dms|message|messages|email|e-mail|mail|inbox|chat)\b", re.I) def surface_mismatch(task: str, composer_name: str) -> bool: - """True when the task asks to create a POST but the composer found is a comment/reply box. + """True when the composer we found contradicts what the task actually asked for. - Deliberately one-directional: a task that mentions commenting is left alone, so this can only - ever reject a comment box for a post task, never the reverse. A rejection is cheap (the - structural finder, which does find LinkedIn's real composer, gets its turn instead).""" + A public ask (post, comment) is contradicted by a DM box, and a post ask is contradicted by a + comment box. A task that asked for neither is left alone entirely, so 'text tyler hello' still + gets its message box. Rejecting only ever costs a turn: the structural finder, which does find + LinkedIn's real composer, gets its go instead.""" t, name = task or "", composer_name or "" + p_public = bool(P_POST_INTENT_RE.search(t) or P_COMMENT_INTENT_RE.search(t)) + if p_public and P_DM_SURFACE_RE.search(name) and not P_DM_INTENT_RE.search(t): + return True if not P_POST_INTENT_RE.search(t) or P_COMMENT_INTENT_RE.search(t): return False return bool(P_COMMENT_SURFACE_RE.search(name)) diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index b1af1f1d..ad423a16 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -259,10 +259,10 @@ async def run_send_script( composer = browser_send_parse.composer_index_in_state(state_text) if composer and browser_send_parse.surface_mismatch(task_sans_brief, composer[1]): - # Asked to POST, found a COMMENT box: that is someone else's content, not a slower route to - # ours. Drop it and let the tiers below (opener, then the structural finder, which does find - # LinkedIn's real composer) look properly. - logger.info(f"[browser-sendscript] ignoring {composer[1]!r}: a comment box is not where a post goes") + # Asked to POST and found a COMMENT box, or found a DM box nobody asked for: either way it + # is someone else's surface, not a slower route to ours. Drop it and let the tiers below + # (opener, then the structural finder, which does find LinkedIn's real composer) look properly. + logger.info(f"[browser-sendscript] ignoring composer {composer[1]!r}: wrong surface for this task") composer = None if not composer: # The staged snapshot is prestage's, frozen the instant it clicked Message; the overlay composer lazy-renders a beat later (r263/r269 declined on exactly this, prestage's LAST step was the Message click). Poll a short window so the overlay has time to appear before we fall back to the opener. @@ -278,13 +278,14 @@ async def run_send_script( # Reversible-opener hop: prestage often stops on the profile with the "Message" opener visible (its settle raced the overlay). Opening a composer is the allowed opener class; the irreversible bar is unchanged. opener = browser_send_parse.opener_index_in_state(state_text) if opener and browser_send_parse.surface_mismatch(task_sans_brief, opener[1]): - # The same post-is-not-a-comment rule the composer already enforces, applied one step - # earlier. Measured on linkedin.com with the task "start a post": the only opener listed - # was 'Comment', so the script opened a stranger's comment box, found no post composer - # inside it, and declined. Opening the wrong surface is not a slower route to the right - # one, and here it also burns the reversible-opener hop we only get once. - logger.info(f"[browser-sendscript] ignoring opener {opener[1]!r}: a comment box is not " - f"where a post goes") + # The same wrong-surface rule the composer already enforces, applied one step earlier. + # Measured on linkedin.com with "start a post": the only opener listed was 'Comment', so + # the script opened a stranger's comment box, found no post composer inside it, and + # declined. Measured on instagram.com with "write a comment on the first post": the + # opener taken was the profile's 'Message', which is a DM to that person. Opening the + # wrong surface is not a slower route to the right one, and it burns the one reversible + # opener hop we get. + logger.info(f"[browser-sendscript] ignoring opener {opener[1]!r}: wrong surface for this task") opener = None if opener: logger.info(f"[browser-sendscript] firing via opener {opener[1]!r} [{opener[0]}]") diff --git a/backend/tests/test_browser_readonly_guard.py b/backend/tests/test_browser_readonly_guard.py index 0600767e..6e2d6f5b 100644 --- a/backend/tests/test_browser_readonly_guard.py +++ b/backend/tests/test_browser_readonly_guard.py @@ -117,7 +117,10 @@ def test_a_comment_task_keeps_its_comment_opener(): def test_a_post_task_keeps_a_real_post_opener(): - for opener in ("Post", "Compose", "New message", "Message"): + # "New message" and "Message" used to sit in this list as merely not-a-comment. They are DM + # openers on every site we drive, and taking one for a post task is how instagram came within + # a dry-run flag of DMing a stranger. See test_browser_wrong_surface.py. + for opener in ("Post", "Compose", "Create post", "Write"): assert not sp.surface_mismatch('post this, exactly: "hi"', opener), opener diff --git a/backend/tests/test_browser_wrong_surface.py b/backend/tests/test_browser_wrong_surface.py new file mode 100644 index 00000000..49453403 --- /dev/null +++ b/backend/tests/test_browser_wrong_surface.py @@ -0,0 +1,69 @@ +"""A send must land on the surface the task asked for, or not at all. + +Getting this wrong is not a failed action. Measured live 2026-07-31 in a dry-run sweep: the task +"write a comment on the first post" walked to instagram.com//, took that profile's +"Message" opener and filled "Message...". The only reason it did not DM a stranger the text is that +the run was armed with OSW_SENDSCRIPT_DRYRUN=1. + +So the real phrasings from that sweep are pinned here, both the ones that must be refused and the +ones that must keep working, because a guard this blunt breaks whole sites when it drifts. +""" + +from backend.apps.agents.browser import browser_send_parse as sp + +# Verbatim from the coverage harness, which is what actually reaches the send script. +X = 'Go to x.com and post this tweet, exactly: "coverage probe alpha"' +LINKEDIN = 'Go to linkedin.com and create a post with exactly this text: "coverage probe alpha"' +INSTAGRAM = 'Go to instagram.com and write a comment "coverage probe alpha" on the first post' +GMAIL = 'Go to gmail and start a new email with the body "coverage probe alpha"' +TWITCH = 'Go to twitch.tv, open the first live channel, and write "coverage probe alpha" in chat' +YOUTUBE = 'Go to youtube.com, open the first video, and write the comment "coverage probe alpha"' + + +def test_the_instagram_dm_that_started_this(): + assert sp.surface_mismatch(INSTAGRAM, "Message") is True + assert sp.surface_mismatch(INSTAGRAM, "Message...") is True + + +def test_no_task_that_never_mentions_messaging_may_reach_a_dm_box(): + for task in (X, LINKEDIN, INSTAGRAM, YOUTUBE): + for surface in ("Message", "Message...", "Direct message", "Send a DM", "New message"): + assert sp.surface_mismatch(task, surface) is True, (task, surface) + + +def test_a_task_that_does_ask_to_message_still_gets_its_composer(): + # Gmail's box is literally named "Message Body" and twitch's chat input is "Send a message"; + # refusing those would take both sites out of the write path entirely. + assert sp.surface_mismatch(GMAIL, "Message Body") is False + assert sp.surface_mismatch(TWITCH, "Send a message") is False + assert sp.surface_mismatch("DM @someone \"hi\"", "Message...") is False + assert sp.surface_mismatch("text tyler hello", "Write a message") is False + # "reply" reads as public, so this one needs the word inbox to survive. + assert sp.surface_mismatch("Reply to her inbox thread with \"hi\"", "Message") is False + + +def test_the_original_post_versus_comment_rule_is_untouched(): + assert sp.surface_mismatch(LINKEDIN, "Text editor for creating comment") is True + assert sp.surface_mismatch(X, "Add a comment") is True + # A task that ASKS to comment keeps its comment box. + assert sp.surface_mismatch(YOUTUBE, "Add a comment") is False + assert sp.surface_mismatch(INSTAGRAM, "Add a comment...") is False + + +def test_the_composers_we_actually_want_are_never_refused(): + assert sp.surface_mismatch(X, "Post text") is False + assert sp.surface_mismatch(LINKEDIN, "Share") is False + assert sp.surface_mismatch(LINKEDIN, "start a post") is False + assert sp.surface_mismatch(TWITCH, "Chat") is False + + +def test_a_task_asking_for_neither_a_post_nor_a_comment_is_left_alone(): + # Nothing to contradict, so the guard stays out of it rather than guessing. + for surface in ("Message", "Message...", "New message"): + assert sp.surface_mismatch("send tyler hello", surface) is False, surface + + +def test_empty_inputs_never_refuse(): + assert sp.surface_mismatch("", "") is False + assert sp.surface_mismatch(X, "") is False + assert sp.surface_mismatch("", "Message") is False