← Files LexMount 云浏览器ARCHIVED FILE
skills/lexmount-cloud-browser/scripts/remote_auth.py
13.6 KB · Oct 2, 2026 · 00:34 UTC
#!/usr/bin/env python3
"""Official Browser PKCE login with a user-local callback and remote exchange."""
import argparse
import base64
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib import error, parse, request
import webbrowser
PUBLIC_REQUEST = None
CONNECT = "https://browser.lexmount.cn"
API = "https://api.lexmount.cn"
SCOPES = "browser:sessions browser:contexts browser:actions"
class AuthError(ValueError):
"""Only fixed, non-secret diagnostic messages may use this exception."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
def status(state_dir):
state_dir = Path(state_dir)
if (state_dir / "credentials.json").is_file():
return {"status": "credentials_saved", "next": "Run official CLI doctor; saved credentials alone do not prove access"}
if not (state_dir / "pending.json").is_file():
return {"status": "request_missing", "next": "Create a new authorization helper in this task"}
pending = load_json(state_dir / "pending.json")
remaining = max(0, int(pending["expires_at"] - time.time()))
return {"status": "waiting_for_authorization" if remaining else "request_expired",
"expires_in_seconds": remaining,
"next": "Complete login and return the result file" if remaining else "Create a new authorization helper; the old one cannot be resumed"}
def private_json(path, value):
path = Path(path)
fd, temporary = tempfile.mkstemp(dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
json.dump(value, stream)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def load_json(path):
path = Path(path)
if path.stat().st_size > 32768:
raise ValueError("Authorization file is too large")
value = json.loads(path.read_text())
if not isinstance(value, dict):
raise ValueError("Invalid authorization file")
return value
def begin(output_dir, private_root, client_name):
if private_root is None:
raise AuthError("private_storage_required", "Provide a private directory in the current task workspace")
private_root = Path(private_root)
output_dir = Path(output_dir).resolve()
private_root = private_root.resolve()
if private_root == output_dir or output_dir in private_root.parents:
raise AuthError("unsafe_private_storage", "Private OAuth state must be outside the deliverable directory")
private_root.mkdir(parents=True, exist_ok=True, mode=0o700)
state_dir = Path(tempfile.mkdtemp(prefix="request-", dir=private_root))
verifier = secrets.token_urlsafe(32)
public = {"state": secrets.token_urlsafe(24),
"challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("="),
"expires_at": int(time.time()) + 1800,
"client_name": client_name[:80]}
private_json(state_dir / "pending.json", dict(public, verifier=verifier))
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
helper = output_dir / ("lexmount-authorize-" + public["state"][:8] + ".py")
source = Path(__file__).read_text()
source = source.replace("PUBLIC_REQUEST = None", "PUBLIC_REQUEST = " + repr(public), 1)
with helper.open("x") as stream:
stream.write(source)
return {"status": "authorization_required", "helper_file": str(helper.resolve()),
"state_dir": str(state_dir.resolve()),
"credentials_file": str((state_dir / "credentials.json").resolve()),
"expires_in_seconds": 1800}
def valid_redirect(value):
u = parse.urlsplit(value)
return (u.scheme == "http" and u.hostname == "127.0.0.1" and
u.port is not None and 0 < u.port < 65536 and u.path == "/callback" and
not u.username and not u.password and not u.query and not u.fragment)
def callback_handler(public, redirect_uri, result_path):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
u = parse.urlsplit(self.path)
q = parse.parse_qs(u.query, keep_blank_values=True)
valid = (time.time() < public["expires_at"] and u.path == "/callback" and
self.headers.get("Host") == parse.urlsplit(redirect_uri).netloc and
len(q.get("state", [])) == 1 and
hmac.compare_digest(q["state"][0], public["state"]) and
len(q.get("code", [])) == 1 and
re.fullmatch(r"[A-Za-z0-9_-]{43}", q["code"][0]))
if not valid:
self.send_error(400, "Invalid or expired authorization callback")
return
private_json(result_path, {"state": public["state"], "code": q["code"][0],
"redirect_uri": redirect_uri})
self.server.complete = True
body = ("<!doctype html><meta charset=utf-8><title>Lexmount authorization</title>"
"<h1>Authorization received</h1><p>Return to the same cloud task and attach the "
"lexmount-authorization-result JSON file saved next to the helper. "
"Do not paste its contents into chat.</p>").encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return Handler
def local_login(no_open=False):
public = PUBLIC_REQUEST
if not public or time.time() >= public["expires_at"]:
raise AuthError("request_expired", "Authorization request expired; ask the same cloud task for a new helper")
server = HTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler)
redirect_uri = "http://127.0.0.1:%s/callback" % server.server_port
result_path = Path(__file__).resolve().with_name(
"lexmount-authorization-result-" + public["state"][:8] + ".json")
server.RequestHandlerClass = callback_handler(public, redirect_uri, result_path)
server.complete = False
server.timeout = 1
url = CONNECT + "/connect/codex?" + parse.urlencode({
"source": "browser-cli", "intent": "agent-browser-control", "response": "code",
"expires_in": "7d", "scope": SCOPES, "redirect_uri": redirect_uri,
"state": public["state"], "code_challenge": public["challenge"],
"code_challenge_method": "S256", "client_name": public["client_name"]})
print(json.dumps({"status": "waiting_for_user_authorization", "authorization_url": url}), flush=True)
try:
if not no_open:
webbrowser.open(url)
while not server.complete and time.time() < public["expires_at"]:
server.handle_request()
if not server.complete:
raise AuthError("callback_timeout", "No valid callback received before expiry; request a new helper")
return {"status": "callback_received", "result_file": str(result_path),
"next": "Attach this JSON file to the same cloud task within 10 minutes; do not paste its contents."}
finally:
server.server_close()
class NoRedirect(request.HTTPRedirectHandler):
def redirect_request(self, *_):
return None
def exchange(payload):
req = request.Request(CONNECT + "/api/connect/codex/exchange",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Accept": "application/json"})
try:
with request.build_opener(NoRedirect).open(req, timeout=20) as response:
raw = response.read(32769)
if len(raw) > 32768:
raise ValueError("Authorization response is too large")
return json.loads(raw)
except error.HTTPError as exc:
raise AuthError("exchange_http_%s" % exc.code, "Official authorization exchange rejected (HTTP %s)" % exc.code) from None
except (error.URLError, TimeoutError):
raise AuthError("exchange_unreachable", "Official authorization exchange unavailable; check connectivity") from None
def extract_credentials(payload):
if not isinstance(payload, dict):
raise ValueError("Invalid authorization exchange response")
candidates = [payload] + [payload[k] for k in ("credential", "credentials", "token", "env")
if isinstance(payload.get(k), dict)]
for value in candidates:
project = value.get("project_id") or value.get("projectId") or value.get("LEXMOUNT_PROJECT_ID") or payload.get("project_id")
key = value.get("api_key") or value.get("apiKey") or value.get("LEXMOUNT_API_KEY") or payload.get("api_key")
if not isinstance(project, str) or not project or not isinstance(key, str) or not key:
continue
api = value.get("api_base_url") or value.get("apiBaseUrl") or payload.get("api_base_url") or API
if not isinstance(api, str) or api.rstrip("/") != API:
raise AuthError("unexpected_api_endpoint", "Authorization returned an unexpected API endpoint")
scopes = value.get("scopes") or value.get("scope") or payload.get("scopes") or SCOPES.split()
if isinstance(scopes, str):
scopes = scopes.split()
return {"kind": "api_key", "project_id": project, "api_key": key,
"api_base_url": API, "scopes": scopes,
"source": "connect_from_browser_cli", "connect_base_url": CONNECT}
raise ValueError("Official authorization response did not contain usable credentials")
def finish(state_dir, callback_file, exchange_fn=exchange):
state_dir = Path(state_dir)
if not (state_dir / "pending.json").is_file():
raise AuthError("request_consumed" if (state_dir / "credentials.json").is_file() else "request_missing",
"No pending request remains in this task; check status before starting another login")
if not Path(callback_file).is_file():
raise AuthError("callback_file_missing", "The authorization result attachment is not present at the supplied path")
lock = state_dir / "exchange.lock"
fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.close(fd)
try:
pending = load_json(state_dir / "pending.json")
result = load_json(callback_file)
if time.time() >= pending["expires_at"]:
raise AuthError("request_expired", "Authorization request expired; generate a new helper")
if not isinstance(result.get("state"), str) or not hmac.compare_digest(result["state"], pending["state"]):
raise AuthError("state_mismatch", "Authorization result does not belong to this task")
if not isinstance(result.get("code"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", result["code"]):
raise AuthError("invalid_code", "Authorization result has an invalid code format")
if not isinstance(result.get("redirect_uri"), str) or not valid_redirect(result["redirect_uri"]):
raise AuthError("invalid_callback", "Authorization result has an invalid callback address")
credentials = extract_credentials(exchange_fn({"code": result["code"],
"code_verifier": pending["verifier"], "redirect_uri": result["redirect_uri"]}))
path = state_dir / "credentials.json"
private_json(path, credentials)
(state_dir / "pending.json").unlink()
return {"status": "authenticated", "credentials_file": str(path.resolve()),
"api_key_redacted": True, "next": "Run official browser-cli doctor using this credentials file"}
finally:
lock.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", nargs="?", choices=["begin", "local", "finish", "status"],
default="local" if PUBLIC_REQUEST else None)
parser.add_argument("--output-dir")
parser.add_argument("--private-root", help="Private state directory in this task, outside the deliverable directory")
parser.add_argument("--client-name", default="ChatGPT Work")
parser.add_argument("--state-dir")
parser.add_argument("--callback-file")
parser.add_argument("--no-open", action="store_true")
args = parser.parse_args()
try:
if args.operation == "begin" and args.output_dir:
value = begin(args.output_dir, args.private_root, args.client_name)
elif args.operation == "local":
value = local_login(args.no_open)
elif args.operation == "finish" and args.state_dir and args.callback_file:
value = finish(args.state_dir, args.callback_file)
elif args.operation == "status" and args.state_dir:
value = status(args.state_dir)
else:
parser.error("begin requires --output-dir; finish requires --state-dir and --callback-file")
print(json.dumps(value, ensure_ascii=False))
except AuthError as exc:
print(json.dumps({"status": "authorization_failed", "error": exc.code, "message": str(exc)}))
raise SystemExit(1)
except (ValueError, OSError, KeyError, TypeError):
# File names and provider bodies can contain tokens; keep failures generic.
print(json.dumps({"status": "authorization_failed", "error": "invalid_data_or_io_error", "message":
"Authorization failed or expired. Verify the result belongs to this task; request a new helper if needed. No credentials were exposed."}))
raise SystemExit(1)
if __name__ == "__main__":
main()
SHA-256: 138b96dd80365335cf888813c943c7fcac9378cb2ecf72f29b7406637915663e