fix(navigation): harden autonomous perimeter guards against Play Store trap

This commit is contained in:
2026-05-03 18:29:25 +02:00
parent 93b2140844
commit b36dde77d8
12 changed files with 709 additions and 18 deletions

View File

@@ -247,7 +247,8 @@ class InstagramEmulator:
# Could implement swipe transitions here (e.g. scroll down loads new feed)
def app_start(self, pkg, use_monkey=False):
pass
self.app_starts.append(pkg)
logger.info(f"[Emulator] app_start({pkg})")
def app_stop(self, pkg):
pass

View File

@@ -34,7 +34,7 @@ def test_goap_recovers_from_trapped_state_with_restart(monkeypatch):
# We just want to see that AFTER the restart, it tries 'tap reels tab' again,
# meaning it cleared the state.
goap.achieve("open reels", max_steps=6)
goap.achieve("open reels", max_steps=10)
# It should have tried 'tap reels tab' twice, failed both times,
# then triggered 'force start instagram'.

View File

@@ -0,0 +1,52 @@
"""
Production Bug Regression: Play Store Trap (2026-05-03)
========================================================
Session: 2026-05-03_18-03-15
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
Root Cause: Story loop had no perimeter guard. A story's swipe-up link
opened the Play Store, and the bot continued tapping at (w*0.85, h*0.5)
on Play Store UI for 5+ iterations until user killed the process.
This test uses the EXACT production XML dumps to validate:
1. ScreenIdentity correctly classifies Play Store as FOREIGN_APP
2. SAE correctly classifies Play Store as OBSTACLE_FOREIGN_APP
3. Story frame before the linkout is correctly classified as STORY_VIEW
"""
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
class TestPlayStoreTrapRegression:
def test_screen_identity_classifies_play_store_as_foreign(self):
"""ScreenIdentity must classify com.android.vending as FOREIGN_APP."""
xml = load_fixture_xml("play_store_from_story_link.xml")
sid = ScreenIdentity("testuser")
result = sid.identify(xml)
assert result["screen_type"] == ScreenType.FOREIGN_APP, (
f"ScreenIdentity classified Play Store as {result['screen_type'].value}! "
f"This is the exact production bug from 2026-05-03."
)
def test_sae_classifies_play_store_as_obstacle_foreign_app(self):
"""SAE must classify com.android.vending as OBSTACLE_FOREIGN_APP."""
xml = load_fixture_xml("play_store_from_story_link.xml")
device = E2EDeviceStub([xml])
SituationalAwarenessEngine.reset()
sae = SituationalAwarenessEngine(device)
result = sae.perceive(xml)
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
f"SAE classified Play Store as {result.value}! " f"Exact production bug regression from 2026-05-03."
)
def test_story_view_before_linkout_is_story(self):
"""The last story frame before the Play Store must be STORY_VIEW."""
xml = load_fixture_xml("story_view_before_linkout.xml")
sid = ScreenIdentity("testuser")
result = sid.identify(xml)
assert result["screen_type"] == ScreenType.STORY_VIEW, (
f"Frame before Play Store was classified as {result['screen_type'].value}, "
f"not STORY_VIEW. This could cause false positives."
)

View File

@@ -0,0 +1,56 @@
"""
E2E: SAE Foreign App Fast-Path
================================
Validates that the SAE classifies known foreign packages (Play Store,
Chrome, Settings) as OBSTACLE_FOREIGN_APP using O(1) structural detection
WITHOUT falling through to LLM classification.
This eliminates 2-5 seconds of LLM inference for detections that
should be instantaneous set lookups.
"""
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
def test_sae_classifies_play_store_without_llm():
"""Play Store XML must be classified as FOREIGN_APP via fast-path, not LLM."""
xml = load_fixture_xml("play_store_from_story_link.xml")
device = E2EDeviceStub([xml])
SituationalAwarenessEngine.reset()
sae = SituationalAwarenessEngine(device)
result = sae.perceive(xml)
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
f"SAE classified Play Store as {result.value} instead of OBSTACLE_FOREIGN_APP! "
f"The bot cannot detect it left Instagram."
)
def test_sae_fast_path_handles_known_foreign_packages():
"""
Verify the fast-path handles com.android.vending, com.android.chrome,
com.google.android.youtube etc. without LLM calls.
com.android.settings may classify as OBSTACLE_SYSTEM which is also valid.
"""
known_foreign_pkgs = {
"com.android.vending": SituationType.OBSTACLE_FOREIGN_APP,
"com.android.chrome": SituationType.OBSTACLE_FOREIGN_APP,
"com.google.android.youtube": SituationType.OBSTACLE_FOREIGN_APP,
}
for pkg, expected in known_foreign_pkgs.items():
xml = f"""<?xml version="1.0" encoding="utf-8"?>
<hierarchy rotation="0">
<node class="android.widget.FrameLayout" package="{pkg}"
bounds="[0,0][1080,2400]">
<node text="Some content" class="android.widget.TextView"
package="{pkg}" />
</node>
</hierarchy>"""
device = E2EDeviceStub([xml])
SituationalAwarenessEngine.reset()
sae = SituationalAwarenessEngine(device)
result = sae.perceive(xml)
assert result == expected, f"Package {pkg} was classified as {result.value}, expected {expected.value}!"

