mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
233 lines
8.7 KiB
Python
233 lines
8.7 KiB
Python
"""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}
|