feat(playground): persist ChatGPT login through Moli

This commit is contained in:
ldm0
2026-08-22 03:57:20 +08:00
parent 227196a629
commit 1a11bbebac
4 changed files with 809 additions and 25 deletions
@@ -0,0 +1,579 @@
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import shutil
import signal
import subprocess
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from chatgpt_cdp_demo import DemoError, read_json_url_no_proxy, reserve_local_port
from chatgpt_playwright_core import (
PlaywrightChatGPTSession,
auth_blocking_reason_from_url,
)
Reporter = Callable[[str], None]
PROFILE_METADATA_FILE = ".chatgpt-playground-auth.json"
@dataclass(frozen=True)
class ChromiumAuthState:
cookies: list[dict[str, object]]
local_storage: list[list[str]]
user_agent: str
def default_moli_chatgpt_profile_dir() -> Path:
data_home = os.environ.get("XDG_DATA_HOME")
root = (
Path(data_home).expanduser() if data_home else Path.home() / ".local" / "share"
)
return root / "moli" / "chatgpt-profile"
def prepare_profile_dir(profile_dir: str | os.PathLike[str]) -> Path:
path = Path(profile_dir).expanduser().resolve()
path.mkdir(mode=0o700, parents=True, exist_ok=True)
path.chmod(0o700)
return path
def load_profile_user_agent(profile_dir: str | os.PathLike[str]) -> str:
path = Path(profile_dir).expanduser().resolve() / PROFILE_METADATA_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return ""
if not isinstance(payload, dict):
return ""
user_agent = payload.get("user_agent")
return user_agent if isinstance(user_agent, str) else ""
def save_profile_user_agent(
profile_dir: str | os.PathLike[str], user_agent: str
) -> None:
directory = prepare_profile_dir(profile_dir)
target = directory / PROFILE_METADATA_FILE
descriptor, temporary_name = tempfile.mkstemp(
prefix=f"{PROFILE_METADATA_FILE}.", dir=directory
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(
{"user_agent": user_agent}, handle, ensure_ascii=False, sort_keys=True
)
handle.write("\n")
temporary.replace(target)
target.chmod(0o600)
except BaseException:
try:
os.close(descriptor)
except OSError:
pass
temporary.unlink(missing_ok=True)
raise
def chromium_cookie_params(cookies: list[dict[str, Any]]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
for cookie in cookies:
domain = str(cookie.get("domain") or "")
if "chatgpt.com" not in domain and "openai.com" not in domain:
continue
item: dict[str, object] = {
"name": str(cookie.get("name") or ""),
"value": str(cookie.get("value") or ""),
"domain": domain,
"path": str(cookie.get("path") or "/"),
"secure": bool(cookie.get("secure")),
"httpOnly": bool(cookie.get("httpOnly")),
}
expires = cookie.get("expires")
if isinstance(expires, (int, float)) and expires > 0:
item["expires"] = expires
same_site = cookie.get("sameSite")
if same_site in {"Strict", "Lax", "None"}:
item["sameSite"] = same_site
result.append(item)
return result
def resolve_auth_chromium(args: argparse.Namespace) -> Path:
configured = str(
getattr(args, "auth_chromium_bin", "")
or getattr(args, "chromium_bin", "")
or os.environ.get("CHROMIUM_BIN")
or ""
)
if configured:
candidate = Path(configured).expanduser().resolve()
if candidate.is_file():
return candidate
raise DemoError(f"authentication Chromium binary does not exist: {candidate}")
for name in (
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
):
resolved = shutil.which(name)
if resolved:
return Path(resolved).resolve()
raise DemoError(
"Chromium is required for ChatGPT authentication; pass --auth-chromium-bin"
)
def resolve_xvfb() -> Path:
configured = os.environ.get("XVFB_BIN") or ""
if configured:
candidate = Path(configured).expanduser().resolve()
if candidate.is_file():
return candidate
raise DemoError(f"Xvfb binary does not exist: {candidate}")
resolved = shutil.which("Xvfb")
if resolved:
return Path(resolved).resolve()
raise DemoError("Xvfb is required for headful ChatGPT authentication")
def auth_proxy_options(args: argparse.Namespace) -> tuple[str, str]:
proxy = str(
getattr(args, "http_proxy", "")
or os.environ.get("HTTPS_PROXY")
or os.environ.get("https_proxy")
or os.environ.get("HTTP_PROXY")
or os.environ.get("http_proxy")
or ""
)
no_proxy = str(
getattr(args, "http_no_proxy", "")
or os.environ.get("NO_PROXY")
or os.environ.get("no_proxy")
or ""
)
return proxy, no_proxy
def terminate_process_group(process: subprocess.Popen[Any] | None) -> None:
if process is None or process.poll() is not None:
return
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=3)
def remove_auth_profile(path: Path | None) -> None:
if path is None or not path.exists():
return
resolved = path.resolve()
temporary_root = Path(tempfile.gettempdir()).resolve()
if resolved.parent != temporary_root or not resolved.name.startswith(
"moli-chatgpt-auth-chromium."
):
raise DemoError(
f"refusing to remove unexpected authentication profile: {resolved}"
)
shutil.rmtree(resolved)
def start_xvfb() -> tuple[subprocess.Popen[str], str]:
process = subprocess.Popen( # noqa: S603 - executable is resolved from an explicit trusted path.
[
str(resolve_xvfb()),
"-displayfd",
"1",
"-screen",
"0",
"1440x1000x24",
"-nolisten",
"tcp",
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
start_new_session=True,
)
if process.stdout is None:
terminate_process_group(process)
raise DemoError("Xvfb stdout pipe is unavailable")
display_number = process.stdout.readline().strip()
if not display_number or process.poll() is not None:
terminate_process_group(process)
raise DemoError("Xvfb did not allocate a display for ChatGPT authentication")
return process, f":{display_number}"
async def wait_for_chromium_cdp(
endpoint: str, process: subprocess.Popen[bytes], timeout: float
) -> str:
deadline = time.monotonic() + timeout
last_error: BaseException | None = None
while time.monotonic() < deadline:
if process.poll() is not None:
return_code = process.returncode
raise DemoError(
f"authentication Chromium exited before CDP startup: rc={return_code}"
)
try:
version = await asyncio.to_thread(
read_json_url_no_proxy, endpoint + "/json/version"
)
websocket_url = version.get("webSocketDebuggerUrl")
if isinstance(websocket_url, str) and websocket_url:
return websocket_url
except BaseException as error: # noqa: BLE001 - report the final startup error.
last_error = error
await asyncio.sleep(0.1)
raise DemoError(
"timed out waiting for authentication Chromium CDP; "
f"last_error={type(last_error).__name__ if last_error else 'none'}"
)
async def click_visible_text(page: Any, patterns: tuple[str, ...]) -> bool:
interactive = page.locator("button, [role='button'], a, label")
for pattern in patterns:
expression = re.compile(pattern, re.IGNORECASE)
for candidates in (
interactive.filter(has_text=expression),
page.get_by_text(expression),
):
for index in range(min(await candidates.count(), 12)):
candidate = candidates.nth(index)
try:
if await candidate.is_visible(
timeout=500
) and await candidate.is_enabled(timeout=500):
await candidate.click(timeout=5000)
return True
except Exception: # noqa: BLE001, S112 - probe the next visible candidate.
continue
return False
async def select_push_mfa(
session: PlaywrightChatGPTSession, reporter: Reporter
) -> None:
if session.page is None:
raise DemoError("MFA page is unavailable")
push_patterns = (
r"push notification",
r"chatgpt app",
r"approve.*device",
r"push",
)
clicked = await click_visible_text(session.page, push_patterns)
if not clicked:
expanded = await click_visible_text(
session.page,
(
r"try another method",
r"use another method",
r"different method",
r"other (?:options|methods)",
),
)
if expanded:
reporter("expand MFA methods")
await asyncio.sleep(1)
clicked = await click_visible_text(session.page, push_patterns)
if not clicked:
raise DemoError("Push approval is not available on the ChatGPT MFA page")
reporter("select Push approval")
await asyncio.sleep(1)
await click_visible_text(
session.page, (r"^continue$", r"send push", r"send notification")
)
async def submit_external_login_once(
session: PlaywrightChatGPTSession,
email: str,
password: str,
) -> None:
state = await session.navigate_to_initial_login_state()
if state.get("loggedIn"):
return
state = await session.navigate_to_login_form()
if state.get("blockingReason"):
raise DemoError(
f"login blocked before credentials: {state.get('blockingReason')}"
)
if state.get("hasEmailInput"):
session.report("fill email in authentication Chromium")
await session.accept_cookie_consent()
await session.fill_email_native(email)
state = await session.wait_for_state_after_email_submit(
timeout=60, label="password form"
)
if state.get("loggedIn"):
return
if not state.get("hasPasswordInput"):
raise DemoError(
f"password input did not appear; blocking={state.get('blockingReason')!r}"
)
await session.wait_for_auth_password_runtime_ready(timeout=20)
session.report("submit password once in authentication Chromium")
await session.fill_password_native(password)
async def wait_for_external_login(
session: PlaywrightChatGPTSession,
args: argparse.Namespace,
reporter: Reporter,
) -> None:
deadline = time.monotonic() + max(1.0, float(args.login_timeout))
selected_push = False
tried_email_verification = False
reported_device_approval = False
while time.monotonic() < deadline:
try:
state = await session.helper("loginState", timeout=15)
except Exception: # noqa: BLE001 - navigation can invalidate the helper realm.
await asyncio.sleep(0.5)
continue
if state.get("loggedIn"):
reporter("authentication approved")
return
reason = state.get("blockingReason") or auth_blocking_reason_from_url(
str(state.get("url") or "")
)
if reason == "mfa" and not selected_push:
await select_push_mfa(session, reporter)
selected_push = True
await asyncio.sleep(1)
continue
if reason == "device-approval":
if (
bool(getattr(args, "try_email_verification", False))
and not tried_email_verification
):
tried_email_verification = True
if await session.click_try_with_email_verification():
await asyncio.sleep(1)
continue
if not reported_device_approval:
reporter("approve the new login in the ChatGPT app")
reported_device_approval = True
await asyncio.sleep(1)
continue
if reason in {"email-verification", "verification-code"}:
if not session.has_auth_code_source():
raise DemoError(
"ChatGPT requested an email code but no auth-code input "
"is configured"
)
code = await session.read_auth_code()
reporter("submit ChatGPT email verification code")
await session.fill_auth_code_native(code)
session._auth_code_cache = None
await asyncio.sleep(1.5)
continue
if reason:
raise DemoError(
f"ChatGPT authentication reached an unsupported step: {reason}"
)
await asyncio.sleep(0.5)
if reported_device_approval:
raise DemoError("timed out waiting for ChatGPT device approval")
raise DemoError("timed out waiting for ChatGPT authentication")
async def export_auth_state(session: PlaywrightChatGPTSession) -> ChromiumAuthState:
if session.page is None or session.context is None:
raise DemoError("authentication Chromium page is unavailable")
if not session.page.url.startswith("https://chatgpt.com/"):
await session.page.goto(
"https://chatgpt.com/", wait_until="domcontentloaded", timeout=120_000
)
await session.wait_for_document_settle(timeout=10)
cookies = chromium_cookie_params(await session.context.cookies())
local_storage = await session.page.evaluate("Object.entries(localStorage)")
user_agent = await session.page.evaluate("navigator.userAgent")
if not isinstance(local_storage, list) or not all(
isinstance(entry, list)
and len(entry) == 2
and all(isinstance(value, str) for value in entry)
for entry in local_storage
):
raise DemoError("authentication Chromium returned invalid localStorage state")
if not isinstance(user_agent, str) or not user_agent:
raise DemoError("authentication Chromium returned an empty user agent")
if not cookies:
raise DemoError("authentication Chromium returned no ChatGPT cookies")
return ChromiumAuthState(
cookies=cookies, local_storage=local_storage, user_agent=user_agent
)
async def authenticate_in_external_chromium(
args: argparse.Namespace,
email: str,
password: str,
reporter: Reporter,
) -> ChromiumAuthState:
from playwright.async_api import async_playwright
profile: Path | None = None
xvfb: subprocess.Popen[str] | None = None
chromium: subprocess.Popen[bytes] | None = None
session: PlaywrightChatGPTSession | None = None
try:
profile = Path(tempfile.mkdtemp(prefix="moli-chatgpt-auth-chromium."))
profile.chmod(0o700)
xvfb, display = start_xvfb()
port = reserve_local_port()
endpoint = f"http://127.0.0.1:{port}"
command = [
str(resolve_auth_chromium(args)),
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
"--remote-allow-origins=*",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--password-store=basic",
"--window-size=1440,1000",
]
proxy, no_proxy = auth_proxy_options(args)
if proxy:
command.append(f"--proxy-server={proxy}")
if no_proxy:
command.append(f"--proxy-bypass-list={no_proxy.replace(',', ';')}")
if hasattr(os, "geteuid") and os.geteuid() == 0:
command.append("--no-sandbox")
command.append("about:blank")
environment = os.environ.copy()
environment["DISPLAY"] = display
reporter("start authentication Chromium under Xvfb")
chromium = subprocess.Popen( # noqa: ASYNC220, S603 - trusted binary; explicit process-group cleanup.
command,
env=environment,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
websocket_url = await wait_for_chromium_cdp(
endpoint, chromium, float(args.startup_timeout)
)
playwright = await async_playwright().start()
auth_args = argparse.Namespace(**vars(args))
auth_args.backend = "chromium"
session = PlaywrightChatGPTSession(auth_args, reporter=reporter)
session.playwright = playwright
session.browser = await playwright.chromium.connect_over_cdp(websocket_url)
session.context = session.browser.contexts[0]
await session.finish_page_setup()
if session.page is None:
raise DemoError("authentication Chromium did not expose a page target")
webdriver = await session.page.evaluate("navigator.webdriver")
if webdriver:
raise DemoError(
"authentication Chromium unexpectedly exposes navigator.webdriver"
)
await submit_external_login_once(session, email, password)
await wait_for_external_login(session, auth_args, reporter)
state = await export_auth_state(session)
reporter(f"captured ChatGPT session ({len(state.cookies)} cookies)")
return state
finally:
if session is not None:
await session.close()
terminate_process_group(chromium)
terminate_process_group(xvfb)
remove_auth_profile(profile)
async def import_auth_state(
session: PlaywrightChatGPTSession,
state: ChromiumAuthState,
) -> None:
if session.page is None or session.context is None:
raise DemoError("Moli page is unavailable for authentication import")
cdp = await session.context.new_cdp_session(session.page)
await cdp.send("Network.enable")
await cdp.send("Network.setCookies", {"cookies": state.cookies})
await session.page.goto(
"https://chatgpt.com/", wait_until="domcontentloaded", timeout=180_000
)
await session.page.evaluate(
"""entries => {
for (const [key, value] of entries) localStorage.setItem(key, value);
}""",
state.local_storage,
)
await session.page.reload(wait_until="domcontentloaded", timeout=180_000)
await session.require_existing_session()
async def create_authenticated_moli_session(
args: argparse.Namespace,
email: str,
password: str,
reporter: Reporter = print,
) -> PlaywrightChatGPTSession:
profile_dir = str(getattr(args, "profile_dir", "") or "")
if profile_dir:
prepare_profile_dir(profile_dir)
if not getattr(args, "user_agent", None):
stored_user_agent = load_profile_user_agent(profile_dir)
if stored_user_agent:
args.user_agent = stored_user_agent
existing = PlaywrightChatGPTSession(args, reporter=reporter)
try:
await existing.start()
except BaseException:
await existing.close()
raise
try:
await existing.require_existing_session()
except DemoError:
await existing.close()
reporter("Moli profile needs ChatGPT authentication")
else:
reporter("reuse logged-in Moli profile")
return existing
if not password:
raise DemoError(
"password is required because the Moli profile is not logged in"
)
state = await authenticate_in_external_chromium(args, email, password, reporter)
if not getattr(args, "user_agent", None):
args.user_agent = state.user_agent
authenticated = PlaywrightChatGPTSession(args, reporter=reporter)
try:
await authenticated.start()
await import_auth_state(authenticated, state)
if profile_dir:
save_profile_user_agent(profile_dir, state.user_agent)
reporter("ChatGPT authentication imported into Moli")
return authenticated
except BaseException:
await authenticated.close()
raise
+5 -3
View File
@@ -73,6 +73,7 @@ class TextField:
@dataclass
class TuiState:
fields: dict[str, TextField]
title: str = "Moli ChatGPT CDP TUI"
focus: str = "email"
logged_in: bool = False
busy: bool = False
@@ -290,7 +291,7 @@ def draw(stdscr: Any, state: TuiState, colors: dict[str, int]) -> None:
stdscr.refresh()
return
safe_add(stdscr, 0, 2, "Moli ChatGPT CDP TUI", colors["title"])
safe_add(stdscr, 0, 2, state.title, colors["title"])
safe_add(stdscr, 1, 2, state.status[: width - 4], colors["dim"] if not state.busy else 0)
safe_add(stdscr, 2, 0, "-" * (width - 1))
@@ -388,7 +389,7 @@ def submit_current(state: TuiState, backend: Backend) -> None:
state.status = "email is required"
add_message(state, "error", state.status)
return
if not password:
if not password and bool(getattr(backend, "requires_password", True)):
state.status = "password is required"
add_message(state, "error", state.status)
return
@@ -424,7 +425,8 @@ def run_tui_with_backend(stdscr: Any, args: argparse.Namespace, backend_cls: Any
"password": TextField("password", "Password", os.environ.get("CHATGPT_PASSWORD", ""), secret=True),
"auth_code": TextField("auth_code", "Auth Code"),
"prompt": TextField("prompt", "Prompt", args.prompt),
}
},
title=str(getattr(args, "tui_title", "Moli ChatGPT CDP TUI")),
)
backend = backend_cls(args, events)
try:
+104 -22
View File
@@ -7,12 +7,15 @@ import curses
import os
import queue
import threading
from typing import Any
from chatgpt_auth_bridge import (
create_authenticated_moli_session,
default_moli_chatgpt_profile_dir,
)
from chatgpt_cdp_demo import (
DEFAULT_ANSWER_TIMEOUT,
DEFAULT_MOLI_HTTP_TIMEOUT_MS,
DEFAULT_LOGIN_TIMEOUT,
DEFAULT_MOLI_HTTP_TIMEOUT_MS,
DEFAULT_STARTUP_TIMEOUT,
DEFAULT_URL,
)
@@ -20,12 +23,19 @@ from chatgpt_cdp_tui import UiEvent, error_text, run_tui_with_backend
from chatgpt_playwright_core import PlaywrightChatGPTSession
TUI_LOGIN_TIMEOUT = max(300.0, DEFAULT_LOGIN_TIMEOUT)
TUI_TRY_EMAIL_VERIFICATION = True
TUI_TRY_EMAIL_VERIFICATION = False
class PlaywrightBackend:
def __init__(self, args: argparse.Namespace, events: "queue.Queue[UiEvent]") -> None:
requires_password = False
def __init__(
self, args: argparse.Namespace, events: "queue.Queue[UiEvent]"
) -> None:
self.args = args
self.requires_password = not (
args.backend == "moli" and args.auth_backend == "chromium-bridge"
)
self.events = events
self.auth_codes: "queue.Queue[str]" = queue.Queue()
self.loop = asyncio.new_event_loop()
@@ -60,9 +70,24 @@ class PlaywrightBackend:
async def _login(self, email: str, password: str) -> None:
try:
self.args.auth_code_provider = self.read_auth_code_from_tui
self.session = PlaywrightChatGPTSession(self.args, reporter=lambda message: self.emit("status", message))
await self.session.start()
await self.session.login(email, password)
def reporter(message: str) -> None:
self.emit("status", message)
if (
self.args.backend == "moli"
and self.args.auth_backend == "chromium-bridge"
):
self.session = await create_authenticated_moli_session(
self.args,
email,
password,
reporter=reporter,
)
else:
self.session = PlaywrightChatGPTSession(self.args, reporter=reporter)
await self.session.start()
await self.session.login(email, password)
self.emit("login_ok", "login ok")
except Exception as error: # noqa: BLE001 - show operational failures in the TUI.
await self._cleanup()
@@ -102,18 +127,39 @@ class PlaywrightBackend:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Interactive Playwright TUI for ChatGPT over Moli CDP.")
parser.add_argument("--backend", choices=("moli", "chromium"), default="moli", help="browser backend, default: moli")
parser.add_argument("--url", default=DEFAULT_URL, help=f"initial URL, default: {DEFAULT_URL}")
parser.add_argument("--email", default=os.environ.get("CHATGPT_EMAIL", ""), help="pre-fill email")
parser = argparse.ArgumentParser(
description="Interactive Playwright TUI for ChatGPT over Moli CDP."
)
parser.add_argument(
"--backend",
choices=("moli", "chromium"),
default="moli",
help="browser backend, default: moli",
)
parser.add_argument(
"--auth-backend",
choices=("chromium-bridge", "moli"),
default="chromium-bridge",
help="fresh-login backend when chatting through Moli, default: chromium-bridge",
)
parser.add_argument(
"--url", default=DEFAULT_URL, help=f"initial URL, default: {DEFAULT_URL}"
)
parser.add_argument(
"--email", default=os.environ.get("CHATGPT_EMAIL", ""), help="pre-fill email"
)
parser.add_argument("--prompt", default="", help="pre-fill the prompt input")
parser.add_argument("--auth-code", default=os.environ.get("CHATGPT_AUTH_CODE", ""), help="verification code for email auth fallback")
parser.add_argument(
"--auth-code",
default=os.environ.get("CHATGPT_AUTH_CODE", ""),
help="verification code for email auth fallback",
)
parser.add_argument(
"--try-email-verification",
dest="try_email_verification",
action="store_true",
default=TUI_TRY_EMAIL_VERIFICATION,
help="click Try with email on device approval pages, default: enabled",
help="click Try with email on device approval pages, default: disabled",
)
parser.add_argument(
"--no-try-email-verification",
@@ -135,30 +181,66 @@ def build_parser() -> argparse.ArgumentParser:
"--live-trace-output",
help="append sanitized live-trace summaries as JSON Lines to this path",
)
parser.add_argument("--moli-bin", help="path to the moli binary; alternatively set MOLI_BIN")
parser.add_argument("--chromium-bin", help="path to a Chromium/Chrome executable for --backend chromium")
parser.add_argument("--headful", action="store_true", help="run Chromium with a visible browser window")
parser.add_argument("--profile-dir", help="optional Moli profile dir for cookies/localStorage")
parser.add_argument(
"--moli-bin", help="path to the moli binary; alternatively set MOLI_BIN"
)
parser.add_argument(
"--chromium-bin",
help="path to a Chromium/Chrome executable for --backend chromium",
)
parser.add_argument(
"--auth-chromium-bin",
help="Chromium/Chrome executable used only for Moli login bootstrap",
)
parser.add_argument(
"--headful",
action="store_true",
help="run Chromium with a visible browser window",
)
parser.add_argument(
"--profile-dir",
default=os.environ.get(
"MOLI_CHATGPT_PROFILE_DIR", str(default_moli_chatgpt_profile_dir())
),
help="persistent Moli login profile, default: $MOLI_CHATGPT_PROFILE_DIR or the user data directory",
)
parser.add_argument("--user-agent", help="optional user agent passed to moli serve")
parser.add_argument("--http-proxy", help="optional proxy passed to moli serve")
parser.add_argument("--http-no-proxy", help="optional no-proxy list passed to moli serve")
parser.add_argument(
"--http-no-proxy", help="optional no-proxy list passed to moli serve"
)
parser.add_argument(
"--http-timeout",
type=int,
default=DEFAULT_MOLI_HTTP_TIMEOUT_MS,
help=f"Moli request timeout in milliseconds, default: {DEFAULT_MOLI_HTTP_TIMEOUT_MS}",
)
parser.add_argument("--http-max-concurrent", type=int, help="optional max active fetch transfers passed to moli serve")
parser.add_argument("--http-max-host-open", type=int, help="optional per-host fetch transfer cap passed to moli serve")
parser.add_argument("--startup-timeout", type=float, default=DEFAULT_STARTUP_TIMEOUT)
parser.add_argument(
"--http-max-concurrent",
type=int,
help="optional max active fetch transfers passed to moli serve",
)
parser.add_argument(
"--http-max-host-open",
type=int,
help="optional per-host fetch transfer cap passed to moli serve",
)
parser.add_argument(
"--startup-timeout", type=float, default=DEFAULT_STARTUP_TIMEOUT
)
parser.add_argument("--login-timeout", type=float, default=TUI_LOGIN_TIMEOUT)
parser.add_argument("--answer-timeout", type=float, default=DEFAULT_ANSWER_TIMEOUT)
parser.add_argument("--debug-snapshot", action="store_true", help="accepted for parity with the raw-CDP TUI")
parser.add_argument(
"--debug-snapshot",
action="store_true",
help="accepted for parity with the raw-CDP TUI",
)
return parser
def main() -> int:
args = build_parser().parse_args()
args.tui_title = "Moli ChatGPT Playwright TUI"
curses.wrapper(run_tui_with_backend, args, PlaywrightBackend)
return 0
@@ -0,0 +1,121 @@
from __future__ import annotations
import os
import stat
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from chatgpt_auth_bridge import (
auth_proxy_options,
chromium_cookie_params,
default_moli_chatgpt_profile_dir,
load_profile_user_agent,
prepare_profile_dir,
save_profile_user_agent,
)
from chatgpt_cdp_demo import CHATGPT_HELPER_JS
from chatgpt_playwright_tui import PlaywrightBackend, build_parser
class ChatGPTAuthBridgeTests(unittest.TestCase):
def test_default_profile_uses_xdg_data_home(self) -> None:
with mock.patch.dict(
os.environ, {"XDG_DATA_HOME": "/tmp/moli-xdg-data"}, clear=False
):
self.assertEqual(
default_moli_chatgpt_profile_dir(),
Path("/tmp/moli-xdg-data/moli/chatgpt-profile"),
)
def test_profile_metadata_round_trips_with_private_permissions(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
profile = Path(temporary) / "profile"
prepared = prepare_profile_dir(profile)
save_profile_user_agent(prepared, "Test Browser/1.0")
self.assertEqual(load_profile_user_agent(prepared), "Test Browser/1.0")
self.assertEqual(stat.S_IMODE(prepared.stat().st_mode), 0o700)
self.assertEqual(
stat.S_IMODE(
(prepared / ".chatgpt-playground-auth.json").stat().st_mode
),
0o600,
)
def test_cookie_conversion_keeps_only_chatgpt_auth_domains(self) -> None:
converted = chromium_cookie_params(
[
{
"name": "session",
"value": "secret",
"domain": ".chatgpt.com",
"path": "/",
"expires": 12345.0,
"httpOnly": True,
"secure": True,
"sameSite": "Lax",
},
{
"name": "auth",
"value": "secret-2",
"domain": "auth.openai.com",
"path": "/",
"expires": -1,
"httpOnly": True,
"secure": True,
"sameSite": "None",
},
{
"name": "unrelated",
"value": "ignored",
"domain": "example.com",
"path": "/",
},
]
)
self.assertEqual([cookie["name"] for cookie in converted], ["session", "auth"])
self.assertEqual(converted[0]["expires"], 12345.0)
self.assertNotIn("expires", converted[1])
def test_auth_chromium_inherits_proxy_environment(self) -> None:
args = mock.Mock(http_proxy=None, http_no_proxy=None)
with mock.patch.dict(
os.environ,
{
"HTTPS_PROXY": "http://proxy.example:8080",
"NO_PROXY": "localhost,127.0.0.1",
},
clear=True,
):
self.assertEqual(
auth_proxy_options(args),
("http://proxy.example:8080", "localhost,127.0.0.1"),
)
def test_helper_ignores_google_hidden_password_and_avoids_double_submit(
self,
) -> None:
self.assertIn("hiddenpassword", CHATGPT_HELPER_JS)
self.assertIn("style?.display === 'none'", CHATGPT_HELPER_JS)
self.assertIn(
"return { ok: true, reactSubmit: true, clickedSubmit: false };",
CHATGPT_HELPER_JS,
)
def test_playwright_tui_defaults_to_bridge_and_persistent_profile(self) -> None:
with mock.patch.dict(
os.environ, {"XDG_DATA_HOME": "/tmp/moli-tui-data"}, clear=False
):
args = build_parser().parse_args([])
self.assertEqual(args.auth_backend, "chromium-bridge")
self.assertEqual(args.profile_dir, "/tmp/moli-tui-data/moli/chatgpt-profile")
self.assertFalse(args.try_email_verification)
self.assertFalse(PlaywrightBackend.requires_password)
if __name__ == "__main__":
unittest.main()