Skip to content

Commit 90d4060

Browse files
committed
Make auth handoff use same browser lease
1 parent 5035ace commit 90d4060

15 files changed

Lines changed: 341 additions & 83 deletions

File tree

ax_browser_broker/api.py

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class LeaseRequest(BaseModel):
6161
owner: str = "unknown"
6262
ttl_seconds: int = Field(default=14400, ge=60, le=14400)
6363
identity_id: str | None = None
64+
headed: bool = False
6465

6566

6667
class LeaseIdRequest(BaseModel):
@@ -152,7 +153,7 @@ class AuthRequest(BaseModel):
152153
reason: str = "login_required"
153154
identity_id: str | None = None
154155
profile: str | None = None
155-
mode: str = "vnc"
156+
mode: str = "same_lease"
156157
ttl_seconds: int = Field(default=900, ge=60, le=14400)
157158
control_ttl_seconds: int = Field(default=900, ge=60, le=3600)
158159
wait_until: str = "domcontentloaded"
@@ -165,8 +166,8 @@ def normalize_legacy_profile(self) -> "AuthRequest":
165166
raise ValueError("profile and identity_id must match when both are provided")
166167
self.identity_id = self.profile
167168
normalized_mode = self.mode.strip().lower().replace("-", "_")
168-
if normalized_mode not in {"lease_control", "vnc"}:
169-
raise ValueError("mode must be lease_control or vnc")
169+
if normalized_mode not in {"lease_control", "same_lease", "vnc"}:
170+
raise ValueError("mode must be lease_control, same_lease, or vnc")
170171
self.mode = normalized_mode
171172
return self
172173

@@ -284,6 +285,76 @@ async def _legacy_vnc_auth_request(request: AuthRequest) -> dict[str, Any]:
284285
return result
285286

286287

