Skip to content

Commit 2722581

Browse files
Add one-step OpenBrowser control handoff
1 parent cad3830 commit 2722581

10 files changed

Lines changed: 394 additions & 10 deletions

File tree

ax_browser_broker/api.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ class OpenBrowserOpenRequest(BaseModel):
179179
identity_id: str | None = None
180180
ttl_seconds: int = Field(default=300, ge=60, le=14400)
181181
wait_until: str = "domcontentloaded"
182+
verify: bool = True
183+
screenshot: bool = False
184+
control: bool = False
185+
control_owner: str | None = None
186+
control_ttl_seconds: int = Field(default=900, ge=60, le=3600)
182187

183188

184189
class OpenBrowserAuthBatchRequest(BaseModel):
@@ -1930,7 +1935,37 @@ async def openbrowser_open(request: OpenBrowserOpenRequest, _auth: str = Depends
19301935
if isinstance(error, HTTPException):
19311936
raise
19321937
raise _http_error(error) from error
1933-
return {"lease": lease_obj, "navigation": navigation}
1938+
result: dict[str, Any] = {"lease": lease_obj, "navigation": navigation}
1939+
lease_id = str(lease_obj["lease_id"])
1940+
if request.verify:
1941+
try:
1942+
snapshot = await browser_snapshot(LeaseIdRequest(lease_id=lease_id))
1943+
result["snapshot"] = {
1944+
"title": snapshot.get("title"),
1945+
"url": snapshot.get("url"),
1946+
"bodyText": str(snapshot.get("bodyText") or "")[:1200],
1947+
"element_count": len(snapshot.get("elements") or []),
1948+
"slot": snapshot.get("slot"),
1949+
}
1950+
except Exception as error:
1951+
result["verification_error"] = str(error)
1952+
if request.screenshot:
1953+
try:
1954+
shot = await browser_screenshot(ScreenshotRequest(lease_id=lease_id, full_page=False))
1955+
result["screenshot"] = {key: value for key, value in shot.items() if key != "base64"}
1956+
except Exception as error:
1957+
result["screenshot_error"] = str(error)
1958+
if request.control:
1959+
control = await lease_control_request(
1960+
LeaseControlRequest(
1961+
lease_id=lease_id,
1962+
owner=request.control_owner or request.owner,
1963+
ttl_seconds=request.control_ttl_seconds,
1964+
)
1965+
)
1966+
result["control"] = control
1967+
result["portal_url"] = control.get("portal_url")
1968+
return result
19341969

19351970

19361971
@app.post("/openbrowser/v1/browser/navigate")

ax_browser_broker/cli.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ def _print(data: dict[str, Any]) -> int:
6565
return 0
6666

6767

68+
def _compact_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]:
69+
return {
70+
"title": snapshot.get("title"),
71+
"url": snapshot.get("url"),
72+
"bodyText": str(snapshot.get("bodyText") or "")[:1200],
73+
"element_count": len(snapshot.get("elements") or []),
74+
"slot": snapshot.get("slot"),
75+
}
76+
77+
78+
def _compact_screenshot(screenshot: dict[str, Any]) -> dict[str, Any]:
79+
return {key: value for key, value in screenshot.items() if key != "base64"}
80+
81+
6882
def cmd_status(_args: argparse.Namespace) -> int:
6983
return _print(_request("GET", "/status"))
7084

@@ -75,19 +89,43 @@ def cmd_docs(args: argparse.Namespace) -> int:
7589

7690

