diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 38398f4c..94a9df82 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -321,6 +321,28 @@ async def execute_browser_tool( return result +async def p_learn_write_recipe(execute_tool, browser_id: str, tab_id: str, current_url: str, payload: str) -> None: + """After a receipt-VERIFIED DOM write, distill the site's own write route into a replayable + recipe so the NEXT write on this host can skip the DOM (the repeated-write tier). Inline + + bounded (the card is still alive here; a fire-and-forget task would race the finish teardown) + and fully fail-open: no routes / no payload leaf / any error just means no recipe learned, and + the DOM path stays the default. Gated OFF until soaked (OSW_WRITE_RECIPES=1).""" + if os.environ.get("OSW_WRITE_RECIPES", "0") == "0": + return + try: + from backend.apps.agents.browser import browser_write_recipes, browser_skills + host = browser_skills.host_of(current_url or "") + if not host or len(payload or "") < 4: + return + listed = await asyncio.wait_for( + execute_tool("BrowserListRoutes", {"writes": True}, browser_id, tab_id), timeout=4.0) + routes = listed.get("routes") if isinstance(listed, dict) else None + if routes and browser_write_recipes.learn_recipe(host, payload, routes): + logger.info(f"[write-recipe] learned a replayable {host} write from a verified DOM send") + except Exception: + pass + + def p_extract_domain(url: str) -> str | None: """Extract the apex domain from a URL (acme-corp.notion.so → notion.so). Returns None for non-http URLs.""" @@ -1414,6 +1436,7 @@ async def run_browser_agent( done_called = True done_success = True p_aux_c, p_aux_m = await p_get_aux_client() + await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, p_script["payload"]) done_message = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_script["payload"]) or f'Done, I sent "{p_script["payload"]}" for you.') else: @@ -2163,6 +2186,7 @@ async def run_browser_agent( done_called = True done_success = True p_payload = browser_batch_replay.send_payload_from_log(action_log, task) + await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, p_payload) p_aux_c, p_aux_m = await p_get_aux_client() p_nice = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_payload) if p_payload else "") @@ -2256,6 +2280,7 @@ async def run_browser_agent( if p_cs.get("sent"): done_called = True done_success = True + await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, composer_committed_payload) p_aux_c, p_aux_m = await p_get_aux_client() done_message = (await compose_send_confirmation( p_aux_c, p_aux_m, task, composer_committed_payload) diff --git a/backend/apps/agents/browser/browser_write_recipes.py b/backend/apps/agents/browser/browser_write_recipes.py new file mode 100644 index 00000000..ca2c8856 --- /dev/null +++ b/backend/apps/agents/browser/browser_write_recipes.py @@ -0,0 +1,232 @@ +"""Learn-on-first-write, replay-on-repeat: the repeated-write half of the API-first tier. + +The first write on a site is unavoidably a DOM drive (a route can only be captured after the +site's own UI fires it, proven live in the V.8 X soak). But the moment a DOM write SUCCEEDS with +a verified receipt, the mutating route the page fired is a complete recipe: method + URL (with +its live queryId) + body, with the user's payload sitting in one JSON leaf. This module persists +that recipe with the payload slot replaced by a sentinel, and replays it with a NEW payload on +the next write to the same site, skipping the DOM entirely. + +SAFETY (documented in SECURITY.md): +- A recipe is learned ONLY from a receipt-verified successful write the user's own task performed, + so its provenance satisfies the captured-route wall (the site's UI genuinely fired it); replay + seeds route_write's captured set from the recipe itself. +- Secret-shaped string leaves in the stored body are redacted at learn time (payload slot + excepted); cookies/headers are never stored (route_write borrows them live per call). +- Same-origin + OSW_ROUTE_WRITE flag + typed fail-open outcomes all still apply at replay. +- Staleness self-heals: a recipe that misses MAX_MISSES times is dropped, and the next + successful DOM write learns a fresh one (queryId rotation just re-learns). +""" + +import json +import logging +import os +import re +import time +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +logger = logging.getLogger(__name__) + +SENTINEL = "__OSW_PAYLOAD__" +P_MIN_PAYLOAD_CHARS = 4 +P_MAX_BODY_CHARS = 32768 +MAX_MISSES = 3 +P_MAX_RECIPES_ON_DISK = 200 + +# Same secret heuristics as the electron capture (cdp-routes.js), ported so a token-shaped +# body value can never be persisted; over-redacting is the safe direction. +P_TOKEN_PREFIX = re.compile(r"^(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ|Bearer )") + + +@typechecked +def looks_secret_value(v: str) -> bool: + if not v: + return False + if P_TOKEN_PREFIX.match(v): + return True + return len(v) >= 20 and bool(re.search(r"[A-Za-z]", v)) and bool(re.search(r"[0-9]", v)) and not re.search(r"\s", v) + + +class WriteRecipe(BaseModel): + """One site's proven write call, payload slot replaced by the sentinel.""" + + model_config = ConfigDict(validate_assignment=True) + + host: str + method: str + url_template: str + url: str + body_template: str + payload_path: str + learned_at: float + wins: int = 0 + misses: int = 0 + + +@typechecked +def p_dir() -> str: + from backend.config.paths import DATA_ROOT + d = os.path.join(DATA_ROOT, "browser_write_recipes") + os.makedirs(d, mode=0o700, exist_ok=True) + return d + + +@typechecked +def p_path(host: str) -> str: + safe = re.sub(r"[^a-z0-9.-]", "_", host.lower()) + return os.path.join(p_dir(), f"{safe}.json") + + +@typechecked +def recipe_for(host: str) -> Optional[WriteRecipe]: + """The persisted recipe for this host, or None. Corrupt files read as None (fail-open).""" + try: + with open(p_path(host)) as f: + return WriteRecipe(**json.load(f)) + except Exception: + return None + + +@typechecked +def save_recipe(recipe: WriteRecipe) -> None: + """Atomic write, browser_skills pattern; cap the directory so it can't grow unbounded.""" + try: + d = p_dir() + entries = sorted(os.listdir(d), key=lambda f: os.path.getmtime(os.path.join(d, f))) + while len(entries) >= P_MAX_RECIPES_ON_DISK: + os.remove(os.path.join(d, entries.pop(0))) + tmp = p_path(recipe.host) + ".tmp" + with open(tmp, "w") as f: + json.dump(recipe.model_dump(mode="json"), f) + os.replace(tmp, p_path(recipe.host)) + except Exception as e: + logger.info(f"[write-recipe] save failed for {recipe.host}: {e}") + + +@typechecked +def drop_recipe(host: str) -> None: + try: + os.remove(p_path(host)) + except OSError: + pass + + +@typechecked +def p_find_payload_leaf(obj: Any, payload: str, path: str = "$") -> Optional[str]: + """JSON path of the leaf whose string value EQUALS the payload (exact, not substring: + a substring hit means the site wrapped it and blind substitution would corrupt).""" + if isinstance(obj, str): + return path if obj == payload else None + if isinstance(obj, dict): + for k, v in obj.items(): + hit = p_find_payload_leaf(v, payload, f"{path}.{k}") + if hit: + return hit + return None + if isinstance(obj, list): + for i, v in enumerate(obj): + hit = p_find_payload_leaf(v, payload, f"{path}[{i}]") + if hit: + return hit + return None + + +@typechecked +def p_transform_leaves(obj: Any, payload: str) -> Any: + """Copy with the payload leaf swapped for the sentinel and secret-shaped strings redacted.""" + if isinstance(obj, str): + if obj == payload: + return SENTINEL + return "" if looks_secret_value(obj) else obj + if isinstance(obj, dict): + return {k: p_transform_leaves(v, payload) for k, v in obj.items()} + if isinstance(obj, list): + return [p_transform_leaves(v, payload) for v in obj] + return obj + + +@typechecked +def learn_recipe(host: str, payload: str, routes: List[Dict[str, Any]]) -> Optional[WriteRecipe]: + """Distill a recipe from the captured mutating routes of a JUST-verified write. Returns the + saved recipe, or None when no route's body carries the payload as an exact string leaf + (then there is nothing provably replayable, so nothing is stored).""" + if len(payload or "") < P_MIN_PAYLOAD_CHARS: + return None + for r in routes: + body = str(r.get("lastBody") or "") + method = str(r.get("method") or "").upper() + if not body or len(body) > P_MAX_BODY_CHARS or method in ("GET", "HEAD"): + continue + try: + parsed = json.loads(body) + except (json.JSONDecodeError, ValueError): + continue + slot = p_find_payload_leaf(parsed, payload) + if not slot: + continue + recipe = WriteRecipe( + host=host, method=method, + url_template=str(r.get("template") or ""), + url=str(r.get("example") or r.get("template") or ""), + body_template=json.dumps(p_transform_leaves(parsed, payload)), + payload_path=slot, learned_at=time.time(), + ) + save_recipe(recipe) + logger.info(f"[write-recipe] learned {host} {method} {recipe.url_template[:80]} slot={slot}") + return recipe + return None + + +@typechecked +def build_body(recipe: WriteRecipe, payload: str) -> Optional[Dict[str, Any]]: + """The recipe body with the NEW payload in the slot; None when the template holds no + sentinel (corrupt or hand-edited = do not replay) or redacted leaves the site requires.""" + if SENTINEL not in recipe.body_template: + return None + try: + parsed = json.loads(recipe.body_template) + except (json.JSONDecodeError, ValueError): + return None + + def p_sub(obj: Any) -> Any: + if isinstance(obj, str): + return payload if obj == SENTINEL else obj + if isinstance(obj, dict): + return {k: p_sub(v) for k, v in obj.items()} + if isinstance(obj, list): + return [p_sub(v) for v in obj] + return obj + + out = p_sub(parsed) + return out if isinstance(out, dict) else None + + +@typechecked +async def replay_recipe(recipe: WriteRecipe, payload: str, origin: str) -> Dict[str, Any]: + """Replay the recipe with a new payload via route_write (same-origin + flag + live-borrowed + cookies all enforced there). The recipe IS the captured provenance: it was learned from a + route the site's UI fired during a receipt-verified write, so it seeds the captured set. + Returns {ok, receipt|error}; a miss bumps the staleness counter and MAX_MISSES drops it.""" + from backend.apps.agents.browser import route_write + body = build_body(recipe, payload) + if body is None: + drop_recipe(recipe.host) + return {"ok": False, "error": "recipe template unusable; dropped"} + captured = [route_write.CapturedRoute(method=recipe.method, template=recipe.url_template)] + import asyncio + out = await asyncio.to_thread( + route_write.replay_write, recipe.method, recipe.url, body, origin, captured) + if out.ok: + recipe.wins += 1 + save_recipe(recipe) + return {"ok": True, "receipt": out.receipt, "latency_ms": out.latency_ms} + recipe.misses += 1 + if recipe.misses >= MAX_MISSES: + drop_recipe(recipe.host) + logger.info(f"[write-recipe] {recipe.host} dropped after {recipe.misses} misses (stale; next DOM win re-learns)") + else: + save_recipe(recipe) + return {"ok": False, "error": out.error} diff --git a/backend/tests/test_browser_write_recipes.py b/backend/tests/test_browser_write_recipes.py new file mode 100644 index 00000000..f76e970e --- /dev/null +++ b/backend/tests/test_browser_write_recipes.py @@ -0,0 +1,97 @@ +"""Unit tests for the learn-on-first-write / replay-on-repeat recipe module. +Network mocked at route_write.issue_request; disk redirected to tmp_path.""" + +import json +from typing import Any, Dict + +import pytest + +from backend.apps.agents.browser import browser_write_recipes as wr +from backend.apps.agents.browser import route_write + + +@pytest.fixture(autouse=True) +def recipes_tmp(tmp_path, monkeypatch): + monkeypatch.setattr(wr, "p_dir", lambda: str(tmp_path)) + + +P_X_BODY = { + "variables": {"tweet_text": "hello from the test", "dark_request": False, + "media": {"media_entities": [], "possibly_sensitive": False}}, + "features": {"tweetypie_unmention_optimization_enabled": True, "longform_notetweets_consumption_enabled": True}, + "queryId": "AbCdEf123456", +} + + +def p_routes(body: Dict[str, Any]) -> list: + return [ + {"method": "GET", "template": "https://x.com/i/api/graphql/{id}/HomeTimeline", "example": "", "lastBody": ""}, + {"method": "POST", "template": "https://x.com/i/api/graphql/{id}/CreateTweet", + "example": "https://x.com/i/api/graphql/AbCdEf123456/CreateTweet", "lastBody": json.dumps(body)}, + ] + + +def test_learn_finds_payload_slot_and_saves(): + r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY)) + assert r is not None + assert r.payload_path == "$.variables.tweet_text" + assert wr.SENTINEL in r.body_template + assert "hello from the test" not in r.body_template + assert wr.recipe_for("x.com") is not None + + +def test_learn_refuses_substring_and_short_payloads(): + body = {"variables": {"tweet_text": "prefix hello from the test suffix"}} + assert wr.learn_recipe("x.com", "hello from the test", p_routes(body)) is None + assert wr.learn_recipe("x.com", "hi", p_routes(P_X_BODY)) is None + + +def test_learn_redacts_secret_leaves_but_keeps_structure(): + body = {"variables": {"tweet_text": "hello from the test"}, "csrfish": "a1B2c3D4e5F6g7H8i9J0kk"} + r = wr.learn_recipe("x.com", "hello from the test", p_routes(body)) + parsed = json.loads(r.body_template) + assert parsed["csrfish"] == "" + assert parsed["variables"]["tweet_text"] == wr.SENTINEL + + +def test_build_body_substitutes_new_payload_only(): + r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY)) + body = wr.build_body(r, "a brand new tweet") + assert body["variables"]["tweet_text"] == "a brand new tweet" + assert body["queryId"] == "AbCdEf123456" + r.body_template = json.dumps({"variables": {"tweet_text": "no sentinel here"}}) + assert wr.build_body(r, "x") is None + + +@pytest.mark.asyncio +async def test_replay_ok_bumps_wins_and_returns_receipt(monkeypatch): + monkeypatch.setenv("OSW_ROUTE_WRITE", "1") + r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY)) + monkeypatch.setattr(route_write, "get_session", lambda d: ("auth=1; ct0=abc", "UA")) + monkeypatch.setattr(route_write, "issue_request", + lambda m, u, b, h: (200, json.dumps({"data": {"rest_id": "999"}}))) + out = await wr.replay_recipe(r, "new text", "https://x.com") + assert out["ok"] is True and out["receipt"] == "999" + assert wr.recipe_for("x.com").wins == 1 + + +@pytest.mark.asyncio +async def test_replay_miss_bumps_and_drops_after_cap(monkeypatch): + monkeypatch.setenv("OSW_ROUTE_WRITE", "1") + wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY)) + monkeypatch.setattr(route_write, "get_session", lambda d: ("auth=1", "UA")) + monkeypatch.setattr(route_write, "issue_request", lambda m, u, b, h: (404, "gone")) + for i in range(wr.MAX_MISSES): + r = wr.recipe_for("x.com") + assert r is not None, f"recipe gone before miss {i + 1}" + out = await wr.replay_recipe(r, "t", "https://x.com") + assert out["ok"] is False + assert wr.recipe_for("x.com") is None # stale recipe self-evicted + + +@pytest.mark.asyncio +async def test_replay_respects_flag_off(monkeypatch): + monkeypatch.delenv("OSW_ROUTE_WRITE", raising=False) + r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY)) + out = await wr.replay_recipe(r, "t", "https://x.com") + assert out["ok"] is False and "disarmed" in out["error"] diff --git a/electron/cdp-routes.js b/electron/cdp-routes.js index ada451ab..96c77e81 100644 --- a/electron/cdp-routes.js +++ b/electron/cdp-routes.js @@ -95,9 +95,32 @@ function routeKey(method, template) { return String(method || 'GET').toUpperCase() + ' ' + template; } +// The mutating body WITH values, but secret-shaped string leaves redacted (belt; the backend +// recipe learner redacts again). Only JSON, capped: a write recipe needs the real body (the user's +// payload sits in one leaf), which bodyShape (types only) can't provide. Non-JSON / oversized = null. +const MAX_BODY_CHARS = 65536; + +function redactBodyValues(postData) { + if (!postData || postData.length > MAX_BODY_CHARS) return null; + try { + const walk = (v) => + typeof v === 'string' + ? (looksSecretValue(v) ? '' : v) + : Array.isArray(v) + ? v.map(walk) + : v && typeof v === 'object' + ? Object.fromEntries(Object.keys(v).map((k) => [k, walk(v[k])])) + : v; + return JSON.stringify(walk(JSON.parse(postData))); + } catch { + return null; + } +} + function makeRouteEntry(request, resourceType) { const method = String(request.method || 'GET').toUpperCase(); const template = templateUrl(request.url); + const safe = isSafeMethod(method); return { method, template, @@ -105,7 +128,9 @@ function makeRouteEntry(request, resourceType) { resourceType, headers: redactHeaders(request.headers), bodyShape: bodyShape(request.postData), - safe: isSafeMethod(method), + // Only mutating routes carry a replayable body; a GET's body (if any) is never a write recipe. + lastBody: safe ? null : redactBodyValues(request.postData), + safe, hits: 1, lastSeen: Date.now(), }; @@ -123,6 +148,9 @@ function recordRoute(routesMap, request, resourceType, now = Date.now()) { if (existing) { existing.hits += 1; existing.lastSeen = now; + // Refresh the body so a later replay learns from the FRESHEST call (queryId/token rotation + // lives in the URL/headers, but a stale body could hold an old nonce); keep the latest. + if (entry.lastBody) existing.lastBody = entry.lastBody; } else { routesMap.set(key, entry); if (routesMap.size > MAX_ROUTES_PER_WC) { diff --git a/electron/cdp-routes.test.js b/electron/cdp-routes.test.js index 2c3362e4..56bbe621 100644 --- a/electron/cdp-routes.test.js +++ b/electron/cdp-routes.test.js @@ -136,3 +136,30 @@ test('makeRouteEntry carries a redacted example url', () => { assert.ok(e.example.includes('redacted')); assert.ok(!e.example.includes('secretAbc123Long')); }); + +test('makeRouteEntry: mutating route keeps a body, GET does not, secrets redacted', () => { + const post = R.makeRouteEntry({ + method: 'POST', + url: 'https://x.com/i/api/graphql/AbC123/CreateTweet', + headers: { 'x-csrf-token': 'ct0secret' }, + postData: JSON.stringify({ variables: { tweet_text: 'hello world' }, authToken: 'aB3xK9mQ2pL7wR4tY8nZ' }), + }, 'Fetch'); + assert.equal(post.safe, false); + const body = JSON.parse(post.lastBody); + assert.equal(body.variables.tweet_text, 'hello world'); // payload survives + assert.equal(body.authToken, ''); // secret leaf redacted + assert.equal(post.headers['x-csrf-token'], ''); // header redacted + + const get = R.makeRouteEntry({ method: 'GET', url: 'https://x.com/i/api/graphql/AbC123/Home', headers: {}, postData: '' }, 'XHR'); + assert.equal(get.lastBody, null); // no body on a safe route +}); + +test('recordRoute: repeat write refreshes lastBody to the freshest call', () => { + const m = new Map(); + const mk = (text) => ({ method: 'POST', url: 'https://x.com/i/api/graphql/AbC/CreateTweet', headers: {}, postData: JSON.stringify({ variables: { tweet_text: text } }) }); + R.recordRoute(m, mk('first'), 'Fetch'); + R.recordRoute(m, mk('second'), 'Fetch'); + const entry = [...m.values()][0]; + assert.equal(entry.hits, 2); + assert.equal(JSON.parse(entry.lastBody).variables.tweet_text, 'second'); +});