evOo-local/infra/sandboxes/computer/rakazo-page-browser
cynarAI f07d17e927
Oo computer browser: live Chromium, cowork, AgentCursor (#179)
* feat(web): Oo computer browser panel with cowork and local preview

Wire the in-app browser panel to the active Oo's live Chromium via the
computer screen stream, page-browser navigation, and interactive cowork
input with AgentCursor. User browser actions become system meta lines in
thread context; localhost preview remains as Lokale Vorschau.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(i18n): sync Lingui catalogs for Oo browser panel

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix the Oo browser's lint and E2E failures

The shared-browser overlay is a focusable input surface, so it gets the
application role and a translated label. The local preview focuses the
address field again when no page is open, which the split out of
InAppBrowser had dropped. The Oo browser spec opens the panel by its exact
name, because the signed-up user's name also contains "Browser", and reads
the user action from the RPC body's json envelope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:10:25 +02:00

748 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""Drive the live Chromium on this computer over CDP (stdlib only).
Attaches to the Chrome already showing on DISPLAY. Used by the host
BrowserProvider. Not a vendor SDK. On failure prints JSON with
fallback computer_act.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
import threading
import urllib.request
from urllib.parse import urlsplit
from typing import Any
def display_num() -> int:
raw = os.environ.get("DISPLAY", ":1")
try:
return int(str(raw).lstrip(":").split(".")[0] or "1")
except ValueError:
return 1
def cdp_port() -> int:
override = os.environ.get("RAKAZO_CDP_PORT")
if override:
return int(override)
# rakazo-browser uses 9221 + display number (:1 -> 9222).
return 9221 + display_num()
def http_get_json(url: str, timeout: float = 5.0) -> Any:
with urllib.request.urlopen(url, timeout=timeout) as resp:
data = resp.read(1024 * 1024 + 1)
if len(data) > 1024 * 1024:
raise RuntimeError("CDP discovery response too large")
return json.loads(data.decode("utf-8"))
def wait_for_cdp(port: int, timeout_s: float = 3.0) -> None:
deadline = time.time() + timeout_s
last_error = "unreachable"
while time.time() < deadline:
try:
http_get_json(f"http://127.0.0.1:{port}/json/version", timeout=1.0)
return
except Exception as exc: # noqa: BLE001
last_error = str(exc)
time.sleep(0.25)
raise RuntimeError(f"CDP not ready on port {port}: {last_error}")
def discover_ws_url(port: int) -> str:
version = http_get_json(f"http://127.0.0.1:{port}/json/version")
ws = version.get("webSocketDebuggerUrl")
if not isinstance(ws, str) or not ws:
raise RuntimeError("CDP version endpoint missing webSocketDebuggerUrl")
parsed = urlsplit(ws)
if parsed.scheme != "ws" or parsed.hostname != "127.0.0.1" or parsed.port != port:
raise RuntimeError("Unexpected CDP endpoint")
return ws
WORLD_NAME = "rakazo-page-browser"
class CdpClient:
def __init__(self, ws_url: str) -> None:
self._ws_url = ws_url
self._sock: socket.socket | None = None
self._buf = bytearray()
self._next_id = 1
self.context_id: int | None = None
self._pending: dict[int, dict[str, Any] | None] = {}
# Worlds live in Chromium until navigation; rediscover them per CLI process.
self._contexts: dict[int, dict[str, Any]] = {}
def connect(self) -> None:
assert self._ws_url.startswith("ws://"), self._ws_url
rest = self._ws_url[len("ws://") :]
host_port, _, path = rest.partition("/")
if ":" in host_port:
host, port_s = host_port.split(":", 1)
port = int(port_s)
else:
host, port = host_port, 80
path = "/" + path
sock = socket.create_connection((host, port), timeout=10)
key = base64.b64encode(os.urandom(16)).decode("ascii")
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host_port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
)
sock.sendall(req.encode("ascii"))
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
raise RuntimeError("CDP websocket handshake closed")
data += chunk
if len(data) > 16_384:
raise RuntimeError("CDP handshake too large")
header, _, leftover = data.partition(b"\r\n\r\n")
if b"101" not in header.split(b"\r\n", 1)[0]:
raise RuntimeError(f"CDP websocket upgrade failed: {header[:200]!r}")
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()
)
if expected not in header:
raise RuntimeError("CDP websocket accept mismatch")
self._sock = sock
self._buf.extend(leftover)
def close(self) -> None:
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def _send_frame(self, payload: bytes) -> None:
assert self._sock is not None
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
header = bytearray([0x81])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", n))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", n))
header.extend(mask)
self._sock.sendall(header + masked)
def _send_control(self, opcode: int, payload: bytes) -> None:
assert self._sock is not None
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
header = bytearray([0x80 | opcode, 0x80 | len(payload)])
header.extend(mask)
self._sock.sendall(header + masked)
def _read_more(self) -> None:
assert self._sock is not None
chunk = self._sock.recv(65536)
if not chunk:
raise RuntimeError("CDP websocket closed")
self._buf.extend(chunk)
def _read_frame(self) -> bytes:
assert self._sock is not None
while True:
while len(self._buf) < 2:
chunk = self._sock.recv(65536)
if not chunk:
raise RuntimeError("CDP websocket closed")
self._buf.extend(chunk)
b1, b2 = self._buf[0], self._buf[1]
opcode = b1 & 0x0F
masked = bool(b2 & 0x80)
length = b2 & 0x7F
offset = 2
if length == 126:
while len(self._buf) < 4:
self._read_more()
length = struct.unpack("!H", self._buf[2:4])[0]
offset = 4
elif length == 127:
while len(self._buf) < 10:
self._read_more()
length = struct.unpack("!Q", self._buf[2:10])[0]
offset = 10
if length > 2 * 1024 * 1024:
raise RuntimeError("CDP frame too large")
if not (b1 & 0x80):
raise RuntimeError("Fragmented CDP message unsupported")
mask = b""
if masked:
while len(self._buf) < offset + 4:
self._read_more()
mask = bytes(self._buf[offset : offset + 4])
offset += 4
while len(self._buf) < offset + length:
chunk = self._sock.recv(65536)
if not chunk:
raise RuntimeError("CDP websocket closed while reading frame")
self._buf.extend(chunk)
payload = bytes(self._buf[offset : offset + length])
del self._buf[: offset + length]
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
raise RuntimeError("CDP websocket closed by peer")
if opcode == 0x9:
self._send_control(0xA, payload)
continue
if opcode in (0x1, 0x2, 0x0):
return payload
def _handle_event(self, message: dict[str, Any]) -> None:
method = message.get("method")
params = message.get("params") or {}
session_id = message.get("sessionId")
if method == "Runtime.executionContextCreated":
context = params.get("context")
if not isinstance(context, dict):
return
context_id = context.get("id")
if not isinstance(context_id, int):
return
recorded = dict(context)
recorded["_sessionId"] = session_id
self._contexts[context_id] = recorded
elif method == "Runtime.executionContextDestroyed":
context_id = params.get("executionContextId")
if isinstance(context_id, int):
self._contexts.pop(context_id, None)
elif method == "Runtime.executionContextsCleared":
if session_id is None:
self._contexts.clear()
else:
self._contexts = {
cid: ctx
for cid, ctx in self._contexts.items()
if ctx.get("_sessionId") != session_id
}
def find_world_context(self, world_name: str, frame_id: str, session_id: str) -> int | None:
for context_id, context in self._contexts.items():
if context.get("name") != world_name:
continue
if context.get("_sessionId") != session_id:
continue
aux = context.get("auxData") or {}
if not isinstance(aux, dict) or aux.get("frameId") != frame_id or aux.get("isDefault") is not False:
continue
return context_id
return None
def call(
self,
method: str,
params: dict[str, Any] | None = None,
*,
session_id: str | None = None,
timeout_s: float = 30.0,
) -> dict[str, Any]:
assert self._sock is not None
msg_id = self._next_id
self._next_id += 1
payload: dict[str, Any] = {"id": msg_id, "method": method, "params": params or {}}
if session_id:
payload["sessionId"] = session_id
self._pending[msg_id] = None
self._send_frame(json.dumps(payload).encode("utf-8"))
deadline = time.time() + timeout_s
while time.time() < deadline:
pending = self._pending.get(msg_id)
if pending is not None:
self._pending.pop(msg_id, None)
if "error" in pending:
raise RuntimeError(f"{method} failed: {pending['error']}")
result = pending.get("result")
return result if isinstance(result, dict) else {}
self._sock.settimeout(max(0.1, deadline - time.time()))
try:
frame = self._read_frame()
except socket.timeout:
continue
message = json.loads(frame.decode("utf-8"))
if not isinstance(message, dict):
continue
if message.get("id") in self._pending:
self._pending[int(message["id"])] = message
continue
if message.get("method"):
self._handle_event(message)
raise TimeoutError(f"CDP call timed out: {method}")
def attach_page(client: CdpClient) -> str:
targets = client.call("Target.getTargets").get("targetInfos") or []
pages = [t for t in targets if isinstance(t, dict) and t.get("type") == "page"]
candidates = []
for page in pages[:32]:
attached = client.call("Target.attachToTarget", {
"targetId": page["targetId"], "flatten": True,
})
session_id = attached["sessionId"]
try:
client.call("Page.enable", session_id=session_id)
ensure_world(client, session_id)
if eval_json(client, session_id, 'document.visibilityState === "visible"'):
focused = eval_json(client, session_id, "document.hasFocus()")
candidates.append((session_id, focused))
except Exception:
continue
focused = [session for session, focus in candidates if focus]
if len(focused) == 1:
selected = focused[0]
elif len(candidates) == 1:
selected = candidates[0][0]
else:
raise RuntimeError("No unambiguous visible browser tab. Use desktop tools to select a tab.")
ensure_world(client, selected)
return selected
def ensure_world(client: CdpClient, session_id: str) -> None:
# Rediscover only an isolated context bound to this frame and CDP session.
# Never run helpers in a page-owned default context with the same name.
frame = client.call("Page.getFrameTree", session_id=session_id)["frameTree"]["frame"]
frame_id = frame["id"]
client.call("Runtime.enable", session_id=session_id)
existing = client.find_world_context(WORLD_NAME, frame_id, session_id)
if existing is not None:
client.context_id = existing
return
world = client.call("Page.createIsolatedWorld", {
"frameId": frame_id, "worldName": WORLD_NAME,
}, session_id=session_id)
client.context_id = world["executionContextId"]
EVAL_HELPERS = r"""
(() => {
if (window.__rakazoPageBrowser) return true;
const refMap = new Map();
// New snapshots/documents never recycle references. Retain exact nodes, not selectors.
function newRef() {
return `e${Array.from(crypto.getRandomValues(new Uint32Array(4)), n => n.toString(16).padStart(8, "0")).join("")}`;
}
function visible(el) {
if (!(el instanceof Element)) return false;
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function roleOf(el) {
const explicit = el.getAttribute("role");
if (explicit) return explicit;
const tag = el.tagName.toLowerCase();
if (tag === "a") return "link";
if (tag === "button") return "button";
if (tag === "input") {
const type = (el.getAttribute("type") || "text").toLowerCase();
if (type === "submit" || type === "button") return "button";
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
return "textbox";
}
if (tag === "textarea" || el.isContentEditable) return "textbox";
if (tag === "select") return "combobox";
if (/^h[1-6]$/.test(tag)) return "heading";
return tag;
}
function nameOf(el) {
return (
el.getAttribute("aria-label") ||
Array.from(el.labels || []).map(label => label.textContent).join(" ").trim() ||
el.getAttribute("placeholder") ||
el.getAttribute("name") ||
el.getAttribute("title") ||
(el.innerText || el.textContent || "").trim().slice(0, 80) ||
""
);
}
function isInteractive(el) {
if (!(el instanceof Element)) return false;
const tag = el.tagName.toLowerCase();
if (["a", "button", "input", "textarea", "select", "summary"].includes(tag)) return true;
if (el.isContentEditable) return true;
const role = el.getAttribute("role");
if (
role &&
["button", "link", "textbox", "checkbox", "radio", "menuitem", "tab", "option", "switch"].includes(
role
)
) {
return true;
}
return typeof el.onclick === "function" || el.hasAttribute("tabindex");
}
function formatTree(elements) {
if (!elements.length) return "(no interactive elements)";
return elements
.map((el) => {
const value =
el.value !== undefined && el.value !== "" ? ` value=${JSON.stringify(el.value)}` : "";
return `- ${el.role} "${el.name}" [${el.ref}]${value}`;
})
.join("\n");
}
// Screen pixels of the element center's box, only while that center is in the viewport.
// Drives the visible bot cursor; the model never sees it.
function screenBox(el, viewport) {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return undefined;
const box = [
viewport.x + rect.left * viewport.scale,
viewport.y + rect.top * viewport.scale,
rect.width * viewport.scale,
rect.height * viewport.scale,
].map(Math.round);
return box[0] >= 0 && box[1] >= 0 && box[2] > 0 && box[3] > 0 ? box : undefined;
}
function viewportOnScreen() {
const scale = window.devicePixelRatio || 1;
// Browser chrome sits above the page; side borders are split evenly.
return {
scale,
x: (window.screenX + Math.max(0, (window.outerWidth - window.innerWidth) / 2)) * scale,
y: (window.screenY + Math.max(0, window.outerHeight - window.innerHeight)) * scale,
screen: {
width: Math.round(window.screen.width * scale),
height: Math.round(window.screen.height * scale),
},
};
}
window.__rakazoPageBrowser = {
snapshot() {
refMap.clear();
const elements = [];
const viewport = viewportOnScreen();
for (const el of document.querySelectorAll("body *")) {
if (!isInteractive(el) || !visible(el)) continue;
const ref = newRef();
refMap.set(ref, el);
elements.push({
ref,
role: roleOf(el),
name: String(nameOf(el)).slice(0, 200),
value: el.type === "password" ? undefined
: ["checkbox", "radio"].includes(el.type) ? (el.checked ? "checked" : "unchecked")
: "value" in el ? String(el.value ?? "").slice(0, 2000) : undefined,
tag: el.tagName.toLowerCase(),
box: screenBox(el, viewport),
});
if (elements.length >= 80) break;
}
return {
url: location.href,
title: (document.title || "").slice(0, 200),
screen: viewport.screen,
elements,
tree: (document.body?.innerText || "").slice(0, 12000) + "\n" + formatTree(elements)
+ (document.querySelector("iframe,frame") ? "\nEmbedded frames require desktop tools." : ""),
};
},
resolve(ref) {
const el = refMap.get(String(ref));
if (!el || !el.isConnected || !visible(el)) return null;
if (el.matches(":disabled") || el.getAttribute("aria-disabled") === "true") {
throw new Error("Element is disabled");
}
return el;
},
click(ref) {
const el = this.resolve(ref);
if (!el) throw new Error(`Unknown element ref: ${ref}`);
el.focus();
el.click();
return { url: location.href, title: (document.title || "").slice(0, 200) };
},
fill(ref, value) {
const el = this.resolve(ref);
if (!el) throw new Error(`Unknown element ref: ${ref}`);
if (el.readOnly) throw new Error("Element is read-only");
el.focus();
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
if (el instanceof HTMLInputElement && !["text", "search", "email", "url", "tel", "password", "number"].includes(el.type)) {
throw new Error("Input type requires desktop tools");
}
const prototype = el instanceof HTMLInputElement ? HTMLInputElement.prototype
: el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLSelectElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value").set.call(el, String(value));
if (el.value !== String(value)) throw new Error("Input did not accept the requested value");
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
} else if (el.isContentEditable) {
el.textContent = String(value);
el.dispatchEvent(new Event("input", { bubbles: true }));
} else {
throw new Error(`Element is not fillable: ${ref}`);
}
return { url: location.href, title: (document.title || "").slice(0, 200) };
},
type(ref, text) {
const el = this.resolve(ref);
if (!el) throw new Error(`Unknown element ref: ${ref}`);
return this.fill(ref, `${"value" in el ? el.value : el.textContent || ""}${text}`);
},
};
return true;
})()
"""
def eval_json(client: CdpClient, session_id: str, expression: str) -> Any:
result = client.call(
"Runtime.evaluate",
{
"expression": expression,
"contextId": client.context_id,
"returnByValue": True,
"awaitPromise": True,
},
session_id=session_id,
)
if result.get("exceptionDetails"):
details = result["exceptionDetails"]
text = (
details.get("text")
or (details.get("exception") or {}).get("description")
or "evaluate failed"
)
raise RuntimeError(str(text))
return (result.get("result") or {}).get("value")
def ensure_helpers(client: CdpClient, session_id: str) -> None:
ensure_world(client, session_id)
eval_json(client, session_id, EVAL_HELPERS)
def navigate(client: CdpClient, session_id: str, url: str) -> dict[str, Any]:
parsed = urlsplit(url)
if parsed.scheme not in ("http", "https") or parsed.username or parsed.password:
raise RuntimeError("Only HTTP(S) URLs without embedded credentials are supported")
result = client.call("Page.navigate", {"url": url}, session_id=session_id, timeout_s=15.0)
if result.get("errorText") or result.get("isDownload"):
raise RuntimeError(result.get("errorText") or "Navigation started a download")
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
frame = client.call("Page.getFrameTree", session_id=session_id)["frameTree"]["frame"]
if result.get("loaderId") and frame.get("loaderId") != result["loaderId"]:
time.sleep(0.1)
continue
ensure_world(client, session_id)
ready = eval_json(client, session_id, "document.readyState")
if ready in ("interactive", "complete"):
return {"ok": True, "url": eval_json(client, session_id, "location.href"),
"title": eval_json(client, session_id, "(document.title || '').slice(0, 200)")}
except Exception:
pass
time.sleep(0.1)
raise RuntimeError("Navigation did not settle; inspect the screen before continuing")
def snapshot(client: CdpClient, session_id: str) -> dict[str, Any]:
ensure_helpers(client, session_id)
data = eval_json(client, session_id, "window.__rakazoPageBrowser.snapshot()")
if not isinstance(data, dict):
raise RuntimeError("snapshot returned non-object")
return {"ok": True, **data}
def browser_state(client: CdpClient, session_id: str) -> dict[str, Any]:
ensure_helpers(client, session_id)
history = client.call("Page.getNavigationHistory", session_id=session_id)
entries = history.get("entries") if isinstance(history, dict) else None
current = history.get("currentIndex") if isinstance(history, dict) else None
can_back = isinstance(current, int) and current > 0
can_forward = (
isinstance(current, int)
and isinstance(entries, list)
and current >= 0
and current < len(entries) - 1
)
url = eval_json(client, session_id, "location.href")
title = eval_json(client, session_id, "(document.title || '').slice(0, 200)")
ready = eval_json(client, session_id, "document.readyState")
return {
"ok": True,
"url": url if isinstance(url, str) else "",
"title": title if isinstance(title, str) else "",
"loading": ready not in ("interactive", "complete"),
"canGoBack": can_back,
"canGoForward": can_forward,
}
def history_step(client: CdpClient, session_id: str, delta: int) -> dict[str, Any]:
ensure_helpers(client, session_id)
history = client.call("Page.getNavigationHistory", session_id=session_id)
current = history.get("currentIndex") if isinstance(history, dict) else None
entries = history.get("entries") if isinstance(history, dict) else None
if not isinstance(current, int) or not isinstance(entries, list):
raise RuntimeError("Navigation history unavailable")
target = current + delta
if target < 0 or target >= len(entries):
raise RuntimeError("Cannot navigate in that direction")
entry_id = entries[target].get("id")
if not isinstance(entry_id, int):
raise RuntimeError("Navigation entry missing")
client.call("Page.navigateToHistoryEntry", {"entryId": entry_id}, session_id=session_id)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
ready = eval_json(client, session_id, "document.readyState")
if ready in ("interactive", "complete"):
return browser_state(client, session_id)
time.sleep(0.1)
raise RuntimeError("History navigation did not settle")
def reload_page(client: CdpClient, session_id: str) -> dict[str, Any]:
client.call("Page.reload", session_id=session_id)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
ready = eval_json(client, session_id, "document.readyState")
if ready in ("interactive", "complete"):
return browser_state(client, session_id)
time.sleep(0.1)
raise RuntimeError("Reload did not settle")
def act(client: CdpClient, session_id: str, actions: list[dict[str, Any]]) -> dict[str, Any]:
if not actions or len(actions) > 24:
raise RuntimeError("Expected 1 to 24 actions")
for step in actions:
if not isinstance(step, dict) or step.get("kind") not in ("click", "fill", "type"):
raise RuntimeError("Invalid browser action")
if not isinstance(step.get("ref"), str) or not step["ref"]:
raise RuntimeError("Element ref is required")
if step["kind"] != "click" and not isinstance(step.get("text"), str):
raise RuntimeError("Text is required")
ensure_helpers(client, session_id)
completed = 0
uncertain = False
try:
for step in actions:
kind, ref = step["kind"], step["ref"]
# Preflight catches stale refs before a mutation is dispatched.
if not eval_json(client, session_id, f"Boolean(window.__rakazoPageBrowser.resolve({json.dumps(ref)}))"):
raise RuntimeError("Stale element ref. Take a fresh snapshot.")
arguments = json.dumps(ref)
if kind != "click":
arguments += ", " + json.dumps(step["text"])
uncertain = True
data = eval_json(client, session_id, f"window.__rakazoPageBrowser.{kind}({arguments})")
if not isinstance(data, dict):
raise RuntimeError("Action response unavailable")
completed += 1
uncertain = False
snap = snapshot(client, session_id)
return {**snap, "completed": completed}
except Exception as exc:
return {"ok": False, "completed": completed, "uncertain": uncertain,
"error": str(exc), "fallback": "computer_act"}
def exit_when_stdin_closes() -> None:
"""The supervisor owns this pipe; losing it cancels this helper, not Chromium."""
try:
while os.read(0, 1024):
pass
finally:
os._exit(130)
def run(argv: list[str]) -> int:
if os.environ.get("RAKAZO_BROWSER_WATCH_STDIN") == "1":
threading.Thread(target=exit_when_stdin_closes, daemon=True).start()
if len(argv) < 2:
print(
json.dumps(
{
"ok": False,
"error": "usage: rakazo-page-browser <command> [json-args]",
"fallback": "computer_act",
}
)
)
return 2
command = argv[1]
args: dict[str, Any] = {}
if len(argv) >= 3 and argv[2].strip():
args = json.loads(argv[2])
port = cdp_port()
try:
wait_for_cdp(port)
ws_url = discover_ws_url(port)
client = CdpClient(ws_url)
client.connect()
try:
session_id = attach_page(client)
if command == "navigate":
result = navigate(client, session_id, str(args.get("url") or ""))
elif command == "state":
result = browser_state(client, session_id)
elif command == "back":
result = history_step(client, session_id, -1)
elif command == "forward":
result = history_step(client, session_id, 1)
elif command == "reload":
result = reload_page(client, session_id)
elif command == "snapshot":
result = snapshot(client, session_id)
elif command == "act":
actions = args.get("actions")
if not isinstance(actions, list):
raise RuntimeError("actions must be an array")
result = act(client, session_id, actions)
else:
result = {
"ok": False,
"error": f"unknown command: {command}",
"fallback": "computer_act",
}
print(json.dumps(result))
return 2
print(json.dumps(result))
return 0 # Structured failures preserve partial action progress.
finally:
client.close()
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc), "fallback": "computer_act",
"uncertain": command == "act"}))
return 0
if __name__ == "__main__":
sys.exit(run(sys.argv))