[eric] browser: repeat-write recipe tier (learn on verified DOM write, replay on repeat; default-off)

This commit is contained in:
ciregenz
2026-07-17 11:50:07 -07:00
parent 102a35c39b
commit 19734e1eac
5 changed files with 410 additions and 1 deletions
@@ -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)
@@ -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 "<redacted>" 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}
@@ -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"] == "<redacted>"
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"]
+29 -1
View File
@@ -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) ? '<redacted>' : 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) {
+27
View File
@@ -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, '<redacted>'); // secret leaf redacted
assert.equal(post.headers['x-csrf-token'], '<redacted>'); // 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');
});