Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions coding-agent/docs/adr/0050-codex-oauth-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# ADR-0050: Codex OAuth Provider Support

**Status**: Accepted
**Date**: 2026-05-31

## Context

The coding agent currently supports API-key-based LLM providers (OpenAI, Anthropic, Copilot, Kimi, DeepSeek, StepFun). We need to add support for OpenAI's ChatGPT Codex Responses API, which requires OAuth 2.0 device authorization instead of an API key. Codex provides a distinct API surface and model capabilities that differ from the standard chat completions API.

## Decision

We will implement **Codex OAuth support** directly within the coding agent codebase under a new `src/coding_agent/oauth/` package, rather than depending on the `ya-oauth` mono-repo packages.

### Why not use `ya-oauth` / `ya-oauth-provider`?

The reference `ya-oauth` packages provide a clean OAuth client, but pulling in the entire mono-repo introduces a larger dependency surface than needed. The core OAuth flow (device code → token exchange → refresh) is straightforward and can be implemented in a self-contained module.

### Architecture

```
src/coding_agent/oauth/
├── __init__.py # Public exports
├── types.py # Pydantic models
├── store.py # File-backed OAuth credential store
├── codex.py # Codex OAuth client (device flow, token exchange, refresh)
├── auth.py # httpx OAuthBearerAuth class
└── cli.py # Click CLI commands (login, status, refresh, logout, doctor)
```

### OAuth Flow

1. **Device Code Request**: POST to `https://auth.openai.com/api/accounts/deviceauth/usercode`
2. **User Authorization**: User opens `https://auth.openai.com/codex/device` in browser and enters the code
3. **Token Polling**: POST to device token endpoint until user authorizes
4. **Token Exchange**: Exchange authorization code for access/refresh tokens
5. **Token Refresh**: Use refresh token to obtain new access token before it expires
6. **Storage**: Tokens stored in `~/.coding-agent/oauth/auth.json` with file locking

### Provider Integration

The Codex provider wraps `OpenAICompatProvider` with `OAuthBearerAuth`, an `httpx.Auth` subclass that:

1. Reads the stored access token
2. Attaches it as a `Bearer` authorization header
3. Automatically refreshes the token on 401 responses
4. Persists the refreshed token to the auth store

### CLI Integration

A new `coding-agent oauth` command group is added:

```bash
coding-agent oauth login codex # Start device code flow
coding-agent oauth status # Show login status
coding-agent oauth refresh codex # Manually refresh tokens
coding-agent oauth logout codex # Revoke and remove tokens
coding-agent oauth doctor # Inspect auth file health
```

The `coding-agent --provider codex` flag selects the Codex provider for agent runs.

## Consequences

### Positive

- Self-contained OAuth implementation with minimal dependencies (httpx, pydantic)
- Reuses existing `OpenAICompatProvider` streaming infrastructure
- Consistent UX with `ya-oauth` reference implementation
- Secure file storage with proper permissions (0600 for files, 0700 for directories)

### Negative

- Duplicates some logic from `ya-oauth` (JWT parsing, device auth polling)
- Must maintain the OAuth flow if OpenAI changes their endpoints

### Risks

- OpenAI may change the Codex OAuth endpoints or flow (mitigated by keeping it self-contained and easy to update)
- Token refresh failures could interrupt long-running agent sessions (mitigated by retry logic in the provider)

## Alternatives Rejected

1. **Depend on `ya-oauth` packages** — Rejected to keep dependency surface small
2. **API key fallback** — Codex does not support API key authentication; OAuth is required
3. **External credential helper** — Rejected to keep the UX simple (single binary)

## Acceptance Criteria

- [ ] `test_oauth_store_writes_private_file_and_round_trips_record`
- [ ] `test_store_backed_token_source_refreshes_and_persists_token`
- [ ] `test_store_backed_token_source_missing_login_fails_fast`
- [ ] `test_oauth_bearer_auth_refreshes_on_unauthorized_response`
- [ ] `test_oauth_doctor_redacts_tokens`
- [ ] `test_codex_provider_uses_oauth_backed_openai_compat`
- [ ] `uv run pytest tests/coding_agent/test_codex_oauth.py tests/cli/test_oauth_commands.py tests/coding_agent/plugins/test_llm_provider.py -k "oauth or codex" -v`

## References