7791
def cmd_open(args: argparse.Namespace) -> int:
78-
return _print(
79-
_request(
92+
result = _request(
93+
"POST",
94+
"/openbrowser/v1/open",
95+
{
96+
"owner": args.owner,
97+
"identity_id": args.identity,
98+
"url": args.url,
99+
"ttl_seconds": args.ttl,
100+
},
101+
auth=True,
102+
)
103+
lease_id = str((result.get("lease") or {}).get("lease_id") or "")
104+
if lease_id and not args.no_verify:
105+
snapshot = _request("POST", "/openbrowser/v1/browser/snapshot", {"lease_id": lease_id}, auth=True)
106+
result["snapshot"] = _compact_snapshot(snapshot)
107+
if lease_id and args.screenshot:
108+
screenshot = _request(
109+
"POST",
110+
"/openbrowser/v1/browser/screenshot",
111+
{"lease_id": lease_id, "full_page": False},
112+
auth=True,
113+
)
114+
result["screenshot"] = _compact_screenshot(screenshot)
115+
if lease_id and args.control:
116+
control = _request(
80117
"POST",
81-
"/openbrowser/v1/open",
118+
"/openbrowser/v1/lease-control/request",
82119
{
83-
"owner": args.owner,
84-
"identity_id": args.identity,
85-
"url": args.url,
86-
"ttl_seconds": args.ttl,
120+
"owner": args.control_owner or args.owner,
121+
"lease_id": lease_id,
122+
"ttl_seconds": args.control_ttl,
87123
},
88124
auth=True,
89125
)
90-
)
126+
result["control"] = control
127+
result["portal_url"] = control.get("portal_url")
128+
return _print(result)
91129

92130

93131
def cmd_auth(args: argparse.Namespace) -> int:
@@ -141,6 +179,11 @@ def build_parser() -> argparse.ArgumentParser:
141179
open_cmd.add_argument("--identity", default=None)
142180
open_cmd.add_argument("--owner", default="openbrowser-cli")
143181
open_cmd.add_argument("--ttl", type=int, default=900)
182+
open_cmd.add_argument("--control", action="store_true", help="Return a temporary human-control portal URL")
183+
open_cmd.add_argument("--control-owner", default=None)
184+
open_cmd.add_argument("--control-ttl", type=int, default=900)
185+
open_cmd.add_argument("--screenshot", action="store_true", help="Capture a compact screenshot receipt without base64 output")
186+
open_cmd.add_argument("--no-verify", action="store_true", help="Skip the post-navigation snapshot receipt")
144187
open_cmd.set_defaults(func=cmd_open)
145188

146189
auth = sub.add_parser("auth", help="Create an auth handoff or active lease-control response")

ax_browser_broker/docs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"quickstart": {
88
"title": "OpenBrowser Broker Quickstart",
99
"steps": [
10+
"For simple user handoff requests such as 'open Lovable for me', call browser_open_control with owner, url, and optional identity_id. It opens the page, verifies state, and returns the control URL.",
1011
"Call browser_lease with owner and optional identity_id.",
1112
"Immediately call browser_snapshot or browser_screenshot to see the current page state before doing anything else.",
1213
"Do NOT call browser_navigate if the current page is already meaningful (e.g. after a human auth handoff the browser is on the target page). Only navigate when the current page is blank, a new tab, or unrelated to the task.",
@@ -16,6 +17,7 @@
1617
"Call broker_audit after browser-agent work.",
1718
],
1819
"examples": [
20+
{"tool": "browser_open_control", "args": {"owner": "agent-name", "identity_id": "work-main", "url": "https://example.com"}},
1921
{"tool": "browser_lease", "args": {"owner": "agent-name", "identity_id": "work-main"}},
2022
{"tool": "browser_snapshot", "args": {"lease_id": "<lease_id>"}},
2123
{"tool": "browser_navigate", "args": {"lease_id": "<lease_id>", "url": "https://example.com"}},
@@ -104,10 +106,12 @@
104106
"commands": [
105107
"openbrowser status",
106108
"openbrowser docs quickstart",
109+
"openbrowser open https://example.com --identity work-main --control",
107110
"openbrowser auth https://example.com/login --identity work-main --owner agent-name",
108111
],
109112
"notes": [
110113
"The CLI talks to the local broker API and reads the local server-side API key file when needed.",
114+
"Use open --control for simple 'open this for me' requests; it returns a verified control URL in one command.",
111115
"Use it for status, docs, auth handoffs, active lease-control links, and quick smoke checks.",
112116
"Use Broker MCP directly for normal click/type/screenshot workflows when tools are available.",
113117
],

ax_browser_broker/mcp_server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,39 @@ def browser_navigate(lease_id: str, url: str) -> dict[str, Any]:
7373
return _request("POST", "/browser/navigate", {"lease_id": lease_id, "url": url})
7474

7575

76+
@mcp.tool()
77+
def browser_open_control(
78+
owner: str,
79+
url: str,
80+
identity_id: str | None = None,
81+
ttl_seconds: int = 900,
82+
control_ttl_seconds: int = 900,
83+
) -> dict[str, Any]:
84+
"""Open a URL, verify the page, and return a human-control link in one call."""
85+
lease_obj = browser_lease(owner=owner, ttl_seconds=ttl_seconds, identity_id=identity_id)
86+
lease_id = str(lease_obj["lease_id"])
87+
try:
88+
navigation = browser_navigate(lease_id=lease_id, url=url)
89+
except Exception:
90+
browser_release(lease_id)
91+
raise
92+
snapshot = browser_snapshot(lease_id)
93+
control = lease_control_request(lease_id=lease_id, owner=owner, ttl_seconds=control_ttl_seconds)
94+
return {
95+
"lease": lease_obj,
96+
"navigation": navigation,
97+
"snapshot": {
98+
"title": snapshot.get("title"),
99+
"url": snapshot.get("url"),
100+
"bodyText": str(snapshot.get("bodyText") or "")[:1200],
101+
"element_count": len(snapshot.get("elements") or []),
102+
"slot": snapshot.get("slot"),
103+
},
104+
"control": control,
105+
"portal_url": control.get("portal_url"),
106+
}
107+
108+
76109
@mcp.tool()
77110
def browser_snapshot(lease_id: str) -> dict[str, Any]:
78111
"""Return text and interactive element snapshot for the active page."""

ax_browser_broker/remote_mcp_server.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,35 @@ def browser_open(owner: str, url: str, identity_id: str | None = None, ttl_secon
110110
return _request("POST", "/open", {"owner": owner, "url": url, "identity_id": identity_id, "ttl_seconds": ttl_seconds})
111111

112112

113+
@mcp.tool()
114+
def browser_open_control(
115+
owner: str,
116+
url: str,
117+
identity_id: str | None = None,
118+
ttl_seconds: int = 900,
119+
control_ttl_seconds: int = 900,
120+
screenshot: bool = False,
121+
) -> dict[str, Any]:
122+
"""Open a URL, verify the page, and return a human-control link in one call."""
123+
result = browser_open(owner=owner, url=url, identity_id=identity_id, ttl_seconds=ttl_seconds)
124+
lease_id = str((result.get("lease") or {}).get("lease_id") or result.get("lease_id") or "")
125+
if lease_id:
126+
snapshot = browser_snapshot(lease_id)
127+
result["snapshot"] = {
128+
"title": snapshot.get("title"),
129+
"url": snapshot.get("url"),
130+
"bodyText": str(snapshot.get("bodyText") or "")[:1200],
131+
"element_count": len(snapshot.get("elements") or []),
132+
"slot": snapshot.get("slot"),
133+
}
134+
if screenshot:
135+
result["screenshot"] = {key: value for key, value in browser_screenshot(lease_id).items() if key != "base64"}
136+
control = lease_control_request(lease_id=lease_id, owner=owner, ttl_seconds=control_ttl_seconds)
137+
result["control"] = control
138+
result["portal_url"] = control.get("portal_url")
139+
return result
140+
141+
113142
@mcp.tool()
114143
def browser_navigate(lease_id: str, url: str) -> dict[str, Any]:
115144
"""Navigate a leased browser session to a URL."""

tests/test_api.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,70 @@ async def fake_release(lease_id):
708708
assert released == ["lease-open"]
709709

710710

711+
def test_openbrowser_open_can_return_verified_control_link(monkeypatch) -> None:
712+
monkeypatch.setenv("OPENBROWSER_API_KEYS", "test-openbrowser-key")
713+
714+
async def fake_create_lease(_request):
715+
return {"lease_id": "lease-open", "name": "pool-b", "identity_id": "chrome-work"}
716+
717+
async def fake_browser_navigate(_request):
718+
return {"lease_id": "lease-open", "slot": "pool-b", "url": "https://lovable.dev/dashboard", "title": "Home | Lovable"}
719+
720+
async def fake_browser_snapshot(_request):
721+
return {
722+
"lease_id": "lease-open",
723+
"slot": "pool-b",
724+
"title": "Home | Lovable",
725+
"url": "https://lovable.dev/dashboard",
726+
"bodyText": "A" * 1400,
727+
"elements": [{"selector": "button"}],
728+
}
729+
730+
async def fake_browser_screenshot(_request):
731+
return {
732+
"lease_id": "lease-open",
733+
"slot": "pool-b",
734+
"path": "/tmp/shot.png",
735+
"mime_type": "image/png",
736+
"base64": "secret-image-data",
737+
}
738+
739+
async def fake_lease_control(_request):
740+
return {
741+
"token": "control-token",
742+
"lease_id": "lease-open",
743+
"portal_url": "https://browser.example.com/auth/lease-control/control-token",
744+
}
745+
746+
monkeypatch.setattr(api, "create_lease", fake_create_lease)
747+
monkeypatch.setattr(api, "browser_navigate", fake_browser_navigate)
748+
monkeypatch.setattr(api, "browser_snapshot", fake_browser_snapshot)
749+
monkeypatch.setattr(api, "browser_screenshot", fake_browser_screenshot)
750+
monkeypatch.setattr(api, "lease_control_request", fake_lease_control)
751+
client = TestClient(api.app)
752+
753+
response = client.post(
754+
"/openbrowser/v1/open",
755+
json={
756+
"owner": "pytest-open",
757+
"url": "https://lovable.dev",
758+
"identity_id": "chrome-work",
759+
"control": True,
760+
"screenshot": True,
761+
},
762+
headers={"authorization": "Bearer test-openbrowser-key"},
763+
)
764+
765+
assert response.status_code == 200
766+
data = response.json()
767+
assert data["portal_url"].endswith("/auth/lease-control/control-token")
768+
assert data["snapshot"]["title"] == "Home | Lovable"
769+
assert data["snapshot"]["bodyText"] == "A" * 1200
770+
assert data["snapshot"]["element_count"] == 1
771+
assert data["screenshot"]["path"] == "/tmp/shot.png"
772+
assert "base64" not in data["screenshot"]
773+
774+
711775
def test_lease_failure_records_telemetry(monkeypatch) -> None:
712776
events = []
713777
monkeypatch.setattr(api, "lease", lambda *_args, **_kwargs: (_ for _ in ()).throw(api.LeaseError("No healthy free browser slots")))

tests/test_cli.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,66 @@ def fake_request(method, path, body=None, auth=False):
5050
assert json.loads(capsys.readouterr().out) == {"ok": True}
5151

5252

53+
def test_cli_open_control_sends_one_step_handoff_payload(monkeypatch, capsys) -> None:
54+
calls = []
55+
56+
def fake_request(method, path, body=None, auth=False):
57+
calls.append((method, path, body, auth))
58+
if path == "/openbrowser/v1/open":
59+
return {"lease": {"lease_id": "lease-cli"}, "navigation": {"url": "https://lovable.dev/dashboard"}}
60+
if path == "/openbrowser/v1/browser/snapshot":
61+
return {"title": "Home | Lovable", "url": "https://lovable.dev/dashboard", "bodyText": "ok", "elements": [], "slot": "pool-b"}
62+
if path == "/openbrowser/v1/browser/screenshot":
63+
return {"path": "/tmp/shot.png", "base64": "image-data"}
64+
if path == "/openbrowser/v1/lease-control/request":
65+
return {"portal_url": "https://browser.example.com/auth/lease-control/tok"}
66+
raise AssertionError(path)
67+
68+
monkeypatch.setattr(cli, "_request", fake_request)
69+
70+
assert (
71+
cli.main(
72+
[
73+
"open",
74+
"https://lovable.dev",
75+
"--identity",
76+
"chrome-work",
77+
"--owner",
78+
"pytest-open",
79+
"--control",
80+
"--screenshot",
81+
]
82+
)
83+
== 0
84+
)
85+
86+
assert calls == [
87+
(
88+
"POST",
89+
"/openbrowser/v1/open",
90+
{
91+
"owner": "pytest-open",
92+
"identity_id": "chrome-work",
93+
"url": "https://lovable.dev",
94+
"ttl_seconds": 900,
95+
},
96+
True,
97+
),
98+
("POST", "/openbrowser/v1/browser/snapshot", {"lease_id": "lease-cli"}, True),
99+
("POST", "/openbrowser/v1/browser/screenshot", {"lease_id": "lease-cli", "full_page": False}, True),
100+
(
101+
"POST",
102+
"/openbrowser/v1/lease-control/request",
103+
{"owner": "pytest-open", "lease_id": "lease-cli", "ttl_seconds": 900},
104+
True,
105+
),
106+
]
107+
output = json.loads(capsys.readouterr().out)
108+
assert output["portal_url"].endswith("/tok")
109+
assert output["snapshot"]["title"] == "Home | Lovable"
110+
assert "base64" not in output["screenshot"]
111+
112+
53113
def test_cli_prints_compact_http_errors(monkeypatch, capsys) -> None:
54114
def fake_request(_request, timeout=None):
55115
raise urllib.error.HTTPError(

tests/test_docs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_docs_topics_and_quickstart() -> None:
1414

1515
quickstart = docs("quickstart")
1616
assert quickstart["topic"] == "quickstart"
17-
assert quickstart["examples"][0]["tool"] == "browser_lease"
17+
assert quickstart["examples"][0]["tool"] == "browser_open_control"
1818

1919
identities = docs("identities")
2020
assert any("not overwritten" in item for item in identities["facts"])

0 commit comments

Comments
 (0)