browser-workspace 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Browser Use Cloud auth for browser-harness.
|
|
2
|
+
|
|
3
|
+
The model-facing contract stays small: cloud browser startup either has a key
|
|
4
|
+
or tells the agent to run `browser-harness auth login`. OAuth details live here.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import getpass
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import secrets
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import webbrowser
|
|
25
|
+
|
|
26
|
+
from . import paths
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
AUTH_BASE = "https://api.browser-use.com"
|
|
30
|
+
# Browser Use currently exposes this registered CLI OAuth client. Keep an env
|
|
31
|
+
# escape hatch so a dedicated browser-harness client can be used once issued.
|
|
32
|
+
DEFAULT_CLIENT_ID = "browser-use-terminal"
|
|
33
|
+
CALLBACK_PATH = "/browser-use-cloud/callback"
|
|
34
|
+
AUTH_TIMEOUT_SECONDS = 600
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CloudAuthRequired(RuntimeError):
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__("cloud-auth-required: run `browser-harness auth login`")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PendingCallback:
|
|
48
|
+
state: str
|
|
49
|
+
code: str | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
error_description: str | None = None
|
|
52
|
+
complete: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class BrowserAuthStart:
|
|
57
|
+
server: HTTPServer
|
|
58
|
+
callback: PendingCallback
|
|
59
|
+
redirect_uri: str
|
|
60
|
+
verifier: str
|
|
61
|
+
auth_url: str
|
|
62
|
+
expires_in: int | None
|
|
63
|
+
opened: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class DeviceAuthStart:
|
|
68
|
+
device_code: str
|
|
69
|
+
user_code: str
|
|
70
|
+
verification_uri: str
|
|
71
|
+
verification_uri_complete: str | None = None
|
|
72
|
+
expires_in: int | None = None
|
|
73
|
+
interval: int = 5
|
|
74
|
+
opened: bool = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def open_uri(self) -> str:
|
|
78
|
+
return self.verification_uri_complete or self.verification_uri
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AuthRecord:
|
|
83
|
+
api_key: str
|
|
84
|
+
api_key_id: str | None = None
|
|
85
|
+
project_id: str | None = None
|
|
86
|
+
expires_at: str | None = None
|
|
87
|
+
scopes: list[str] = field(default_factory=list)
|
|
88
|
+
source: str = "oauth"
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_token_response(cls, data: dict, *, source: str = "oauth") -> "AuthRecord":
|
|
92
|
+
api_key = data.get("api_key")
|
|
93
|
+
if not api_key:
|
|
94
|
+
raise AuthError("auth token response did not include an api_key")
|
|
95
|
+
scopes = data.get("scopes") or []
|
|
96
|
+
if not isinstance(scopes, list):
|
|
97
|
+
scopes = []
|
|
98
|
+
return cls(
|
|
99
|
+
api_key=api_key,
|
|
100
|
+
api_key_id=data.get("api_key_id"),
|
|
101
|
+
project_id=data.get("project_id"),
|
|
102
|
+
expires_at=data.get("expires_at"),
|
|
103
|
+
scopes=[str(s) for s in scopes],
|
|
104
|
+
source=source,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def to_storage(self) -> dict:
|
|
108
|
+
return {
|
|
109
|
+
"api_key": self.api_key,
|
|
110
|
+
"api_key_id": self.api_key_id,
|
|
111
|
+
"project_id": self.project_id,
|
|
112
|
+
"expires_at": self.expires_at,
|
|
113
|
+
"scopes": self.scopes,
|
|
114
|
+
"source": self.source,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def auth_base() -> str:
|
|
119
|
+
return (os.environ.get("BROWSER_USE_CLOUD_API_URL") or AUTH_BASE).rstrip("/")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def client_id() -> str:
|
|
123
|
+
return os.environ.get("BROWSER_HARNESS_OAUTH_CLIENT_ID") or DEFAULT_CLIENT_ID
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def auth_path() -> Path:
|
|
127
|
+
override = os.environ.get("BH_AUTH_PATH")
|
|
128
|
+
if override:
|
|
129
|
+
return Path(override).expanduser()
|
|
130
|
+
return paths.config_dir() / "auth.json"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_auth_file(path: Path | None = None) -> dict:
|
|
134
|
+
path = path or auth_path()
|
|
135
|
+
try:
|
|
136
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return {}
|
|
139
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
140
|
+
raise AuthError(f"auth file is not valid JSON: {path}") from e
|
|
141
|
+
if not isinstance(data, dict):
|
|
142
|
+
raise AuthError(f"auth file must contain a JSON object: {path}")
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def save_auth_record(record: AuthRecord, path: Path | None = None) -> None:
|
|
147
|
+
path = path or auth_path()
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
_chmod_private(path.parent, directory=True)
|
|
150
|
+
existing = load_auth_file(path)
|
|
151
|
+
existing["browser_use"] = record.to_storage()
|
|
152
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
153
|
+
_write_private_json(tmp, existing)
|
|
154
|
+
os.replace(tmp, path)
|
|
155
|
+
_chmod_private(path)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def clear_auth(path: Path | None = None) -> bool:
|
|
159
|
+
path = path or auth_path()
|
|
160
|
+
data = load_auth_file(path)
|
|
161
|
+
existed = bool(data.get("browser_use"))
|
|
162
|
+
data.pop("browser_use", None)
|
|
163
|
+
if data:
|
|
164
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
165
|
+
_write_private_json(tmp, data)
|
|
166
|
+
os.replace(tmp, path)
|
|
167
|
+
_chmod_private(path)
|
|
168
|
+
else:
|
|
169
|
+
try:
|
|
170
|
+
path.unlink()
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
pass
|
|
173
|
+
return existed
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def stored_auth_record(path: Path | None = None) -> dict | None:
|
|
177
|
+
data = load_auth_file(path)
|
|
178
|
+
value = data.get("browser_use")
|
|
179
|
+
return value if isinstance(value, dict) else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_browser_use_api_key() -> str:
|
|
183
|
+
env_key = os.environ.get("BROWSER_USE_API_KEY")
|
|
184
|
+
if env_key:
|
|
185
|
+
return env_key
|
|
186
|
+
stored = stored_auth_record()
|
|
187
|
+
key = stored.get("api_key") if stored else None
|
|
188
|
+
if key:
|
|
189
|
+
return str(key)
|
|
190
|
+
raise CloudAuthRequired()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def auth_status() -> dict:
|
|
194
|
+
if os.environ.get("BROWSER_USE_API_KEY"):
|
|
195
|
+
return {"status": "authenticated", "source": "env", "path": str(auth_path())}
|
|
196
|
+
stored = stored_auth_record()
|
|
197
|
+
if not stored or not stored.get("api_key"):
|
|
198
|
+
return {"status": "missing", "source": None, "path": str(auth_path())}
|
|
199
|
+
return {"status": "authenticated", "source": "stored", "path": str(auth_path())}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def pkce_pair() -> tuple[str, str]:
|
|
203
|
+
verifier = secrets.token_urlsafe(48)
|
|
204
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
205
|
+
challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
206
|
+
return verifier, challenge
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def start_browser_auth(*, open_url=True, timeout=AUTH_TIMEOUT_SECONDS) -> BrowserAuthStart:
|
|
210
|
+
verifier, challenge = pkce_pair()
|
|
211
|
+
state = secrets.token_urlsafe(32)
|
|
212
|
+
callback = PendingCallback(state=state)
|
|
213
|
+
server = _callback_server(callback)
|
|
214
|
+
host, port = server.server_address
|
|
215
|
+
redirect_uri = f"http://{host}:{port}{CALLBACK_PATH}"
|
|
216
|
+
req = {
|
|
217
|
+
"client_id": client_id(),
|
|
218
|
+
"response_type": "code",
|
|
219
|
+
"redirect_uri": redirect_uri,
|
|
220
|
+
"code_challenge": challenge,
|
|
221
|
+
"code_challenge_method": "S256",
|
|
222
|
+
"state": state,
|
|
223
|
+
"device_name": os.environ.get("BH_DEVICE_NAME") or "browser-harness",
|
|
224
|
+
}
|
|
225
|
+
try:
|
|
226
|
+
data = _post_json(f"{auth_base()}/cloud/cli-auth/browser", req)
|
|
227
|
+
except BaseException:
|
|
228
|
+
server.server_close()
|
|
229
|
+
raise
|
|
230
|
+
auth_url = data.get("authorization_uri") or data.get("auth_url")
|
|
231
|
+
if not auth_url:
|
|
232
|
+
server.server_close()
|
|
233
|
+
raise AuthError("auth start response did not include authorization_uri")
|
|
234
|
+
expires_in = _int_or_none(data.get("expires_in"))
|
|
235
|
+
opened = False
|
|
236
|
+
if open_url:
|
|
237
|
+
try:
|
|
238
|
+
opened = bool(webbrowser.open(auth_url))
|
|
239
|
+
except Exception:
|
|
240
|
+
opened = False
|
|
241
|
+
return BrowserAuthStart(
|
|
242
|
+
server=server,
|
|
243
|
+
callback=callback,
|
|
244
|
+
redirect_uri=redirect_uri,
|
|
245
|
+
verifier=verifier,
|
|
246
|
+
auth_url=auth_url,
|
|
247
|
+
expires_in=expires_in,
|
|
248
|
+
opened=opened,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def complete_browser_auth(start: BrowserAuthStart, *, timeout=AUTH_TIMEOUT_SECONDS) -> AuthRecord:
|
|
253
|
+
deadline = time.time() + timeout
|
|
254
|
+
start.server.timeout = 0.5
|
|
255
|
+
try:
|
|
256
|
+
while not start.callback.complete and time.time() < deadline:
|
|
257
|
+
start.server.handle_request()
|
|
258
|
+
finally:
|
|
259
|
+
start.server.server_close()
|
|
260
|
+
if not start.callback.complete:
|
|
261
|
+
raise AuthError("timed out waiting for browser auth callback")
|
|
262
|
+
if start.callback.error:
|
|
263
|
+
detail = f": {start.callback.error_description}" if start.callback.error_description else ""
|
|
264
|
+
raise AuthError(f"auth failed: {start.callback.error}{detail}")
|
|
265
|
+
if not start.callback.code:
|
|
266
|
+
raise AuthError("auth callback did not include a code")
|
|
267
|
+
token = _exchange_authorization_code(start.callback.code, start.redirect_uri, start.verifier)
|
|
268
|
+
record = AuthRecord.from_token_response(token)
|
|
269
|
+
save_auth_record(record)
|
|
270
|
+
return record
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def browser_login(*, open_url=True, json_output=False, timeout=AUTH_TIMEOUT_SECONDS) -> AuthRecord:
|
|
274
|
+
start = start_browser_auth(open_url=open_url, timeout=timeout)
|
|
275
|
+
if json_output:
|
|
276
|
+
print(json.dumps({
|
|
277
|
+
"status": "needs_user_auth",
|
|
278
|
+
"auth_url": start.auth_url,
|
|
279
|
+
"callback": start.redirect_uri,
|
|
280
|
+
"expires_in": start.expires_in,
|
|
281
|
+
"opened": start.opened,
|
|
282
|
+
}), flush=True)
|
|
283
|
+
else:
|
|
284
|
+
print("Open this URL to sign in to Browser Use Cloud:")
|
|
285
|
+
print(start.auth_url, flush=True)
|
|
286
|
+
if start.opened:
|
|
287
|
+
print("Waiting for login to complete...", flush=True)
|
|
288
|
+
else:
|
|
289
|
+
print("Waiting for login to complete after you open the URL...", flush=True)
|
|
290
|
+
record = complete_browser_auth(start, timeout=timeout)
|
|
291
|
+
if json_output:
|
|
292
|
+
print(json.dumps(_stored_success_output()), flush=True)
|
|
293
|
+
else:
|
|
294
|
+
print("Browser Use Cloud auth stored.")
|
|
295
|
+
return record
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def start_device_auth(*, open_url=True) -> DeviceAuthStart:
|
|
299
|
+
data = _post_json(
|
|
300
|
+
f"{auth_base()}/cloud/cli-auth/device",
|
|
301
|
+
{"client_id": client_id(), "device_name": os.environ.get("BH_DEVICE_NAME") or "browser-harness"},
|
|
302
|
+
)
|
|
303
|
+
device_code = data.get("device_code")
|
|
304
|
+
user_code = data.get("user_code")
|
|
305
|
+
verification_uri = data.get("verification_uri") or data.get("verification_url")
|
|
306
|
+
if not device_code or not user_code or not verification_uri:
|
|
307
|
+
raise AuthError("device auth response missing device_code, user_code, or verification_uri")
|
|
308
|
+
opened = False
|
|
309
|
+
open_uri = data.get("verification_uri_complete") or verification_uri
|
|
310
|
+
if open_url:
|
|
311
|
+
try:
|
|
312
|
+
opened = bool(webbrowser.open(open_uri))
|
|
313
|
+
except Exception:
|
|
314
|
+
opened = False
|
|
315
|
+
return DeviceAuthStart(
|
|
316
|
+
device_code=device_code,
|
|
317
|
+
user_code=user_code,
|
|
318
|
+
verification_uri=verification_uri,
|
|
319
|
+
verification_uri_complete=data.get("verification_uri_complete"),
|
|
320
|
+
expires_in=_int_or_none(data.get("expires_in")),
|
|
321
|
+
interval=max(1, _int_or_none(data.get("interval")) or 5),
|
|
322
|
+
opened=opened,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def complete_device_auth(start: DeviceAuthStart, *, timeout: int | None = None) -> AuthRecord:
|
|
327
|
+
deadline = time.time() + (timeout or start.expires_in or AUTH_TIMEOUT_SECONDS)
|
|
328
|
+
interval = start.interval
|
|
329
|
+
while time.time() < deadline:
|
|
330
|
+
try:
|
|
331
|
+
token = _post_json(f"{auth_base()}/cloud/cli-auth/token", {
|
|
332
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
333
|
+
"device_code": start.device_code,
|
|
334
|
+
"client_id": client_id(),
|
|
335
|
+
})
|
|
336
|
+
record = AuthRecord.from_token_response(token)
|
|
337
|
+
save_auth_record(record)
|
|
338
|
+
return record
|
|
339
|
+
except AuthError as e:
|
|
340
|
+
err = _auth_error_code(str(e))
|
|
341
|
+
if err == "authorization_pending":
|
|
342
|
+
time.sleep(interval)
|
|
343
|
+
continue
|
|
344
|
+
if err == "slow_down":
|
|
345
|
+
interval += 5
|
|
346
|
+
time.sleep(interval)
|
|
347
|
+
continue
|
|
348
|
+
raise
|
|
349
|
+
raise AuthError("timed out waiting for device auth")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def device_login(*, open_url=True, json_output=False) -> AuthRecord:
|
|
353
|
+
start = start_device_auth(open_url=open_url)
|
|
354
|
+
if json_output:
|
|
355
|
+
print(json.dumps({
|
|
356
|
+
"status": "needs_user_auth",
|
|
357
|
+
"verification_uri": start.verification_uri,
|
|
358
|
+
"verification_uri_complete": start.verification_uri_complete,
|
|
359
|
+
"user_code": start.user_code,
|
|
360
|
+
"expires_in": start.expires_in,
|
|
361
|
+
"opened": start.opened,
|
|
362
|
+
}), flush=True)
|
|
363
|
+
else:
|
|
364
|
+
print("Open this URL to sign in to Browser Use Cloud:")
|
|
365
|
+
print(start.open_uri, flush=True)
|
|
366
|
+
print(f"Code: {start.user_code}", flush=True)
|
|
367
|
+
print("Waiting for login to complete...", flush=True)
|
|
368
|
+
record = complete_device_auth(start)
|
|
369
|
+
if json_output:
|
|
370
|
+
print(json.dumps(_stored_success_output()), flush=True)
|
|
371
|
+
else:
|
|
372
|
+
print("Browser Use Cloud auth stored.")
|
|
373
|
+
return record
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def api_key_stdin_login(*, json_output=False, input_stream=None) -> AuthRecord:
|
|
377
|
+
key = _read_manual_api_key(input_stream)
|
|
378
|
+
record = AuthRecord(api_key=key, source="manual")
|
|
379
|
+
save_auth_record(record)
|
|
380
|
+
if json_output:
|
|
381
|
+
print(json.dumps(_stored_success_output()), flush=True)
|
|
382
|
+
else:
|
|
383
|
+
print("Browser Use Cloud API key stored.")
|
|
384
|
+
return record
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _exchange_authorization_code(code: str, redirect_uri: str, verifier: str) -> dict:
|
|
388
|
+
return _post_json(f"{auth_base()}/cloud/cli-auth/token", {
|
|
389
|
+
"grant_type": "authorization_code",
|
|
390
|
+
"code": code,
|
|
391
|
+
"redirect_uri": redirect_uri,
|
|
392
|
+
"code_verifier": verifier,
|
|
393
|
+
"client_id": client_id(),
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _callback_server(callback: PendingCallback) -> HTTPServer:
|
|
398
|
+
class Handler(BaseHTTPRequestHandler):
|
|
399
|
+
def do_GET(self): # noqa: N802 - stdlib handler API
|
|
400
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
401
|
+
if parsed.path != CALLBACK_PATH:
|
|
402
|
+
self.send_error(404)
|
|
403
|
+
return
|
|
404
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
405
|
+
state = _one(qs, "state")
|
|
406
|
+
if state != callback.state:
|
|
407
|
+
callback.error = "invalid_state"
|
|
408
|
+
callback.error_description = "OAuth callback state did not match"
|
|
409
|
+
else:
|
|
410
|
+
callback.code = _one(qs, "code")
|
|
411
|
+
callback.error = _one(qs, "error")
|
|
412
|
+
callback.error_description = _one(qs, "error_description")
|
|
413
|
+
callback.complete = True
|
|
414
|
+
body = b"<html><body><h1>Browser Use Cloud login complete</h1><p>You can close this tab.</p></body></html>"
|
|
415
|
+
self.send_response(200)
|
|
416
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
417
|
+
self.send_header("Content-Length", str(len(body)))
|
|
418
|
+
self.end_headers()
|
|
419
|
+
self.wfile.write(body)
|
|
420
|
+
|
|
421
|
+
def log_message(self, fmt, *args):
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
return HTTPServer(("127.0.0.1", 0), Handler)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _post_json(url: str, payload: dict) -> dict:
|
|
428
|
+
req = urllib.request.Request(
|
|
429
|
+
url,
|
|
430
|
+
method="POST",
|
|
431
|
+
data=json.dumps(payload).encode(),
|
|
432
|
+
headers={"Content-Type": "application/json"},
|
|
433
|
+
)
|
|
434
|
+
try:
|
|
435
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
436
|
+
return json.loads(resp.read() or b"{}")
|
|
437
|
+
except urllib.error.HTTPError as e:
|
|
438
|
+
body = e.read() or b""
|
|
439
|
+
try:
|
|
440
|
+
data = json.loads(body or b"{}")
|
|
441
|
+
except json.JSONDecodeError:
|
|
442
|
+
data = {}
|
|
443
|
+
err = data.get("error") or data.get("state") or f"http_{e.code}"
|
|
444
|
+
desc = data.get("error_description") or data.get("reason") or data.get("message")
|
|
445
|
+
detail = f": {desc}" if desc else ""
|
|
446
|
+
raise AuthError(f"{err}{detail}") from e
|
|
447
|
+
except urllib.error.URLError as e:
|
|
448
|
+
raise AuthError(f"network error: {e.reason}") from e
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _read_manual_api_key(input_stream=None) -> str:
|
|
452
|
+
stream = input_stream or sys.stdin
|
|
453
|
+
if hasattr(stream, "isatty") and stream.isatty():
|
|
454
|
+
try:
|
|
455
|
+
key = getpass.getpass("Browser Use API key: ")
|
|
456
|
+
except EOFError as e:
|
|
457
|
+
raise AuthError("no API key provided") from e
|
|
458
|
+
else:
|
|
459
|
+
key = stream.read()
|
|
460
|
+
key = (key or "").strip()
|
|
461
|
+
if not key:
|
|
462
|
+
raise AuthError("no API key provided")
|
|
463
|
+
if len(key) < 20:
|
|
464
|
+
raise AuthError("API key looks too short")
|
|
465
|
+
return key
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _write_private_json(path: Path, data: dict) -> None:
|
|
469
|
+
raw = (json.dumps(data, indent=2) + "\n").encode()
|
|
470
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
471
|
+
fd = os.open(path, flags, stat.S_IRUSR | stat.S_IWUSR)
|
|
472
|
+
try:
|
|
473
|
+
with os.fdopen(fd, "wb") as f:
|
|
474
|
+
f.write(raw)
|
|
475
|
+
except BaseException:
|
|
476
|
+
try:
|
|
477
|
+
os.close(fd)
|
|
478
|
+
except OSError:
|
|
479
|
+
pass
|
|
480
|
+
raise
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _chmod_private(path: Path, *, directory=False) -> None:
|
|
484
|
+
mode = stat.S_IRWXU if directory else stat.S_IRUSR | stat.S_IWUSR
|
|
485
|
+
try:
|
|
486
|
+
os.chmod(path, mode)
|
|
487
|
+
except OSError:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _one(qs: dict[str, list[str]], key: str) -> str | None:
|
|
492
|
+
values = qs.get(key)
|
|
493
|
+
return values[0] if values else None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _int_or_none(value) -> int | None:
|
|
497
|
+
try:
|
|
498
|
+
return int(value)
|
|
499
|
+
except (TypeError, ValueError):
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _auth_error_code(message: str) -> str:
|
|
504
|
+
return message.split(":", 1)[0]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _stored_success_output() -> dict:
|
|
508
|
+
return {"status": "stored", "path": str(auth_path())}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def run_auth_cli(argv: list[str]) -> int:
|
|
512
|
+
parser = argparse.ArgumentParser(prog="browser-harness auth")
|
|
513
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
514
|
+
login = sub.add_parser("login")
|
|
515
|
+
login_mode = login.add_mutually_exclusive_group()
|
|
516
|
+
login_mode.add_argument("--device-code", action="store_true")
|
|
517
|
+
login_mode.add_argument("--api-key-stdin", action="store_true")
|
|
518
|
+
login.add_argument("--json", action="store_true")
|
|
519
|
+
login.add_argument("--no-open", action="store_true")
|
|
520
|
+
sub.add_parser("status")
|
|
521
|
+
sub.add_parser("logout")
|
|
522
|
+
args = parser.parse_args(argv)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
if args.command == "login":
|
|
526
|
+
if args.api_key_stdin:
|
|
527
|
+
api_key_stdin_login(json_output=args.json)
|
|
528
|
+
elif args.device_code:
|
|
529
|
+
device_login(open_url=not args.no_open, json_output=args.json)
|
|
530
|
+
else:
|
|
531
|
+
browser_login(open_url=not args.no_open, json_output=args.json)
|
|
532
|
+
return 0
|
|
533
|
+
if args.command == "status":
|
|
534
|
+
print(json.dumps(auth_status(), indent=2))
|
|
535
|
+
return 0
|
|
536
|
+
if args.command == "logout":
|
|
537
|
+
removed = clear_auth()
|
|
538
|
+
print(json.dumps({"status": "logged-out" if removed else "missing", "path": str(auth_path())}, indent=2))
|
|
539
|
+
return 0
|
|
540
|
+
except (AuthError, CloudAuthRequired) as e:
|
|
541
|
+
if getattr(args, "json", False):
|
|
542
|
+
print(json.dumps({"status": "error", "reason": str(e)}), file=sys.stderr)
|
|
543
|
+
else:
|
|
544
|
+
print(str(e), file=sys.stderr)
|
|
545
|
+
return 1
|
|
546
|
+
return 2
|