- `src/coding_agent/oauth/`
- `src/coding_agent/cli/oauth_commands.py`
- `src/coding_agent/plugins/llm_provider.py`
- `tests/coding_agent/test_codex_oauth.py`
- `tests/cli/test_oauth_commands.py`
- `https://github.com/Wh1isper/ya-mono/tree/main/packages/ya-oauth`
- `https://github.com/Wh1isper/ya-mono/tree/main/packages/ya-oauth-provider`
2 changes: 2 additions & 0 deletions coding-agent/src/coding_agent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from coding_agent.adapter import PipelineAdapter
from coding_agent.app import create_agent, create_child_pipeline # noqa: F401
from coding_agent.cli.kb_commands import kb
from coding_agent.cli.oauth_commands import oauth_cli
from coding_agent.cli.postmortem_commands import postmortem
from coding_agent.cli.remote_commands import attach, remote
from coding_agent.cli.serve_command import serve
Expand Down Expand Up @@ -230,6 +231,7 @@ def _create_provider(config):


main.add_command(kb)
main.add_command(oauth_cli)
main.add_command(postmortem)
main.add_command(stats)
main.add_command(serve)
Expand Down
5 changes: 5 additions & 0 deletions coding-agent/src/coding_agent/cli/oauth_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Re-export OAuth CLI commands for integration with the main CLI."""

from coding_agent.oauth.cli import oauth_cli

__all__ = ["oauth_cli"]
1 change: 1 addition & 0 deletions coding-agent/src/coding_agent/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"kimi-code-anthropic",
"deepseek",
"stepfun",
"codex",
]


Expand Down
22 changes: 22 additions & 0 deletions coding-agent/src/coding_agent/oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""OAuth credential management for coding agent providers."""

from coding_agent.oauth.codex import CodexOAuthClient, create_codex_token_source
from coding_agent.oauth.store import OAuthStore
from coding_agent.oauth.types import (
AuthFile,
OAuthAccount,
OAuthProviderRecord,
OAuthTokens,
TokenSnapshot,
)

__all__ = [
"AuthFile",
"CodexOAuthClient",
"OAuthAccount",
"OAuthProviderRecord",
"OAuthStore",
"OAuthTokens",
"TokenSnapshot",
"create_codex_token_source",
]
80 changes: 80 additions & 0 deletions coding-agent/src/coding_agent/oauth/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""httpx Auth handler for OAuth Bearer tokens with auto-refresh."""

from __future__ import annotations

import logging
from collections.abc import AsyncGenerator, Generator

import httpx

from coding_agent.oauth.types import OAuthTokenSource

logger = logging.getLogger(__name__)


class OAuthBearerAuth(httpx.Auth):
"""httpx Auth that injects an OAuth Bearer token and auto-refreshes on 401.

This is designed for use with OpenAI-compatible providers that require
OAuth tokens instead of API keys.
"""

def __init__(
self,
token_source: OAuthTokenSource,
*,
provider_name: str = "codex",
extra_headers: dict[str, str] | None = None,
) -> None:
self._token_source = token_source
self._provider_name = provider_name
self._extra_headers = extra_headers
self._cached_token: str | None = None

def sync_auth_flow(
self, request: httpx.Request
) -> Generator[httpx.Request, httpx.Response, None]:
"""Inject a bearer token for synchronous httpx clients."""
import asyncio

snapshot = asyncio.run(self._token_source.get_token())
self._cached_token = snapshot.access_token
request.headers["Authorization"] = f"Bearer {snapshot.access_token}"
if self._extra_headers:
for key, value in self._extra_headers.items():
request.headers.setdefault(key, value)
response = yield request

if response.status_code == 401:
response.read()
logger.debug(
"OAuth token expired (401), refreshing for %s",
self._provider_name,
)
snapshot = asyncio.run(self._token_source.refresh_token())
self._cached_token = snapshot.access_token
request.headers["Authorization"] = f"Bearer {snapshot.access_token}"
yield request

async def async_auth_flow(
self, request: httpx.Request
) -> AsyncGenerator[httpx.Request, httpx.Response]:
"""Inject a bearer token for asynchronous httpx clients."""
snapshot = await self._token_source.get_token()
self._cached_token = snapshot.access_token
request.headers["Authorization"] = f"Bearer {snapshot.access_token}"
if self._extra_headers:
for key, value in self._extra_headers.items():
request.headers.setdefault(key, value)
response = yield request

if response.status_code == 401:
await response.aread()
logger.debug(
"OAuth token expired (401), refreshing for %s",
self._provider_name,
)
snapshot = await self._token_source.refresh_token()
self._cached_token = snapshot.access_token
request.headers["Authorization"] = f"Bearer {snapshot.access_token}"
yield request
Loading