288+
async def _same_lease_auth_request(request: AuthRequest) -> dict[str, Any]:
289+
if request.identity_id:
290+
require_identity(request.identity_id)
291+
active_lease_id = _active_identity_lease_id(request.identity_id) if request.identity_id else None
292+
lease_obj: dict[str, Any]
293+
created_for_auth = False
294+
navigation: dict[str, Any] | None = None
295+
if active_lease_id:
296+
lease_data = require_lease(active_lease_id)
297+
if not lease_data.headed:
298+
raise HTTPException(
299+
status_code=409,
300+
detail=(
301+
f"Identity is already leased in a non-visual slot: {request.identity_id}. "
302+
"Release that lease before starting a login handoff."
303+
),
304+
)
305+
lease_obj = lease_data.__dict__
306+
else:
307+
lease_obj = await create_lease(
308+
LeaseRequest(
309+
owner=request.owner,
310+
ttl_seconds=request.ttl_seconds,
311+
identity_id=request.identity_id,
312+
headed=True,
313+
)
314+
)
315+
created_for_auth = True
316+
try:
317+
navigation = await browser_navigate(
318+
NavigateRequest(lease_id=str(lease_obj["lease_id"]), url=request.url, wait_until=request.wait_until)
319+
)
320+
except Exception:
321+
await release_lease(str(lease_obj["lease_id"]))
322+
raise
323+
auth_data = create_auth_request(
324+
request.owner,
325+
request.url,
326+
request.reason,
327+
request.identity_id,
328+
mode="same_lease",
329+
lease_id=str(lease_obj["lease_id"]),
330+
slot=str(lease_obj.get("name") or ""),
331+
cdp=str(lease_obj.get("cdp") or ""),
332+
)
333+
auth_data["lease_id"] = str(lease_obj["lease_id"])
334+
auth_data["lease"] = lease_obj
335+
auth_data["auth_url"] = auth_data["portal_url"]
336+
auth_data["handoff_url"] = auth_data["portal_url"]
337+
auth_data["created_lease"] = created_for_auth
338+
if navigation:
339+
auth_data["navigation"] = navigation
340+
_safe_record_event(
341+
source=request.owner,
342+
event_type="auth",
343+
message="Same-lease auth handoff request created",
344+
lease_id=str(lease_obj["lease_id"]),
345+
url=request.url,
346+
tags=["auth", "same-lease", request.reason],
347+
data={
348+
"token": auth_data.get("token"),
349+
"status": auth_data.get("status"),
350+
"identity_id": request.identity_id,
351+
"slot": lease_obj.get("name"),
352+
"created_lease": created_for_auth,
353+
},
354+
)
355+
return auth_data
356+
357+
287358
async def _active_identity_control_response(request: AuthRequest) -> dict[str, Any] | None:
288359
if not request.identity_id:
289360
return None
@@ -369,6 +440,11 @@ async def _active_identity_control_response(request: AuthRequest) -> dict[str, A
369440

370441

371442
async def _open_auth_lease_control(request: AuthRequest) -> dict[str, Any]:
443+
if request.mode == "same_lease":
444+
if not request.identity_id:
445+
legacy_request = request.model_copy(update={"mode": "vnc"})
446+
return await _legacy_vnc_auth_request(legacy_request)
447+
return await _same_lease_auth_request(request)
372448
if request.mode == "vnc":
373449
return await _legacy_vnc_auth_request(request)
374450
active_response = await _active_identity_control_response(request)
@@ -636,6 +712,18 @@ async def _verify_live_auth_browser(cdp: str | None, target_url: str, host: str
636712
return {"ok": False, "checked": False, "host": host, "url": target_url, "error": str(error)}
637713

638714

715+
async def _verify_live_lease_page_state(lease_id: str, target_url: str, host: str | None) -> dict[str, Any]:
716+
if not lease_id:
717+
return {"ok": False, "checked": False, "error": "missing lease_id"}
718+
try:
719+
if target_url:
720+
await browser_navigate(NavigateRequest(lease_id=lease_id, url=target_url))
721+
snapshot = await browser_snapshot(LeaseIdRequest(lease_id=lease_id))
722+
return _assess_auth_page_state(snapshot, host)
723+
except Exception as error:
724+
return {"ok": False, "checked": False, "host": host, "url": target_url, "error": str(error)}
725+
726+
639727
def _record_browser_failure(request: LeaseIdRequest, action: str, error: Exception, data: dict[str, Any] | None = None) -> None:
640728
_safe_record_event(
641729
source="broker-api",
@@ -2223,6 +2311,11 @@ async def openbrowser_docs(_auth: str = Depends(require_openbrowser_api_key)) ->
22232311
"dashboard": "/openbrowser",
22242312
"base_url": _openbrowser_base_url(),
22252313
"endpoints": _openbrowser_endpoint_catalog(),
2314+
"agent_guidance": {
2315+
"quickstart": docs("quickstart"),
2316+
"auth": docs("auth"),
2317+
"routing": docs("routing"),
2318+
},
22262319
"identities": {
22272320
"generic": "omit identity_id for a neutral non-account browser",
22282321
"configured": {
@@ -2467,7 +2560,7 @@ async def openbrowser_telemetry_summary(
24672560
@app.post("/lease")
24682561
async def create_lease(request: LeaseRequest) -> dict[str, Any]:
24692562
try:
2470-
lease_obj = lease(request.owner, request.ttl_seconds, request.identity_id)
2563+
lease_obj = lease(request.owner, request.ttl_seconds, request.identity_id, headed=request.headed)
24712564
result = lease_obj.__dict__
24722565
_safe_record_event(
24732566
source=lease_obj.owner,
@@ -4474,6 +4567,44 @@ async def auth_complete(token: str) -> dict[str, Any]:
44744567
pending_target_url = str(pending_request.get("url") or "")
44754568
pending_host = urllib.parse.urlsplit(pending_target_url).hostname if pending_target_url else None
44764569
pending_vnc = pending_request.get("vnc") or {}
4570+
if pending_request.get("mode") == "same_lease":
4571+
live_verification = await _verify_live_lease_page_state(
4572+
str(pending_request.get("lease_id") or ""),
4573+
pending_target_url,
4574+
pending_host,
4575+
)
4576+
if not live_verification.get("ok"):
4577+
response = dict(pending_request)
4578+
response["completion_blocked"] = True
4579+
response["auth_verified"] = False
4580+
response["live_page_verification"] = live_verification
4581+
_safe_record_event(
4582+
source=str(pending_request.get("owner", "unknown")),
4583+
event_type="auth",
4584+
message="Same-lease auth completion blocked because live tab still appears signed out",
4585+
severity="warning",
4586+
lease_id=str(pending_request.get("lease_id") or ""),
4587+
url=pending_target_url,
4588+
tags=["auth", "complete", "blocked", "same-lease", "live-page-signed-out"],
4589+
data={"token": token, "identity_id": pending_identity_id, "verification": live_verification},
4590+
)
4591+
return response
4592+
request = complete_auth_request(token)
4593+
request["vnc_stop"] = stop_auth_vnc(token, missing_ok=True)
4594+
request["auth_verified"] = True
4595+
request["live_page_verification"] = live_verification
4596+
request["lease_id"] = str(pending_request.get("lease_id") or "")
4597+
request["handoff_url"] = request.get("portal_url")
4598+
_safe_record_event(
4599+
source=str(request.get("owner", "unknown")),
4600+
event_type="auth",
4601+
message="Same-lease auth request completed",
4602+
lease_id=str(request.get("lease_id") or ""),
4603+
url=str(request.get("url") or ""),
4604+
tags=["auth", "complete", "same-lease"],
4605+
data={"token": token, "status": request.get("status")},
4606+
)
4607+
return request
44774608
if pending_identity_id and pending_vnc.get("cdp"):
44784609
live_verification = await _verify_live_auth_browser(
44794610
str(pending_vnc.get("cdp")),

ax_browser_broker/auth.py

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,14 @@ def create_auth_request(
145145
reason: str = "login_required",
146146
identity_id: str | None = None,
147147
mode: str = "vnc",
148+
lease_id: str | None = None,
149+
slot: str | None = None,
150+
cdp: str | None = None,
148151
) -> dict[str, Any]:
149152
now = int(time.time())
150153
normalized_mode = mode.strip().lower().replace("-", "_")
151-
if normalized_mode not in {"lease_control", "vnc"}:
152-
raise AuthError("mode must be lease_control or vnc")
154+
if normalized_mode not in {"lease_control", "same_lease", "vnc"}:
155+
raise AuthError("mode must be lease_control, same_lease, or vnc")
153156
if identity_id:
154157
require_identity(identity_id)
155158
token = secrets.token_urlsafe(24)
@@ -167,6 +170,12 @@ def create_auth_request(
167170
}
168171
if identity_id:
169172
request["identity_id"] = identity_id
173+
if lease_id:
174+
request["lease_id"] = lease_id
175+
if slot:
176+
request["slot"] = slot
177+
if cdp:
178+
request["cdp"] = cdp
170179
with locked_auth_state() as state:
171180
gc_auth_requests(state)
172181
state["requests"][token] = request
@@ -605,7 +614,10 @@ def start_auth_vnc(token: str, websocket_port: int = 6081, vnc_port: int = 5901)
605614
os.chmod(password_file, 0o600)
606615

607616
try:
608-
if request.get("identity_id"):
617+
if request.get("mode") == "same_lease":
618+
vnc_state = _start_lease_vnc(request, websocket_port, vnc_port, password_file, log_path)
619+
display = str(vnc_state["display"])
620+
elif request.get("identity_id"):
609621
vnc_state = _start_identity_auth_vnc(request, websocket_port, vnc_port, password_file, log_path)
610622
display = str(vnc_state["display"])
611623
else:
@@ -743,6 +755,80 @@ def _close_chrome_via_cdp(cdp_port: int) -> bool:
743755
return False
744756

745757

758+
def _start_lease_vnc(
759+
request: dict[str, Any],
760+
websocket_port: int,
761+
vnc_port: int,
762+
password_file: Path,
763+
log_path: Path,
764+
) -> dict[str, Any]:
765+
from .pool import require_lease
766+
767+
lease_id = str(request.get("lease_id") or "")
768+
if not lease_id:
769+
raise AuthError("same_lease auth request is missing lease_id")
770+
lease = require_lease(lease_id)
771+
display_file = BROWSER_POOL_DIR / "state" / f"{lease.name}.display"
772+
try:
773+
display = display_file.read_text(encoding="utf-8").strip()
774+
except OSError as error:
775+
raise AuthError(f"Headed display is not available for lease slot {lease.name}") from error
776+
if not display:
777+
raise AuthError(f"Headed display is empty for lease slot {lease.name}")
778+
x11vnc = shutil.which("x11vnc")
779+
websockify = shutil.which("websockify")
780+
if not x11vnc or not websockify:
781+
raise AuthError("x11vnc or websockify is missing")
782+
env = os.environ.copy()
783+
env["DISPLAY"] = display
784+
with log_path.open("ab") as log:
785+
x11vnc_proc = subprocess.Popen(
786+
[
787+
x11vnc,
788+
"-display",
789+
display,
790+
"-rfbport",
791+
str(vnc_port),
792+
"-localhost",
793+
"-forever",
794+
"-shared",
795+
"-passwdfile",
796+
str(password_file),
797+
],
798+
stdout=log,
799+
stderr=log,
800+
env=env,
801+
start_new_session=True,
802+
)
803+
websockify_proc = subprocess.Popen(
804+
[
805+
websockify,
806+
"--web=/usr/share/novnc",
807+
f"127.0.0.1:{websocket_port}",
808+
f"127.0.0.1:{vnc_port}",
809+
],
810+
stdout=log,
811+
stderr=log,
812+
env=env,
813+
start_new_session=True,
814+
)
815+
return {
816+
"mode": "same-lease",
817+
"lease_id": lease_id,
818+
"slot": lease.name,
819+
"identity_id": lease.identity_id,
820+
"display": display,
821+
"x11vnc_pid": x11vnc_proc.pid,
822+
"websockify_pid": websockify_proc.pid,
823+
"websocket_port": websocket_port,
824+
"vnc_port": vnc_port,
825+
"password_file": str(password_file),
826+
"started_at": int(time.time()),
827+
"profile_dir": lease.profile_dir,
828+
"cdp": lease.cdp,
829+
}
830+
831+
746832
def stop_auth_vnc(token: str, missing_ok: bool = False) -> dict[str, Any]:
747833
try:
748834
request = get_auth_request(token)

ax_browser_broker/browser_pool/bin/launch_chrome.sh

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ PROFILE_DIR="${BROWSER_POOL_DIR}/profiles/${NAME}"
1111
LOG="${BROWSER_POOL_DIR}/logs/${NAME}.log"
1212
MAINTENANCE_FILE="${BROWSER_POOL_DIR}/state/maintenance/${NAME}.json"
1313
PROXY_PID_FILE="${BROWSER_POOL_DIR}/state/${NAME}.proxy.pid"
14+
XVFB_PID_FILE="${BROWSER_POOL_DIR}/state/${NAME}.xvfb.pid"
15+
DISPLAY_FILE="${BROWSER_POOL_DIR}/state/${NAME}.display"
1416
PROXY_ARGS=()
1517
SYNC_ARGS=()
18+
HEADLESS_ARGS=(--headless)
19+
DISPLAY_ARGS=()
1620
CHROME_LANG="en-US"
1721

1822
find_chrome_bin() {
@@ -88,6 +92,14 @@ if [[ -f "$PROXY_PID_FILE" ]]; then
8892
fi
8993
rm -f "$PROXY_PID_FILE"
9094
fi
95+
if [[ -f "$XVFB_PID_FILE" ]]; then
96+
old_xvfb_pid="$(cat "$XVFB_PID_FILE" 2>/dev/null || true)"
97+
if [[ -n "$old_xvfb_pid" ]]; then
98+
kill "$old_xvfb_pid" 2>/dev/null || true
99+
fi
100+
rm -f "$XVFB_PID_FILE"
101+
fi
102+
rm -f "$DISPLAY_FILE"
91103
sleep 1
92104

93105
if [[ -n "${PROXY_REF:-}" ]]; then
@@ -142,12 +154,29 @@ if ! CHROME_BIN="$(find_chrome_bin)"; then
142154
exit 1
143155
fi
144156

157+
if [[ "${CHROME_HEADLESS:-1}" == "0" ]]; then
158+
XVFB_BIN="${OPENBROWSER_XVFB_BIN:-$(command -v Xvfb || true)}"
159+
if [[ -z "$XVFB_BIN" ]]; then
160+
echo "$(date -Is) ${NAME} headed mode requested but Xvfb is missing" >>"$LOG"
161+
exit 1
162+
fi
163+
DISPLAY_NUM="${CHROME_DISPLAY_NUM:-$((PORT - 9000))}"
164+
DISPLAY=":${DISPLAY_NUM}"
165+
"$XVFB_BIN" "$DISPLAY" -screen 0 "${CHROME_WINDOW_SIZE:-1280x800x24}" -nolisten tcp >>"$LOG" 2>&1 &
166+
XVFB_PID=$!
167+
echo "$XVFB_PID" > "$XVFB_PID_FILE"
168+
echo "$DISPLAY" > "$DISPLAY_FILE"
169+
export DISPLAY
170+
HEADLESS_ARGS=()
171+
DISPLAY_ARGS=(--ozone-platform=x11)
172+
fi
173+
145174
(
146175
if [[ -w "/proc/${BASHPID}/oom_score_adj" ]]; then
147176
echo 300 >"/proc/${BASHPID}/oom_score_adj" || true
148177
fi
149178
exec "$CHROME_BIN" \
150-
--headless \
179+
"${HEADLESS_ARGS[@]}" \
151180
--user-data-dir="$PROFILE_DIR" \
152181
--no-sandbox \
153182
--disable-gpu \
@@ -169,6 +198,7 @@ fi
169198
--window-size=1280,800 \
170199
--window-position=0,0 \
171200
--remote-allow-origins='*' \
201+
"${DISPLAY_ARGS[@]}" \
172202
"${PROXY_ARGS[@]}"
173203
) >"$LOG" 2>&1 &
174204
CHROME_PID=$!

0 commit comments

Comments
 (0)