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.
Files changed (108) hide show
  1. package/PRIVACY.md +21 -0
  2. package/README.md +138 -0
  3. package/SKILL.md +301 -0
  4. package/agent-workspace/agent_helpers.py +718 -0
  5. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  6. package/bin/browser-workspace +75 -0
  7. package/extension/manifest.json +26 -0
  8. package/extension/service-worker.mjs +126 -0
  9. package/extension/workspace-manager.mjs +680 -0
  10. package/extension/workspace-page.mjs +7 -0
  11. package/extension/workspace.html +8 -0
  12. package/interaction-skills/connection.md +52 -0
  13. package/interaction-skills/cookies.md +3 -0
  14. package/interaction-skills/cross-origin-iframes.md +3 -0
  15. package/interaction-skills/dialogs.md +64 -0
  16. package/interaction-skills/downloads.md +3 -0
  17. package/interaction-skills/drag-and-drop.md +3 -0
  18. package/interaction-skills/dropdowns.md +3 -0
  19. package/interaction-skills/iframes.md +3 -0
  20. package/interaction-skills/network-requests.md +3 -0
  21. package/interaction-skills/print-as-pdf.md +3 -0
  22. package/interaction-skills/profile-sync.md +90 -0
  23. package/interaction-skills/screenshots.md +17 -0
  24. package/interaction-skills/scrolling.md +22 -0
  25. package/interaction-skills/shadow-dom.md +3 -0
  26. package/interaction-skills/tabs.md +73 -0
  27. package/interaction-skills/uploads.md +1 -0
  28. package/interaction-skills/viewport.md +16 -0
  29. package/licenses/browser-harness-MIT.txt +21 -0
  30. package/managed_chrome.py +244 -0
  31. package/node/admin.mjs +44 -0
  32. package/package.json +36 -0
  33. package/platform_runner.py +187 -0
  34. package/platforms/chatgpt/README.md +60 -0
  35. package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  36. package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  37. package/platforms/chatgpt/actions/_mcp_app.py +80 -0
  38. package/platforms/chatgpt/actions/_new_thread.py +85 -0
  39. package/platforms/chatgpt/actions/_new_turn.py +33 -0
  40. package/platforms/chatgpt/actions/_project_setup.py +353 -0
  41. package/platforms/chatgpt/actions/_readiness.py +52 -0
  42. package/platforms/chatgpt/actions/_submit_thread.py +129 -0
  43. package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
  44. package/platforms/chatgpt/actions/_thread_turn.py +362 -0
  45. package/platforms/chatgpt/manifest.yaml +34 -0
  46. package/platforms/chatgpt/tests/test_contract.py +176 -0
  47. package/platforms/chrome-web-store/README.md +49 -0
  48. package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  49. package/platforms/chrome-web-store/flows/check-status.md +7 -0
  50. package/platforms/chrome-web-store/flows/open-item.md +10 -0
  51. package/platforms/chrome-web-store/flows/submit-review.md +10 -0
  52. package/platforms/chrome-web-store/flows/update-listing.md +18 -0
  53. package/platforms/chrome-web-store/flows/upload-package.md +11 -0
  54. package/platforms/chrome-web-store/flows/verify-published.md +9 -0
  55. package/platforms/chrome-web-store/manifest.yaml +33 -0
  56. package/platforms/chrome-web-store/tests/test_contract.py +83 -0
  57. package/platforms/microsoft-teams/README.md +32 -0
  58. package/platforms/microsoft-teams/actions/_meeting.py +210 -0
  59. package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  60. package/platforms/microsoft-teams/manifest.yaml +25 -0
  61. package/platforms/microsoft-teams/tests/test_contract.py +24 -0
  62. package/platforms/tiktok/README.md +10 -0
  63. package/platforms/tiktok/actions/_post.py +180 -0
  64. package/platforms/tiktok/manifest.yaml +99 -0
  65. package/platforms/wechat-channels/README.md +10 -0
  66. package/platforms/wechat-channels/actions/_common.py +163 -0
  67. package/platforms/wechat-channels/actions/_manage.py +130 -0
  68. package/platforms/wechat-channels/actions/_post.py +232 -0
  69. package/platforms/wechat-channels/manifest.yaml +118 -0
  70. package/platforms/wechat-channels/tests/test_contract.py +36 -0
  71. package/platforms/xhs/README.md +10 -0
  72. package/platforms/xhs/actions/_manage.py +355 -0
  73. package/platforms/xhs/actions/_post.py +192 -0
  74. package/platforms/xhs/manifest.yaml +98 -0
  75. package/platforms/youtube/README.md +10 -0
  76. package/platforms/youtube/actions/_post.py +100 -0
  77. package/platforms/youtube/manifest.yaml +94 -0
  78. package/pyproject.toml +29 -0
  79. package/session_client.py +97 -0
  80. package/session_daemon.py +325 -0
  81. package/src/browser_harness/__init__.py +2 -0
  82. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  83. package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
  84. package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
  85. package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
  86. package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
  87. package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
  88. package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
  89. package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
  90. package/src/browser_harness/_ipc.py +201 -0
  91. package/src/browser_harness/admin.py +1588 -0
  92. package/src/browser_harness/auth.py +546 -0
  93. package/src/browser_harness/daemon.py +894 -0
  94. package/src/browser_harness/helpers.py +668 -0
  95. package/src/browser_harness/macos.py +131 -0
  96. package/src/browser_harness/mcp_cli.py +16 -0
  97. package/src/browser_harness/paths.py +49 -0
  98. package/src/browser_harness/recorder.py +343 -0
  99. package/src/browser_harness/run.py +413 -0
  100. package/src/browser_harness/telemetry.py +308 -0
  101. package/src/browser_harness/video-template.html +970 -0
  102. package/src/browser_harness/video.py +749 -0
  103. package/src/browser_harness/video_render.py +524 -0
  104. package/src/browser_workspace.egg-info/PKG-INFO +10 -0
  105. package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
  106. package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
  107. package/src/browser_workspace.egg-info/requires.txt +5 -0
  108. 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