View File

@@ -154,6 +154,10 @@ class TestSAELoop:
from GramAddict.core.perception.screen_identity import ScreenIdentity
from GramAddict.core.qdrant_memory import ScreenMemoryDB
db = ScreenMemoryDB()
if not db.is_connected:
pytest.skip("Qdrant is not running — this test requires vector store for NORMAL override storage")
# Load real home feed XML
xml_dump = Path("tests/e2e/fixtures/home_feed_real.xml").read_text()
@@ -168,7 +172,7 @@ class TestSAELoop:
# Simulate LLM unlearning by storing this exact state as NORMAL
compressed = sae._compress_xml(xml_dump)
ScreenMemoryDB().store_screen(compressed, "NORMAL")
db.store_screen(compressed, "NORMAL")
identity = ScreenIdentity("testuser")
# Ensure we inject the device so get_screenshot_b64 doesn't crash if it falls back

View File

@@ -0,0 +1,67 @@
"""
E2E: Feed Loop Foreign App Escape
====================================
Validates that the feed loop detects a foreign app takeover
(e.g. Play Store opened) and immediately aborts with CONTEXT_LOST.
Parity with story loop guard.
"""
import datetime
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
def test_feed_loop_escapes_foreign_app(e2e_configs):
"""
Simulates: Feed → Play Store.
The loop must detect com.android.vending and abort immediately.
"""
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
from GramAddict.core.dopamine_engine import DopamineEngine
feed_xml = load_fixture_xml("home_feed_with_ad.xml")
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
# Feed -> Play Store
device = E2EDeviceStub([feed_xml, play_store_xml])
dopamine = DopamineEngine()
dopamine.session_limit_seconds = 999
# Needs a dummy zero_engine and nav_graph
class DummyZeroEngine:
pass
class DummyNavGraph:
def do(self, action):
pass
def navigate_to(self, state, engine):
pass
cognitive_stack = {
"dopamine": dopamine,
"zero_engine": DummyZeroEngine(),
"nav_graph": DummyNavGraph(),
"radome": None,
"active_inference": None,
}
session_state = type(
"S", (), {"startTime": datetime.datetime.now(), "check_limit": lambda *args, **kwargs: (False, False)}
)()
# Override speed_multiplier to avoid multi-minute sleeps in CI
e2e_configs.args.speed_multiplier = 0.01
result = _run_zero_latency_feed_loop(
device, DummyZeroEngine(), DummyNavGraph(), e2e_configs, session_state, "HomeFeed", cognitive_stack
)
# Must return CONTEXT_LOST
assert (
result == "CONTEXT_LOST"
), f"Feed loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground."
# Must have pressed back to attempt recovery
assert "back" in device.pressed_keys, "Feed loop detected foreign app but never pressed BACK to recover!"

View File

@@ -0,0 +1,54 @@
"""
E2E: Story Loop Foreign App Escape
====================================
Validates that the story binging loop detects a foreign app takeover
(e.g. Play Store opened via story link) and immediately aborts with
CONTEXT_LOST instead of continuing to tap blindly.
Production Bug Reproduction:
Session: 2026-05-03_18-03-15
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
Root Cause: _run_zero_latency_stories_loop had no perimeter guard.
Uses the REAL production XML dumps from the 2026-05-03 session trace.
"""
import datetime
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
def test_story_loop_escapes_foreign_app(e2e_configs):
"""
Simulates: Story → Story → Story → Play Store (via swipe-up link).
The loop must detect com.android.vending and abort immediately.
"""
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
from GramAddict.core.dopamine_engine import DopamineEngine
story_xml = load_fixture_xml("story_view_before_linkout.xml")
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
# 3 story iterations → then Play Store appears
device = E2EDeviceStub([story_xml, story_xml, story_xml, play_store_xml])
dopamine = DopamineEngine()
dopamine.session_limit_seconds = 999
cognitive_stack = {"dopamine": dopamine}
session_state = type("S", (), {"startTime": datetime.datetime.now()})()
# Override speed_multiplier to avoid multi-minute sleeps in CI
e2e_configs.args.speed_multiplier = 0.01
result = _run_zero_latency_stories_loop(device, e2e_configs, session_state, cognitive_stack)
# Must return CONTEXT_LOST, NOT FEED_EXHAUSTED
assert result == "CONTEXT_LOST", (
f"Story loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground. "
f"The bot would have tapped blindly on the Play Store! "
f"This is the exact production bug from 2026-05-03."
)
# Must have pressed back to attempt recovery
assert "back" in device.pressed_keys, "Story loop detected foreign app but never pressed BACK to recover!"