From e4f7e53ebb149efe2be9aaa6e5831f27374c4fc1 Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Fri, 21 Aug 2026 15:18:45 -0700 Subject: [PATCH] Provider: ChatGPT-subscription sign-in (OAuth PKCE, tokens local-only) Browser sign-in on the registered loopback port; tokens in the SecretStore profile. Rides the Responses provider for conversion/streaming; refresh-on-expiry and on-401. REST: signin (background + status poll), status, signout; oauth rows in providers list. --- coworker/providers/__init__.py | 2 + coworker/providers/codex_auth.py | 472 ++++++++++++++++++++++ coworker/providers/codex_provider.py | 128 ++++++ coworker/providers/matrix.py | 27 ++ coworker/providers/registry.py | 31 ++ coworker/server/app.py | 18 + coworker/server/manager.py | 100 ++++- tests/test_codex_provider.py | 562 +++++++++++++++++++++++++++ 8 files changed, 1325 insertions(+), 15 deletions(-) create mode 100644 coworker/providers/codex_auth.py create mode 100644 coworker/providers/codex_provider.py create mode 100644 tests/test_codex_provider.py diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py index 5245a15a..d38da557 100644 --- a/coworker/providers/__init__.py +++ b/coworker/providers/__init__.py @@ -8,6 +8,7 @@ from .base import ( ToolCall, ) from .capabilities import capabilities_for +from .codex_provider import CodexProvider from .gemini_provider import GeminiProvider from .openai_provider import OpenAIProvider, resolve_api_key from .openai_responses import OpenAIResponsesProvider @@ -33,6 +34,7 @@ __all__ = [ "ToolCall", "AnthropicProvider", "BedrockProvider", + "CodexProvider", "GeminiProvider", "OpenAIProvider", "OpenAIResponsesProvider", diff --git a/coworker/providers/codex_auth.py b/coworker/providers/codex_auth.py new file mode 100644 index 00000000..681a6d9f --- /dev/null +++ b/coworker/providers/codex_auth.py @@ -0,0 +1,472 @@ +"""Subscription sign-in for the `openai-codex` provider (OAuth 2.0 + PKCE). + +Instead of an API key, the user signs in with their ChatGPT plan: a browser flow +against the vendor's auth service using their public subscription client id, with the +loopback redirect that id is registered for (the port is FIXED — any other port fails +the redirect-uri check server-side). Tokens land in the SecretStore profile +`provider:openai-codex` — the same local-only storage every provider profile uses, +never a plaintext config file — mirroring `mcp/oauth.py` (tokens + `tokens_issued_at`). + +The pieces: + + - `sign_in()` — async, explicit-action only: bind the loopback port, open the + browser, wait for the redirect, exchange the code, persist tokens + account id. + - `CodexTokenStore` — persistence + proactive refresh (JWT `exp`, sync httpx: the + provider is called from engine worker threads, `asyncio.to_thread` like its peers). + - `verify()` — the Test-button probe: one cheap authenticated request that + distinguishes signed-out vs expired vs OK. + +The account id rides the token JWTs (the `https://api.openai.com/auth` claim); we +decode without verification — the backend verifies the token, we only route with it. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import secrets as pysecrets +import time +import uuid +from typing import Any, Optional +from urllib.parse import parse_qs, urlencode, urlsplit + +logger = logging.getLogger(__name__) + +AUTH_ISSUER = "https://auth.openai.com" +AUTHORIZE_URL = AUTH_ISSUER + "/oauth/authorize" +TOKEN_URL = AUTH_ISSUER + "/oauth/token" +# The public subscription client id (ships in the vendor's own tooling — not a secret). +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CALLBACK_PORT = 1455 +CALLBACK_PATH = "/auth/callback" +# Registered redirect for CLIENT_ID, verbatim — host and port are not ours to choose. +REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}" +SCOPE = "openid profile email offline_access" +ORIGINATOR = "openworker" +CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +PROFILE = "provider:openai-codex" +FLOW_TIMEOUT_SECONDS = 300 +# Refresh this close to the JWT `exp` instead of sending an about-to-die bearer. +REFRESH_MARGIN_SECONDS = 300 +_ACCOUNT_CLAIM = "https://api.openai.com/auth" +# Smallest curated model — the verify probe should cost as close to nothing as possible. +_VERIFY_MODEL = "gpt-5.1-codex-mini" + +SIGNED_OUT_ERROR = ( + "Not signed in to ChatGPT — connect your account in Settings ▸ Models to use " + "the subscription provider." +) +EXPIRED_ERROR = "ChatGPT session expired — sign in again in Settings ▸ Models." +PLAN_LIMIT_ERROR = ( + "ChatGPT plan limit reached — your subscription's rolling usage window (about " + "5 hours) is used up. Wait for it to reset, upgrade the plan, or switch to an " + "API-key provider." +) +PORT_BUSY_ERROR = ( + f"Port {CALLBACK_PORT} is already in use — the OpenAI Codex CLI is the usual " + "holder. Quit it and start the sign-in again." +) + + +class CodexAuthError(RuntimeError): + """A subscription-auth failure with a user-readable message.""" + + +class CodexSignInRequired(CodexAuthError): + """No usable tokens — the fix is an explicit sign-in, never a silent browser.""" + + +# -- PKCE / JWT helpers ----------------------------------------------------------- + + +def create_pkce() -> tuple[str, str]: + """(verifier, S256 challenge) per RFC 7636.""" + verifier = pysecrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def build_authorize_url(state: str, challenge: str) -> str: + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": SCOPE, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + # The simplified-flow switch the subscription client id expects, plus the + # client-name tag the backend requires on every call. + "codex_cli_simplified_flow": "true", + "originator": ORIGINATOR, + } + return AUTHORIZE_URL + "?" + urlencode(params) + + +def _jwt_claims(token: str) -> dict[str, Any]: + """Decode a JWT payload WITHOUT verification — we only read routing claims + (`exp`, the account object); the backend is the one verifying signatures.""" + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii"))) + return claims if isinstance(claims, dict) else {} + except Exception: + return {} + + +def account_id_from(tokens: dict[str, Any]) -> str: + """The ChatGPT account id, from the auth claim of the id/access token.""" + for key in ("id_token", "access_token"): + auth = _jwt_claims(tokens.get(key) or "").get(_ACCOUNT_CLAIM) or {} + if isinstance(auth, dict): + acct = auth.get("chatgpt_account_id") or auth.get("account_id") or "" + if acct: + return str(acct) + return "" + + +def backend_headers(account_id: str, session_id: str) -> dict[str, str]: + """The non-auth headers every backend request must carry (auth is the bearer).""" + return { + "chatgpt-account-id": account_id, + "originator": ORIGINATOR, + "OpenAI-Beta": "responses=experimental", + "session-id": session_id, + } + + +# -- token persistence + refresh ---------------------------------------------------- + + +def _token_post(data: dict[str, str], timeout: float = 30.0) -> Any: + """One POST to the token endpoint (module-level so tests stub the wire here).""" + import httpx + + return httpx.post( + TOKEN_URL, data=data, headers={"Accept": "application/json"}, timeout=timeout + ) + + +def exchange_code(code: str, verifier: str, timeout: float = 30.0) -> dict[str, Any]: + """authorization_code + PKCE verifier → the token set. Blocking (httpx sync); + `sign_in` runs it via `asyncio.to_thread`.""" + resp = _token_post( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "code_verifier": verifier, + }, + timeout, + ) + if resp.status_code >= 300: + raise CodexAuthError( + f"Sign-in failed — token exchange returned HTTP {resp.status_code}." + ) + return resp.json() + + +class CodexTokenStore: + """Token set + account metadata in the `provider:openai-codex` SecretStore profile. + + `access_token()` is what the provider calls per request: it hands back a live + bearer, refreshing proactively near the JWT `exp` and clearing the profile to a + clean signed-out state when the refresh token is rejected — never a crash loop. + """ + + def __init__(self, secrets: Any) -> None: + self._secrets = secrets + + def _data(self) -> dict[str, Any]: + if self._secrets is None: + return {} + return self._secrets.get(PROFILE) or {} + + def _merge(self, patch: dict[str, Any]) -> None: + self._secrets.put(PROFILE, {**self._data(), **patch}) + + def signed_in(self) -> bool: + return bool(self._data().get("tokens")) + + def account_label(self) -> Optional[str]: + data = self._data() + return data.get("account_email") or data.get("account_id") or None + + def save(self, tokens: dict[str, Any]) -> None: + """Persist a token response, keeping prior values a refresh omitted (the + refresh grant often returns no new refresh/id token).""" + existing = self._data().get("tokens") or {} + merged = { + k: (tokens.get(k) or existing.get(k)) + for k in ("access_token", "refresh_token", "id_token") + } + merged = {k: v for k, v in merged.items() if v} + patch: dict[str, Any] = { + "tokens": merged, + "tokens_issued_at": int(time.time()), + } + account_id = account_id_from(merged) or self._data().get("account_id") + if account_id: + patch["account_id"] = account_id + email = _jwt_claims(merged.get("id_token") or "").get("email") or self._data().get( + "account_email" + ) + if email: + patch["account_email"] = email + self._merge(patch) + + def clear(self) -> bool: + if self._secrets is None: + return False + return bool(self._secrets.delete(PROFILE)) + + def access_token(self) -> tuple[str, str]: + """(live access token, account id) — refreshing first when stale/absent.""" + data = self._data() + tokens = data.get("tokens") or {} + access = tokens.get("access_token") or "" + if not access and not tokens.get("refresh_token"): + raise CodexSignInRequired(SIGNED_OUT_ERROR) + exp = _jwt_claims(access).get("exp") + stale = not access or ( + isinstance(exp, (int, float)) and exp - time.time() < REFRESH_MARGIN_SECONDS + ) + if stale: + return self.refresh() + return access, data.get("account_id") or "" + + def refresh(self) -> tuple[str, str]: + """refresh_token grant → fresh (access token, account id). A rejected refresh + token blanks the profile — the provider reads as cleanly signed out.""" + refresh = (self._data().get("tokens") or {}).get("refresh_token") or "" + if not refresh: + self.clear() + raise CodexSignInRequired(EXPIRED_ERROR) + try: + resp = _token_post( + { + "grant_type": "refresh_token", + "refresh_token": refresh, + "client_id": CLIENT_ID, + } + ) + except Exception as exc: + raise CodexAuthError( + "Couldn't reach the sign-in service to refresh the ChatGPT session " + f"({exc.__class__.__name__})." + ) from exc + if 400 <= resp.status_code < 500: + self.clear() + raise CodexSignInRequired(EXPIRED_ERROR) + if resp.status_code >= 300: + raise CodexAuthError( + f"ChatGPT session refresh failed (HTTP {resp.status_code}) — try again." + ) + self.save(resp.json()) + data = self._data() + return (data.get("tokens") or {}).get("access_token") or "", ( + data.get("account_id") or "" + ) + + +# -- interactive sign-in flow ------------------------------------------------------- + +# The last authorize URL, surfaced over REST so the GUI can offer "reopen sign-in +# page" if the popup was lost (same affordance as mcp/oauth.py). +last_authorize_url: Optional[str] = None +_active_server: Optional[asyncio.AbstractServer] = None + +_PAGE = """OpenWorker + +

{title}

{body}

""" + + +def _http_response(status: str, title: str, body: str) -> bytes: + html = _PAGE.format(title=title, body=body).encode("utf-8") + head = ( + f"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n" + f"Content-Length: {len(html)}\r\nConnection: close\r\n\r\n" + ) + return head.encode("ascii") + html + + +async def _start_callback_server( + expected_state: str, +) -> tuple[asyncio.AbstractServer, "asyncio.Future[str]"]: + """Bind the fixed loopback port and resolve the future with the auth code when + the redirect (carrying the matching `state`) lands.""" + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + + async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + request_line = await reader.readline() + while True: # drain headers; the redirect is a bare GET + line = await reader.readline() + if line in (b"\r\n", b"\n", b""): + break + parts = request_line.decode("ascii", errors="replace").split() + target = urlsplit(parts[1] if len(parts) > 1 else "/") + if target.path != CALLBACK_PATH: + writer.write(_http_response("404 Not Found", "Not found", "")) + return + query = parse_qs(target.query) + error = (query.get("error") or [""])[0] + code = (query.get("code") or [""])[0] + state = (query.get("state") or [""])[0] + if error: + writer.write( + _http_response( + "400 Bad Request", + "Sign-in failed", + "The service reported an error. Return to OpenWorker and try again.", + ) + ) + if not future.done(): + future.set_exception( + CodexAuthError(f"Sign-in failed — the service returned: {error}") + ) + return + # Same loopback gate as mcp/oauth.py: a stray local hit with the wrong + # state must not consume the flow — only the genuine redirect resolves it. + if not code or not pysecrets.compare_digest(state, expected_state): + writer.write( + _http_response( + "400 Bad Request", + "Nothing waiting for this sign-in", + "The sign-in may have timed out. Return to OpenWorker and start it again.", + ) + ) + return + writer.write( + _http_response( + "200 OK", + "Signed in", + "You can close this tab and return to OpenWorker.", + ) + ) + if not future.done(): + future.set_result(code) + finally: + try: + await writer.drain() + writer.close() + except Exception: + pass + + try: + server = await asyncio.start_server(handle, "127.0.0.1", CALLBACK_PORT) + except OSError as exc: + raise CodexAuthError(PORT_BUSY_ERROR) from exc + return server, future + + +async def sign_in( + secrets: Any, + *, + timeout: float = FLOW_TIMEOUT_SECONDS, + open_browser: bool = True, +) -> dict[str, Any]: + """Run the full interactive flow: loopback server → browser → code → tokens. + + Explicit-action only (a Settings button) — never called from an engine turn, so + unlike mcp/oauth.py it needs no non-interactive refusal path. + """ + global last_authorize_url, _active_server + if _active_server is not None: + # A stale flow lost its browser tab; the new one takes the port. + _active_server.close() + await _active_server.wait_closed() + _active_server = None + verifier, challenge = create_pkce() + state = pysecrets.token_urlsafe(24) + url = build_authorize_url(state, challenge) + last_authorize_url = url + server, code_future = await _start_callback_server(state) + _active_server = server + try: + if open_browser: + import webbrowser + + logger.info("codex auth: opening browser for sign-in") + await asyncio.get_running_loop().run_in_executor(None, webbrowser.open, url) + try: + code = await asyncio.wait_for(code_future, timeout) + except asyncio.TimeoutError: + raise CodexAuthError( + "Sign-in timed out — the browser window was not completed in " + f"{int(timeout) // 60} minutes." + ) + finally: + server.close() + await server.wait_closed() + if _active_server is server: + _active_server = None + tokens = await asyncio.to_thread(exchange_code, code, verifier) + store = CodexTokenStore(secrets) + store.save(tokens) + if not (store._data().get("tokens") or {}).get("access_token"): + store.clear() + raise CodexAuthError("Sign-in failed — the token response had no access token.") + return {"ok": True, "account": store.account_label()} + + +# -- verify probe ------------------------------------------------------------------- + + +def verify(secrets: Any, timeout: float = 10.0) -> dict[str, Any]: + """Test-button probe: one cheap authenticated request against the backend. + + Distinguishes signed-out (no/rejected tokens) vs expired (401 with a bearer we + thought was live) vs OK. Never raises; {ok, error?, state?} like the other + provider verifies. + """ + import httpx + + store = CodexTokenStore(secrets) + if not store.signed_in(): + return {"ok": False, "error": SIGNED_OUT_ERROR, "state": "signed_out"} + try: + token, account = store.access_token() + except CodexSignInRequired as exc: + return {"ok": False, "error": str(exc), "state": "signed_out"} + except CodexAuthError as exc: + return {"ok": False, "error": str(exc)} + try: + resp = httpx.post( + CODEX_BASE_URL + "/responses", + headers={ + "Authorization": f"Bearer {token}", + **backend_headers(account, str(uuid.uuid4())), + }, + json={ + "model": _VERIFY_MODEL, + "input": "Reply with OK.", + "store": False, + "stream": True, + "max_output_tokens": 16, + }, + timeout=timeout, + ) + except Exception as exc: + return { + "ok": False, + "error": f"Couldn't reach the ChatGPT backend ({exc.__class__.__name__}).", + } + if resp.status_code < 300: + return {"ok": True, "account": store.account_label()} + if resp.status_code in (401, 403): + return {"ok": False, "error": EXPIRED_ERROR, "state": "expired"} + if resp.status_code == 429: + # Auth is fine — the plan window is just used up right now. + return {"ok": True, "account": store.account_label(), "note": PLAN_LIMIT_ERROR} + return { + "ok": False, + "error": f"The ChatGPT backend returned HTTP {resp.status_code}.", + } diff --git a/coworker/providers/codex_provider.py b/coworker/providers/codex_provider.py new file mode 100644 index 00000000..e180c1d6 --- /dev/null +++ b/coworker/providers/codex_provider.py @@ -0,0 +1,128 @@ +"""`openai-codex` provider — OpenAI models through a ChatGPT subscription. + +The backend speaks the same Responses wire as `/v1/responses` (stateless: full +history each turn, `store: false`, encrypted reasoning in the `_openai` sidecar), so +all conversion/parsing is inherited from `OpenAIResponsesProvider` — this subclass +only swaps the credential: a short-lived OAuth bearer from `codex_auth` instead of an +API key, plus the account/originator/session headers the backend requires. + +Differences from the API-key path: + - The backend serves streamed responses only, so `complete()` drains `stream()`. + - 401 → one refresh-and-retry (the bearer died mid-flight); a rejected refresh + token surfaces as a typed sign-in-required error, never a crash loop. + - 429 → the plan's rolling usage window, surfaced as a user-readable message. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from .base import AssistantTurn +from .codex_auth import ( + CODEX_BASE_URL, + PLAN_LIMIT_ERROR, + CodexTokenStore, + backend_headers, +) +from .openai_responses import OpenAIResponsesProvider + + +def _status_code(exc: Exception) -> Optional[int]: + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status + status = getattr(getattr(exc, "response", None), "status_code", None) + return status if isinstance(status, int) else None + + +class CodexProvider(OpenAIResponsesProvider): + def __init__( + self, + client: Any = None, + *, + secrets: Any = None, + default_model: str = "gpt-5.2-codex", + reasoning_summary: bool = True, + ): + super().__init__( + client=client, + default_model=default_model, + base_url=CODEX_BASE_URL, + reasoning_summary=reasoning_summary, + ) + self._store = CodexTokenStore(secrets) + # One conversation per provider instance in practice (the router caches one + # client per provider); a uuid per instance satisfies the per-conversation + # session header without threading conversation ids through ProviderClient. + self._session_id = str(uuid.uuid4()) + self._client_token: Optional[str] = None + self._injected = client is not None + + def _ensure_client(self) -> Any: + if self._injected: + return self._client + # The bearer is short-lived: fetch per call (refreshes itself near expiry) + # and rebuild the SDK client whenever the token rotated. + token, account = self._store.access_token() + if self._client is None or token != self._client_token: + from openai import OpenAI + + self._client = OpenAI( + api_key=token, + base_url=CODEX_BASE_URL, + default_headers=backend_headers(account, self._session_id), + ) + self._client_token = token + return self._client + + def _request_kwargs( + self, + *, + model: str, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]], + settings: dict[str, Any], + ) -> dict[str, Any]: + kwargs = super()._request_kwargs( + model=model, messages=messages, tools=tools, settings=settings + ) + # Unlike stock /v1/responses, this backend honors a reasoning effort knob. + effort = settings.get("reasoning_effort") + if isinstance(effort, str) and effort: + kwargs["reasoning"] = {**kwargs.get("reasoning", {}), "effort": effort} + # The backend rejects requests without instructions; history normally + # carries a system prompt — this is only the bare-call fallback. + kwargs.setdefault("instructions", "You are a helpful assistant.") + return kwargs + + def _create(self, client: Any, kwargs: dict[str, Any]) -> Any: + try: + return super()._create(client, kwargs) + except Exception as exc: + status = _status_code(exc) + if status == 401 and not self._injected: + # The bearer died mid-flight: force one refresh and retry once. + # A rejected refresh raises CodexSignInRequired out of the store. + self._store.refresh() + self._client = None + self._client_token = None + return super()._create(self._ensure_client(), kwargs) + if status == 429: + raise RuntimeError(PLAN_LIMIT_ERROR) from exc + raise + + def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]] = None, + **settings: Any, + ) -> AssistantTurn: + # The backend only serves streamed responses — aggregate the stream. + turn: Optional[AssistantTurn] = None + for chunk in self.stream(model=model, messages=messages, tools=tools, **settings): + if chunk.turn is not None: + turn = chunk.turn + return turn if turn is not None else AssistantTurn() diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index f48b6944..839458a0 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -57,6 +57,33 @@ MATRIX: dict[str, ModelEntry] = { "gpt-5.6-terra": ModelEntry("GPT-5.6 Terra · OpenAI", _AGENTIC_VISION, 400_000), "gpt-5.6-luna": ModelEntry("GPT-5.6 Luna · OpenAI", _AGENTIC_VISION, 400_000), "gpt-5.5": ModelEntry("GPT-5.5 · OpenAI", _AGENTIC_VISION, 400_000), + # ChatGPT-subscription catalog (the `openai-codex` OAuth provider). Curated to the + # ids the subscription backend actually serves; vision per the vendor's model docs, + # PDF unverified over this backend → local fallback via pdf_support.py. + "openai-codex:gpt-5.2-codex": ModelEntry( + "GPT-5.2 Codex · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.2": ModelEntry( + "GPT-5.2 · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.1-codex": ModelEntry( + "GPT-5.1 Codex · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.1-codex-mini": ModelEntry( + "GPT-5.1 Codex Mini · ChatGPT plan", _AGENTIC, 400_000 + ), # Fable 5 (2026-06-09) is GA; its Mythos 5 sibling is approved-orgs-only, so it # stays out of a picker meant for the public. "anthropic:claude-fable-5": ModelEntry( diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 1f7aea8e..bb91db4e 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -86,6 +86,10 @@ class ProviderDescriptor: ) # One-line note under the provider title (e.g. "Connects through X's OpenAI-compatible API"). blurb: str = "" + # "oauth" → no key form at all: the provider is configured by a browser sign-in + # (tokens in its `provider:` profile) and the GUI renders connect/sign-out + # instead of fields. None → the usual key/field form. + auth: Optional[str] = None def to_dict(self) -> dict[str, Any]: return { @@ -95,6 +99,7 @@ class ProviderDescriptor: "fields": [f.to_dict() for f in self.fields], "recommended_model": self.recommended_model, "blurb": self.blurb, + "auth": self.auth, } @@ -124,6 +129,14 @@ def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient: return OpenAIResponsesProvider(secrets=secrets) +def _build_codex(profile: dict[str, Any], secrets: Any) -> ProviderClient: + # Credentials come from the OAuth token set in the provider's own profile, + # resolved (and refreshed) at call time by the token store — never a key. + from .codex_provider import CodexProvider + + return CodexProvider(secrets=secrets) + + def _build_anthropic(profile: dict[str, Any], secrets: Any) -> ProviderClient: # Key resolution stays in AnthropicProvider/resolve_api_key (explicit → env → SecretStore), # deferred to first call so the provider can be built before a key exists. @@ -347,6 +360,17 @@ DESCRIPTORS: list[ProviderDescriptor] = [ recommended_model="gpt-5.6-sol", env_key="OPENAI_API_KEY", ), + ProviderDescriptor( + name="openai-codex", + title="ChatGPT (OpenAI subscription)", + needs_key=False, + fields=[], + build=_build_codex, + recommended_model="gpt-5.2-codex", + blurb="Sign in with your ChatGPT plan and run OpenAI models through your " + "subscription — no API key. Tokens stay on this machine.", + auth="oauth", + ), ProviderDescriptor( name="anthropic", title="Claude (Anthropic)", @@ -693,6 +717,9 @@ def descriptor_configured(d: ProviderDescriptor, profile: dict[str, Any]) -> boo a stored or env key. Multi-field cloud providers (no `api_key` field, e.g. Bedrock): every required field present — their actual credentials may be ambient (~/.aws, ADC). """ + if d.auth == "oauth": + # A stored token set = signed in (the tokens live in the same profile). + return bool((profile or {}).get("tokens")) if not d.needs_key: return True # keyless (Ollama) — usable out of the box profile = profile or {} @@ -908,6 +935,10 @@ def verify_provider_key( d = _BY_NAME.get(name) or _BY_NAME["openai"] key = (api_key or "").strip() + if d.auth == "oauth": + # OAuth providers verify from their stored tokens (needs the SecretStore), + # which only the manager holds — see SessionManager.verify_provider. + return {"ok": False, "error": f"{d.title} verifies via its sign-in, not a key."} if name == "bedrock": return _verify_bedrock(fields or {}, timeout) if name == "vertex": diff --git a/coworker/server/app.py b/coworker/server/app.py index 0f6c553b..fdd7c64b 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1811,6 +1811,24 @@ def create_app(manager: SessionManager) -> FastAPI: manager.verify_provider, name, (body or {}).get("fields") ) + @app.post("/v1/providers/openai-codex/signin") + async def codex_signin() -> dict[str, Any]: + # Opens the system browser and waits on the loopback callback — that can + # take minutes, so it runs as a background task; the GUI polls the status + # route for the flip (authorizing → signed_in | last_error). Same shape as + # the MCP OAuth connect route. + manager.begin_codex_signin() + asyncio.create_task(manager.codex_signin()) + return {"ok": True, "started": True} + + @app.get("/v1/providers/openai-codex/status") + def codex_status() -> dict[str, Any]: + return manager.codex_status() + + @app.post("/v1/providers/openai-codex/signout") + def codex_signout() -> dict[str, Any]: + return manager.codex_signout() + # -- settings (model API key) ----------------------------------------------- @app.get("/v1/settings") def settings_get() -> dict[str, Any]: diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 6390ecfc..bd02ab2d 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -186,6 +186,10 @@ class SessionManager: # feeds list_mcp's status so the GUI can show "authorizing…" and failures. self._mcp_authorizing: set[str] = set() self._mcp_errors: dict[str, str] = {} + # ChatGPT-subscription provider sign-in in flight / its last error — feeds + # the providers list + status route so the GUI can show "authorizing…". + self._codex_authorizing = False + self._codex_error: Optional[str] = None # http servers whose anonymous connect came back 401/403 — the failure is # "needs sign-in", so the GUI offers the OAuth switch instead of a raw error. self._mcp_auth_hints: set[str] = set() @@ -2764,21 +2768,30 @@ class SessionManager: for f in d.fields if not f.secret and profile.get(f.key) } - out.append( - { - **d.to_dict(), - "configured": configured, - "values": values, - "suggested_models": self._suggested_models(d.name), - # Key hygiene for the Settings pane: when the key was saved (date, stamped - # by set_provider) and when the provider last served a completion (epoch, - # stamped by the router's on_use hook). Absent for env-only config. - "key_set_at": profile.get("key_set_at"), - "last_used_at": (self._prefs.get("provider_last_used") or {}).get( - d.name - ), - } - ) + row = { + **d.to_dict(), + "configured": configured, + "values": values, + "suggested_models": self._suggested_models(d.name), + # Key hygiene for the Settings pane: when the key was saved (date, stamped + # by set_provider) and when the provider last served a completion (epoch, + # stamped by the router's on_use hook). Absent for env-only config. + "key_set_at": profile.get("key_set_at"), + "last_used_at": (self._prefs.get("provider_last_used") or {}).get( + d.name + ), + } + if d.auth == "oauth": + # Sign-in state instead of key state; the token values themselves + # never leave the SecretStore. + row["signed_in"] = configured + row["account"] = profile.get("account_email") or profile.get( + "account_id" + ) + if d.name == "openai-codex": + row["authorizing"] = self._codex_authorizing + row["last_error"] = self._codex_error + out.append(row) return out def pick_native_folder(self) -> dict[str, Any]: @@ -2919,6 +2932,58 @@ class SessionManager: self._refresh_provider(name) return {"ok": True, "provider": name} + # -- ChatGPT-subscription provider (OAuth, no key) --------------------------- + def begin_codex_signin(self) -> None: + """Flag `authorizing` BEFORE the background sign-in task starts, so the GUI's + first poll after the button press already shows it (same reasoning as + begin_mcp_connect).""" + self._codex_authorizing = True + self._codex_error = None + + async def codex_signin(self) -> dict[str, Any]: + """Run the interactive browser sign-in and store the tokens. Long-running + (the user completes it in the browser) — routes run it as a background task + and the GUI polls codex_status for the flip.""" + from ..providers import codex_auth + + self._codex_authorizing = True + self._codex_error = None + try: + result = await codex_auth.sign_in(self.secrets) + except Exception as exc: + self._codex_error = str(exc) + return {"ok": False, "error": str(exc)} + finally: + self._codex_authorizing = False + self._refresh_provider("openai-codex") + # Same convenience as set_provider: surface the recommended model right away, + # and win the default when the current default's provider isn't usable. + added = "openai-codex:gpt-5.2-codex" + self.add_model(added) + if not self._provider_configured(self._model_provider(self.model)): + self.set_default_model(added) + return result + + def codex_status(self) -> dict[str, Any]: + from ..providers import codex_auth + + store = codex_auth.CodexTokenStore(self.secrets) + return { + "signed_in": store.signed_in(), + "account": store.account_label(), + "authorizing": self._codex_authorizing, + "last_error": self._codex_error, + "authorize_url": codex_auth.last_authorize_url, + } + + def codex_signout(self) -> dict[str, Any]: + from ..providers import codex_auth + + had_tokens = codex_auth.CodexTokenStore(self.secrets).clear() + self._codex_error = None + self._refresh_provider("openai-codex") + return {"ok": True, "had_tokens": had_tokens} + def verify_provider( self, name: str, fields: Optional[dict[str, Any]] ) -> dict[str, Any]: @@ -2930,6 +2995,11 @@ class SessionManager: d = get_descriptor(name) if d is None: return {"ok": False, "error": f"unknown provider: {name}"} + if d.auth == "oauth": + # No key form — verify from the stored token set (signed-out / expired / OK). + from ..providers import codex_auth + + return codex_auth.verify(self.secrets) fields = fields or {} profile = self.secrets.get(f"provider:{name}") or {} merged = {} diff --git a/tests/test_codex_provider.py b/tests/test_codex_provider.py new file mode 100644 index 00000000..ba54782d --- /dev/null +++ b/tests/test_codex_provider.py @@ -0,0 +1,562 @@ +"""ChatGPT-subscription provider (`openai-codex`): PKCE flow, token storage + +refresh, backend request shape, failure modes, and the REST surface. No live +network — the token endpoint, the SDK client, and the verify probe are all faked; +the loopback callback server is exercised for real on its fixed port.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import socket +import time +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest +from fastapi.testclient import TestClient + +from coworker.providers import codex_auth +from coworker.providers.codex_auth import ( + CODEX_BASE_URL, + CodexAuthError, + CodexSignInRequired, + CodexTokenStore, + account_id_from, + build_authorize_url, + create_pkce, +) +from coworker.providers.codex_provider import CodexProvider +from coworker.secrets import SecretStore +from coworker.server.app import create_app +from coworker.server.manager import SessionManager + +ACCOUNT_CLAIM = "https://api.openai.com/auth" + + +def _jwt(claims: dict) -> str: + def b64(obj) -> str: + raw = json.dumps(obj).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + return f"{b64({'alg': 'none'})}.{b64(claims)}.sig" + + +def _access(exp_offset: float = 3600, account: str = "acct_1") -> str: + return _jwt( + {"exp": time.time() + exp_offset, ACCOUNT_CLAIM: {"chatgpt_account_id": account}} + ) + + +def _id_token(email: str = "user@example.com", account: str = "acct_1") -> str: + return _jwt({"email": email, ACCOUNT_CLAIM: {"chatgpt_account_id": account}}) + + +def _token_response(status: int = 200, body: dict | None = None): + return SimpleNamespace(status_code=status, json=lambda: body or {}) + + +def _seed(secrets, exp_offset: float = 3600) -> None: + CodexTokenStore(secrets).save( + { + "access_token": _access(exp_offset), + "refresh_token": "rt-1", + "id_token": _id_token(), + } + ) + + +# -- PKCE / authorize URL ----------------------------------------------------------- + + +def test_pkce_challenge_is_s256_of_verifier(): + import hashlib + + verifier, challenge = create_pkce() + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .rstrip(b"=") + .decode() + ) + assert challenge == expected + assert create_pkce()[0] != verifier # fresh randomness per flow + + +def test_authorize_url_shape(): + url = build_authorize_url("st4te", "ch4llenge") + parts = urlsplit(url) + assert f"{parts.scheme}://{parts.netloc}{parts.path}" == codex_auth.AUTHORIZE_URL + q = {k: v[0] for k, v in parse_qs(parts.query).items()} + assert q["response_type"] == "code" + assert q["client_id"] == codex_auth.CLIENT_ID + assert q["redirect_uri"] == "http://localhost:1455/auth/callback" + assert q["state"] == "st4te" + assert q["code_challenge"] == "ch4llenge" + assert q["code_challenge_method"] == "S256" + assert q["codex_cli_simplified_flow"] == "true" + assert q["originator"] == codex_auth.ORIGINATOR + + +def test_account_id_from_token_claim(): + tokens = {"id_token": _id_token(account="acct_9")} + assert account_id_from(tokens) == "acct_9" + # Falls back to the access token, and to the plain account_id key. + tokens = {"access_token": _jwt({ACCOUNT_CLAIM: {"account_id": "acct_x"}})} + assert account_id_from(tokens) == "acct_x" + assert account_id_from({"access_token": "not-a-jwt"}) == "" + + +# -- sign-in flow (loopback callback → exchange → storage) -------------------------- + + +async def _hit_callback(query: str) -> str: + reader, writer = await asyncio.open_connection("127.0.0.1", codex_auth.CALLBACK_PORT) + writer.write( + f"GET /auth/callback?{query} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode() + ) + await writer.drain() + data = await reader.read(-1) + writer.close() + return data.decode() + + +async def test_sign_in_full_flow(tmp_path, monkeypatch): + secrets = SecretStore(tmp_path / "s.json") + opened: dict = {} + exchanged: dict = {} + + monkeypatch.setattr("webbrowser.open", lambda url: opened.update(url=url)) + + def fake_token_post(data, timeout=30.0): + exchanged.update(data) + return _token_response( + body={ + "access_token": _access(), + "refresh_token": "rt-1", + "id_token": _id_token("user@example.com"), + } + ) + + monkeypatch.setattr(codex_auth, "_token_post", fake_token_post) + + task = asyncio.create_task(codex_auth.sign_in(secrets)) + while not opened: # wait for the flow to bind the port and "open" the browser + await asyncio.sleep(0.01) + state = parse_qs(urlsplit(opened["url"]).query)["state"][0] + + # A forged local hit with the wrong state is rejected and does NOT consume the flow. + resp = await _hit_callback("code=evil&state=wrong") + assert resp.startswith("HTTP/1.1 400") + assert not task.done() + + resp = await _hit_callback(f"code=c0de&state={state}") + assert resp.startswith("HTTP/1.1 200") and "close this tab" in resp.lower() + result = await task + + assert result == {"ok": True, "account": "user@example.com"} + assert exchanged["grant_type"] == "authorization_code" + assert exchanged["code"] == "c0de" + assert exchanged["redirect_uri"] == "http://localhost:1455/auth/callback" + assert exchanged["code_verifier"] + profile = secrets.get("provider:openai-codex") + assert profile["tokens"]["refresh_token"] == "rt-1" + assert profile["account_id"] == "acct_1" + assert profile["account_email"] == "user@example.com" + assert isinstance(profile["tokens_issued_at"], int) + + +async def test_sign_in_provider_error_from_callback(tmp_path, monkeypatch): + secrets = SecretStore(tmp_path / "s.json") + opened: dict = {} + monkeypatch.setattr("webbrowser.open", lambda url: opened.update(url=url)) + + task = asyncio.create_task(codex_auth.sign_in(secrets)) + while not opened: + await asyncio.sleep(0.01) + resp = await _hit_callback("error=access_denied") + assert resp.startswith("HTTP/1.1 400") + with pytest.raises(CodexAuthError, match="access_denied"): + await task + assert not CodexTokenStore(secrets).signed_in() + + +async def test_sign_in_port_busy_names_the_usual_holder(tmp_path): + secrets = SecretStore(tmp_path / "s.json") + blocker = socket.socket() + blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + blocker.bind(("127.0.0.1", codex_auth.CALLBACK_PORT)) + blocker.listen(1) + with pytest.raises(CodexAuthError, match="1455"): + await codex_auth.sign_in(secrets, open_browser=False) + finally: + blocker.close() + + +# -- token store: refresh ------------------------------------------------------------ + + +def test_access_token_fresh_needs_no_refresh(tmp_path, monkeypatch): + secrets = SecretStore(tmp_path / "s.json") + _seed(secrets) + monkeypatch.setattr( + codex_auth, "_token_post", lambda *a, **k: pytest.fail("refresh not needed") + ) + token, account = CodexTokenStore(secrets).access_token() + assert token == secrets.get("provider:openai-codex")["tokens"]["access_token"] + assert account == "acct_1" + + +def test_access_token_refreshes_near_expiry(tmp_path, monkeypatch): + secrets = SecretStore(tmp_path / "s.json") + _seed(secrets, exp_offset=30) # inside the refresh margin + sent: dict = {} + fresh = _access(7200) + + def fake_token_post(data, timeout=30.0): + sent.update(data) + return _token_response(body={"access_token": fresh}) + + monkeypatch.setattr(codex_auth, "_token_post", fake_token_post) + token, account = CodexTokenStore(secrets).access_token() + assert token == fresh and account == "acct_1" + assert sent == { + "grant_type": "refresh_token", + "refresh_token": "rt-1", + "client_id": codex_auth.CLIENT_ID, + } + profile = secrets.get("provider:openai-codex") + # The refresh response omitted refresh/id tokens — prior values survive. + assert profile["tokens"]["access_token"] == fresh + assert profile["tokens"]["refresh_token"] == "rt-1" + + +def test_rejected_refresh_blanks_tokens_to_signed_out(tmp_path, monkeypatch): + secrets = SecretStore(tmp_path / "s.json") + _seed(secrets, exp_offset=-10) + monkeypatch.setattr( + codex_auth, "_token_post", lambda *a, **k: _token_response(status=400) + ) + store = CodexTokenStore(secrets) + with pytest.raises(CodexSignInRequired): + store.access_token() + assert not store.signed_in() # clean signed-out state, not a crash loop + + +def test_access_token_signed_out_raises_typed_error(tmp_path): + store = CodexTokenStore(SecretStore(tmp_path / "s.json")) + with pytest.raises(CodexSignInRequired, match="Not signed in"): + store.access_token() + + +# -- provider: request shape to the backend ----------------------------------------- + + +class _FakeSDKClient: + def __init__(self, events=None, errors=None): + self.kwargs: dict = {} + errors = list(errors or []) + + def create(**kwargs): + self.kwargs = kwargs + if errors: + raise errors.pop(0) + return iter(events or []) + + self.responses = SimpleNamespace(create=create) + + +def _completed_event(text="hello"): + return SimpleNamespace( + type="response.completed", + response=SimpleNamespace( + output=[ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + } + ], + status="completed", + incomplete_details=None, + ), + ) + + +def _status_error(status: int, message: str = ""): + exc = Exception(message or f"HTTP {status}") + exc.status_code = status + return exc + + +def _provider(tmp_path, monkeypatch, clients): + """A CodexProvider over seeded tokens whose SDK clients come from `clients` + (each OpenAI() construction pops the next one and records its init kwargs).""" + secrets = SecretStore(tmp_path / "s.json") + _seed(secrets) + built: list[dict] = [] + + def fake_openai(**kwargs): + built.append(kwargs) + return clients.pop(0) + + monkeypatch.setattr("openai.OpenAI", fake_openai) + return CodexProvider(secrets=secrets), secrets, built + + +def test_stream_request_headers_and_body(tmp_path, monkeypatch): + fake = _FakeSDKClient(events=[_completed_event()]) + provider, secrets, built = _provider(tmp_path, monkeypatch, [fake]) + + out = list( + provider.stream( + model="gpt-5.2-codex", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + ], + tools=[{"type": "function", "function": {"name": "f"}}], + ) + ) + + assert built[0]["api_key"] == secrets.get("provider:openai-codex")["tokens"][ + "access_token" + ] + assert built[0]["base_url"] == CODEX_BASE_URL + headers = built[0]["default_headers"] + assert headers["chatgpt-account-id"] == "acct_1" + assert headers["originator"] == codex_auth.ORIGINATOR + assert headers["OpenAI-Beta"] == "responses=experimental" + assert headers["session-id"] # per-conversation uuid + assert fake.kwargs["model"] == "gpt-5.2-codex" + assert fake.kwargs["stream"] is True + assert fake.kwargs["store"] is False + assert fake.kwargs["include"] == ["reasoning.encrypted_content"] + assert fake.kwargs["instructions"] == "sys" + assert fake.kwargs["tools"] == [{"type": "function", "name": "f"}] + assert out[-1].turn.text == "hello" + + +def test_complete_goes_through_stream_and_reasoning_effort_rides(tmp_path, monkeypatch): + fake = _FakeSDKClient(events=[_completed_event("done")]) + provider, _, _ = _provider(tmp_path, monkeypatch, [fake]) + turn = provider.complete( + model="gpt-5.2-codex", + messages=[{"role": "user", "content": "hi"}], + reasoning_effort="high", + ) + assert turn.text == "done" + assert fake.kwargs["stream"] is True # the backend only serves streams + assert fake.kwargs["reasoning"] == {"summary": "auto", "effort": "high"} + assert fake.kwargs["instructions"] # bare-call fallback instructions present + + +def test_401_refreshes_once_and_retries_with_new_bearer(tmp_path, monkeypatch): + first = _FakeSDKClient(errors=[_status_error(401, "Unauthorized")]) + second = _FakeSDKClient(events=[_completed_event("after refresh")]) + provider, secrets, built = _provider(tmp_path, monkeypatch, [first, second]) + fresh = _access(7200) + monkeypatch.setattr( + codex_auth, + "_token_post", + lambda *a, **k: _token_response(body={"access_token": fresh}), + ) + turn = provider.complete( + model="gpt-5.2-codex", messages=[{"role": "user", "content": "hi"}] + ) + assert turn.text == "after refresh" + assert len(built) == 2 and built[1]["api_key"] == fresh + + +def test_429_surfaces_plan_limit_message(tmp_path, monkeypatch): + fake = _FakeSDKClient(errors=[_status_error(429, "Too Many Requests")]) + provider, _, _ = _provider(tmp_path, monkeypatch, [fake]) + with pytest.raises(RuntimeError, match="plan limit"): + provider.complete( + model="gpt-5.2-codex", messages=[{"role": "user", "content": "hi"}] + ) + + +def test_signed_out_provider_raises_typed_error(tmp_path): + provider = CodexProvider(secrets=SecretStore(tmp_path / "s.json")) + with pytest.raises(CodexSignInRequired, match="Not signed in"): + provider.complete( + model="gpt-5.2-codex", messages=[{"role": "user", "content": "hi"}] + ) + + +# -- registry / matrix --------------------------------------------------------------- + + +def test_registry_builds_codex_provider(): + from coworker.providers.registry import build_provider_client, get_descriptor + + assert isinstance(build_provider_client("openai-codex", {}, None), CodexProvider) + d = get_descriptor("openai-codex") + assert d.auth == "oauth" and d.fields == [] + assert d.to_dict()["auth"] == "oauth" + + +def test_descriptor_configured_means_tokens_present(): + from coworker.providers.registry import descriptor_configured, get_descriptor + + d = get_descriptor("openai-codex") + assert not descriptor_configured(d, {}) + assert descriptor_configured(d, {"tokens": {"access_token": "a"}}) + + +def test_matrix_curates_subscription_models(): + from coworker.providers.capabilities import capabilities_for + from coworker.providers.matrix import models_for_provider + + assert models_for_provider("openai-codex") == [ + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + ] + caps = capabilities_for("openai-codex:gpt-5.2-codex") + assert caps.tools and caps.vision and caps.streaming + + +# -- verify probe -------------------------------------------------------------------- + + +def _verify_with_backend(tmp_path, monkeypatch, status: int): + secrets = SecretStore(tmp_path / "s.json") + _seed(secrets) + probes: dict = {} + + def fake_post(url, headers=None, json=None, timeout=None): + probes.update(url=url, headers=headers, body=json) + return SimpleNamespace(status_code=status) + + monkeypatch.setattr("httpx.post", fake_post) + return codex_auth.verify(secrets), probes + + +def test_verify_signed_out(tmp_path): + result = codex_auth.verify(SecretStore(tmp_path / "s.json")) + assert result["ok"] is False and result["state"] == "signed_out" + + +def test_verify_ok_probes_backend(tmp_path, monkeypatch): + result, probes = _verify_with_backend(tmp_path, monkeypatch, 200) + assert result == {"ok": True, "account": "user@example.com"} + assert probes["url"] == CODEX_BASE_URL + "/responses" + assert probes["headers"]["Authorization"].startswith("Bearer ") + assert probes["headers"]["chatgpt-account-id"] == "acct_1" + assert probes["body"]["store"] is False and probes["body"]["stream"] is True + + +def test_verify_expired_and_plan_limited(tmp_path, monkeypatch): + result, _ = _verify_with_backend(tmp_path, monkeypatch, 401) + assert result["ok"] is False and result["state"] == "expired" + result, _ = _verify_with_backend(tmp_path, monkeypatch, 429) + assert result["ok"] is True and "note" in result # auth fine, window used up + + +# -- REST surface -------------------------------------------------------------------- + + +def _rest(tmp_path): + manager = SessionManager(data_dir=tmp_path / "data") + return manager, TestClient(create_app(manager)) + + +def test_providers_list_shows_oauth_state(tmp_path): + manager, client = _rest(tmp_path) + rows = {p["name"]: p for p in client.get("/v1/providers").json()} + row = rows["openai-codex"] + assert row["auth"] == "oauth" + assert row["signed_in"] is False and row["configured"] is False + assert "gpt-5.2-codex" in row["suggested_models"] + + manager.secrets.put( + "provider:openai-codex", + {"tokens": {"access_token": "a", "refresh_token": "r"}, "account_email": "u@x.com"}, + ) + row = {p["name"]: p for p in client.get("/v1/providers").json()}["openai-codex"] + assert row["signed_in"] is True and row["configured"] is True + assert row["account"] == "u@x.com" + assert "tokens" not in row.get("values", {}) # secrets never leave the store + + +def test_signin_route_starts_background_flow(tmp_path, monkeypatch): + manager, _ = _rest(tmp_path) + seen = {} + + async def fake_signin(): + seen["called"] = True + return {"ok": True} + + monkeypatch.setattr(manager, "codex_signin", fake_signin) + client = TestClient(create_app(manager)) + assert client.post("/v1/providers/openai-codex/signin").json() == { + "ok": True, + "started": True, + } + assert seen["called"] + # begin_codex_signin flagged before the task ran, so the very first status + # poll after the button press already shows authorizing. + assert manager._codex_authorizing is True + + +def test_status_and_signout_routes(tmp_path): + manager, client = _rest(tmp_path) + status = client.get("/v1/providers/openai-codex/status").json() + assert status["signed_in"] is False and status["authorizing"] is False + + manager.secrets.put( + "provider:openai-codex", + {"tokens": {"access_token": "a"}, "account_email": "u@x.com"}, + ) + status = client.get("/v1/providers/openai-codex/status").json() + assert status["signed_in"] is True and status["account"] == "u@x.com" + + assert client.post("/v1/providers/openai-codex/signout").json() == { + "ok": True, + "had_tokens": True, + } + assert client.get("/v1/providers/openai-codex/status").json()["signed_in"] is False + + +def test_verify_route_reports_signed_out(tmp_path): + _, client = _rest(tmp_path) + result = client.post("/v1/providers/verify", json={"name": "openai-codex"}).json() + assert result["ok"] is False and result["state"] == "signed_out" + + +async def test_manager_signin_stores_and_promotes_model(tmp_path, monkeypatch): + manager, _ = _rest(tmp_path) + + async def fake_sign_in(secrets, **kwargs): + CodexTokenStore(secrets).save( + { + "access_token": _access(), + "refresh_token": "rt-1", + "id_token": _id_token(), + } + ) + return {"ok": True, "account": "user@example.com"} + + monkeypatch.setattr(codex_auth, "sign_in", fake_sign_in) + result = await manager.codex_signin() + assert result["ok"] is True + assert manager._codex_authorizing is False + settings = manager.get_settings() + assert "openai-codex:gpt-5.2-codex" in settings["models"] + + +async def test_manager_signin_failure_lands_in_status(tmp_path, monkeypatch): + manager, client = _rest(tmp_path) + + async def fake_sign_in(secrets, **kwargs): + raise CodexAuthError("Port 1455 is already in use — quit the other holder.") + + monkeypatch.setattr(codex_auth, "sign_in", fake_sign_in) + result = await manager.codex_signin() + assert result["ok"] is False + status = client.get("/v1/providers/openai-codex/status").json() + assert "1455" in status["last_error"] + assert status["authorizing"] is False