routes/browse.py
Rama main · réplica de lectura del repositorio original.
"""
routes/browse.py — OpenVoiceUI ↔ co-browsing service bridge (issue #154, Phase 2).
Thin proxy from OVU to the shared `jambot-browse` container (Phase 1). Three roles:
1. HTTP proxy for the agent + frontend: /api/browse/start|action|stop|screenshot|dom|status
— injects THIS tenant (the browser client never picks its own tenant) and the
shared-secret key, and forwards to http://jambot-browse:8712.
2. Serves the viewer canvas page at /browse-viewer (default-pages/browse-viewer.html).
3. Keeps the per-process "latest browse state" (url/title/dom digest) so the
conversation route can inject a compact [BROWSE_STATE: …] into the agent's next
turn — text-sight so the agent can navigate without a full screenshot round-trip.
The live video path (CDP screencast) is a WebSocket, which flask-sock owns in
server.py — see `/ws/browse-stream` there. This module is HTTP + page serving only.
Auth: these routes ride the app's normal `before_request` gate (Clerk session or
the internal agent key for non-admin calls), same as /api/conversation. The
upstream service key (BROWSE_SERVICE_KEY) is added here, server-side only — it is
never exposed to the browser.
"""
import logging
import os
import requests
from flask import Blueprint, Response, jsonify, request
from services.paths import DEFAULT_PAGES_DIR, UPLOADS_DIR
logger = logging.getLogger(__name__)
browse_bp = Blueprint("browse", __name__)
BROWSE_SERVICE_URL = os.getenv("BROWSE_SERVICE_URL", "http://jambot-browse:8712").rstrip("/")
BROWSE_SERVICE_KEY = os.getenv("BROWSE_SERVICE_KEY", "").strip()
_TIMEOUT = float(os.getenv("BROWSE_PROXY_TIMEOUT_S", "45"))
def _tenant() -> str:
"""This OVU instance's tenant — the co-browsing session is keyed on it."""
return (os.getenv("JAMBOT_TENANT") or os.getenv("TENANT_NAME") or "default").strip()
def _svc_headers() -> dict:
h = {"Content-Type": "application/json"}
if BROWSE_SERVICE_KEY:
h["X-Browse-Key"] = BROWSE_SERVICE_KEY
return h
# ── Browse-active marker + state (worker-safe) ───────────────────────────────
# OVU runs multiple gunicorn workers, so in-process state is NOT shared across
# them (a browser can hit worker A on /api/browse/start and worker B on the next
# conversation turn). So: a filesystem marker (shared by all workers on the same
# volume) records "a session is active", and the browse SERVICE is the single
# shared source of truth for the live page — get_browse_state() queries it
# directly each turn. That also means USER-driven navigation (which goes to the
# service over the viewer WS, bypassing this proxy) is always reflected: we read
# the service, not a local cache.
_MARKER = UPLOADS_DIR / ".browse-active"
def _set_active(active: bool):
try:
if active:
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
_MARKER.write_text(_tenant(), encoding="utf-8")
elif _MARKER.exists():
_MARKER.unlink()
except Exception as e:
logger.debug("browse marker update failed: %s", e)
def get_browse_state() -> dict | None:
"""Return the live page digest if a browse session is active, else None.
Worker-safe: gated on the filesystem marker (cheap stat, shared across
workers), then reads the browse SERVICE directly so the value always
reflects reality — including USER-driven navigation. Read by the
conversation route to inject [BROWSE_STATE: …]. One DOM call per agent turn,
only while a session is active. Self-heals: if the service reports no
session, the marker is cleared.
"""
if not _MARKER.exists():
return None
try:
r = requests.get(
f"{BROWSE_SERVICE_URL}/session/dom",
params={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT,
)
if r.status_code == 404:
_set_active(False) # service says no session — clear stale marker
return None
if r.status_code != 200:
return None
dom = r.json()
return {
"url": dom.get("url", ""),
"title": dom.get("title", ""),
"text": (dom.get("text", "") or "")[:1200],
"links": [l.get("text", "") for l in dom.get("links", [])][:12],
"buttons": [b.get("text", "") for b in dom.get("buttons", [])][:12],
"tab_count": dom.get("tab_count", 1),
"active_tab": dom.get("active_tab", 0),
"last_download": dom.get("last_download"),
}
except Exception as e:
logger.debug("browse state read failed: %s", e)
return None
# ── HTTP proxy endpoints ─────────────────────────────────────────────────────
@browse_bp.route("/api/browse/start", methods=["POST"])
def browse_start():
body = request.get_json(silent=True) or {}
payload = {
"tenant": _tenant(),
"url": body.get("url", "about:blank"),
}
for k in ("width", "height", "proxy"):
if body.get(k) is not None:
payload[k] = body[k]
try:
r = requests.post(f"{BROWSE_SERVICE_URL}/session/start",
json=payload, headers=_svc_headers(), timeout=_TIMEOUT)
if r.status_code == 200:
_set_active(True)
else:
logger.warning("browse start upstream %s: %s", r.status_code, r.text[:200])
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
logger.error("browse start failed: %s", e)
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/action", methods=["POST"])
def browse_action():
body = request.get_json(silent=True) or {}
payload = dict(body)
payload["tenant"] = _tenant()
try:
r = requests.post(f"{BROWSE_SERVICE_URL}/session/action",
json=payload, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
logger.error("browse action failed: %s", e)
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/screenshot", methods=["GET"])
def browse_screenshot():
try:
r = requests.get(
f"{BROWSE_SERVICE_URL}/session/screenshot",
params={"tenant": _tenant(), "full": request.args.get("full", "")},
headers=_svc_headers(), timeout=_TIMEOUT,
)
return Response(r.content, status=r.status_code,
content_type=r.headers.get("Content-Type", "image/png"))
except requests.RequestException as e:
logger.error("browse screenshot failed: %s", e)
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/dom", methods=["GET"])
def browse_dom():
try:
r = requests.get(f"{BROWSE_SERVICE_URL}/session/dom",
params={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/status", methods=["GET"])
def browse_status():
try:
r = requests.get(f"{BROWSE_SERVICE_URL}/session/status",
params={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/ip", methods=["GET"])
def browse_ip():
"""Egress IP of the live session — proof of residential masking (#154 P4)."""
try:
r = requests.get(f"{BROWSE_SERVICE_URL}/session/ip",
params={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/tabs", methods=["GET"])
def browse_tabs():
"""List the session's tabs for the viewer tab strip (#154 P5)."""
try:
r = requests.get(f"{BROWSE_SERVICE_URL}/session/tabs",
params={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/download", methods=["POST"])
def browse_download():
"""Pull a captured download from the browse service into THIS tenant's
uploads (#154 P5). OVU is the per-tenant trust boundary — the browse
container never touches /mnt/clients. Returns the saved /uploads/ URL."""
from services.paths import UPLOADS_DIR
name = (request.get_json(silent=True) or {}).get("name")
try:
params = {"tenant": _tenant()}
if name:
params["name"] = name
r = requests.get(f"{BROWSE_SERVICE_URL}/session/download",
params=params, headers=_svc_headers(), timeout=_TIMEOUT, stream=True)
if r.status_code != 200:
return Response(r.content, status=r.status_code, content_type="application/json")
# Filename from Content-Disposition, sanitized to a single segment.
cd = r.headers.get("Content-Disposition", "")
fname = "download.bin"
if "filename=" in cd:
fname = cd.split("filename=", 1)[1].strip().strip('"') or fname
fname = os.path.basename(fname)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
dest = UPLOADS_DIR / fname
with open(dest, "wb") as fh:
for chunk in r.iter_content(65536):
fh.write(chunk)
try:
os.chmod(dest, 0o664)
except OSError:
pass
return jsonify({"saved": fname, "url": f"/uploads/{fname}"})
except requests.RequestException as e:
logger.error("browse download failed: %s", e)
return jsonify({"error": "browse service unavailable"}), 503
@browse_bp.route("/api/browse/stop", methods=["POST"])
def browse_stop():
_set_active(False)
try:
r = requests.post(f"{BROWSE_SERVICE_URL}/session/stop",
json={"tenant": _tenant()}, headers=_svc_headers(), timeout=_TIMEOUT)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.RequestException as e:
return jsonify({"error": "browse service unavailable"}), 503
# ── Viewer page ──────────────────────────────────────────────────────────────
@browse_bp.route("/browse-viewer")
def browse_viewer():
"""Serve the live co-browsing viewer (loaded inside the canvas iframe)."""
path = DEFAULT_PAGES_DIR / "browse-viewer.html"
try:
return Response(path.read_text(encoding="utf-8"), content_type="text/html")
except Exception as e:
logger.error("browse-viewer serve failed: %s", e)
return "<h1>Browse viewer unavailable</h1>", 500