From 41cace0656b947f344b850e202d9c4b97c1c7c50 Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Wed, 3 Jun 2026 18:12:15 -0700 Subject: [PATCH 1/3] feat: add Codex OAuth provider with proxy-side credential injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative to #183 that keeps real OAuth tokens on the proxy pod only. The gateway never sees refresh tokens or access tokens — it receives a synthetic unsigned JWT containing just the account_id for client-side provider routing. The proxy auto-refreshes tokens and injects Authorization, chatgpt-account-id, originator, and OpenAI-Beta headers. This addresses Codex as an LLM provider (via openai-chatgpt-responses wire format). Running the native Codex app-server harness is a separate problem with different security trade-offs — see docs/proposals/codex-harness-design.md for the early design direction. - New codexOAuth credential type and openai-oauth known provider - CodexOAuthInjector in internal/proxy with oauth2.TokenSource refresh - Controller parses auth.json, generates synthetic JWT, wires volume mounts and proxy routes - Design proposals and user-guide documentation Signed-off-by: Alexey Kazakov Co-authored-by: Cursor --- api/v1alpha1/claw_types.go | 5 +- .../bases/claw.sandbox.redhat.com_claws.yaml | 5 +- docs/proposals/codex-harness-design.md | 70 ++++ docs/proposals/codex-oauth-design.md | 325 ++++++++++++++++++ docs/proposals/codex-oauth-questions.md | 143 ++++++++ docs/user-guide.md | 42 ++- internal/assets/manifests/claw/configmap.yaml | 32 +- internal/controller/claw_configmap_test.go | 137 ++++++-- internal/controller/claw_credentials.go | 49 ++- internal/controller/claw_credentials_test.go | 274 +++++++++++++++ internal/controller/claw_providers.go | 16 + internal/controller/claw_providers_test.go | 7 +- internal/controller/claw_proxy.go | 62 +++- internal/controller/claw_proxy_test.go | 110 ++++++ .../controller/claw_resource_controller.go | 17 +- internal/proxy/config.go | 35 +- internal/proxy/injector.go | 9 +- internal/proxy/injector_codex_oauth.go | 157 +++++++++ internal/proxy/injector_codex_oauth_test.go | 270 +++++++++++++++ 19 files changed, 1693 insertions(+), 72 deletions(-) create mode 100644 docs/proposals/codex-harness-design.md create mode 100644 docs/proposals/codex-oauth-design.md create mode 100644 docs/proposals/codex-oauth-questions.md create mode 100644 internal/proxy/injector_codex_oauth.go create mode 100644 internal/proxy/injector_codex_oauth_test.go diff --git a/api/v1alpha1/claw_types.go b/api/v1alpha1/claw_types.go index e8a2af27..5dd3fa58 100644 --- a/api/v1alpha1/claw_types.go +++ b/api/v1alpha1/claw_types.go @@ -23,7 +23,7 @@ import ( ) // CredentialType selects the credential injection mechanism used by the proxy. -// +kubebuilder:validation:Enum=apiKey;bearer;gcp;pathToken;oauth2;none;kubernetes +// +kubebuilder:validation:Enum=apiKey;bearer;gcp;pathToken;oauth2;none;kubernetes;codexOAuth type CredentialType string const ( @@ -34,6 +34,7 @@ const ( CredentialTypeOAuth2 CredentialType = "oauth2" CredentialTypeNone CredentialType = "none" CredentialTypeKubernetes CredentialType = "kubernetes" + CredentialTypeCodexOAuth CredentialType = "codexOAuth" ) // ConfigMode controls how operator.json is merged into the user's openclaw.json @@ -153,7 +154,7 @@ type OAuth2Config struct { } // CredentialSpec defines a single credential entry for proxy injection. -// +kubebuilder:validation:XValidation:rule="has(self.type) || has(self.channel) || (has(self.provider) && self.provider in ['google', 'anthropic', 'openai', 'xai', 'openrouter'])",message="type is required (inferred only for known providers: google, anthropic, openai, xai, openrouter)" +// +kubebuilder:validation:XValidation:rule="has(self.type) || has(self.channel) || (has(self.provider) && self.provider in ['google', 'anthropic', 'openai', 'xai', 'openrouter', 'openai-oauth'])",message="type is required (inferred only for known providers: google, anthropic, openai, xai, openrouter, openai-oauth)" // +kubebuilder:validation:XValidation:rule="!has(self.provider) || !has(self.channel)",message="provider and channel are mutually exclusive" // +kubebuilder:validation:XValidation:rule="has(self.channel) || (has(self.type) && self.type == 'none') || has(self.secretRef)",message="secretRef is required unless type is none or channel is set" // +kubebuilder:validation:XValidation:rule="!has(self.type) || self.type != 'apiKey' || has(self.apiKey) || (has(self.provider) && self.provider in ['google', 'anthropic']) || has(self.channel)",message="apiKey config is required when type is apiKey without inferred defaults" diff --git a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml index ca4e134c..b6b7fc78 100644 --- a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml +++ b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml @@ -285,16 +285,17 @@ spec: - oauth2 - none - kubernetes + - codexOAuth type: string required: - name type: object x-kubernetes-validations: - message: 'type is required (inferred only for known providers: - google, anthropic, openai, xai, openrouter)' + google, anthropic, openai, xai, openrouter, openai-oauth)' rule: has(self.type) || has(self.channel) || (has(self.provider) && self.provider in ['google', 'anthropic', 'openai', 'xai', - 'openrouter']) + 'openrouter', 'openai-oauth']) - message: provider and channel are mutually exclusive rule: '!has(self.provider) || !has(self.channel)' - message: secretRef is required unless type is none or channel diff --git a/docs/proposals/codex-harness-design.md b/docs/proposals/codex-harness-design.md new file mode 100644 index 00000000..3e56a388 --- /dev/null +++ b/docs/proposals/codex-harness-design.md @@ -0,0 +1,70 @@ +# Codex App-Server Harness — Design Proposal + +**Status:** Early draft — problem statement and high-level direction only + +**Date:** 2026-06-03 + +**Depends on:** [codex-oauth-design.md](codex-oauth-design.md) (Codex OAuth credential type, prerequisite) + +--- + +## Problem Statement + +The `codexOAuth` credential type (implemented in the companion design) gives users access to Codex models (GPT-5.5, GPT-5.4-mini, etc.) through OpenClaw's own agent runtime. OpenClaw sends prompts via the `openai-chatgpt-responses` wire format, receives tool call requests, and executes them using its own tool infrastructure (MCP servers, terminal, file editing, etc.). + +However, OpenClaw also supports a **native Codex agent harness** — the `codex` plugin spawns a Codex app-server binary as a child process that provides its own agent runtime with sandbox execution, native tool handling, approval flows, and subagent orchestration. Some users may want this full Codex agent experience running inside OpenClaw rather than using OpenClaw's own tool execution layer. + +The current `codexOAuth` design intentionally excludes the native harness because: + +1. **Security boundary.** The Codex app-server runs inside the gateway pod and needs real OAuth tokens for authentication. Our security model keeps real credentials on the proxy, never the gateway. + +2. **Binary dependency.** The Codex app-server is a separate binary that must be installed in the container. The operator's gateway image does not include it today. + +3. **Scope.** The operator's purpose is to deploy and secure OpenClaw instances. The `codexOAuth` credential gives users Codex model access, which is the primary ask. The native harness is an enhancement. + +--- + +## High-Level Approach + +Support the Codex app-server harness through a new CRD section (e.g., `spec.codexHarness`), following the pattern used for Kubernetes support today. Key concerns to address in the detailed design: + +### Binary installation + +Install the Codex app-server binary via an init container or OpenClaw's managed binary system. Options include a dedicated sidecar image, a shared volume init container, or letting OpenClaw's plugin system download it at startup. + +### Authentication + +The app-server authenticates with `chatgpt.com` using OAuth tokens from `auth-profiles.json`. This requires real tokens available to the gateway process — a relaxation of the proxy-only security boundary. The detailed design must define what exactly is exposed (access token only vs. refresh token), whether the proxy can mediate refresh, and what the blast radius is if the gateway is compromised. + +### Plugin and model configuration + +Enable the `codex` plugin in `openclaw.json`, set `agentRuntime: { id: "codex" }` on configured models, and write `auth-profiles.json` with the appropriate credential. This is mostly configuration wiring — the companion design's `openai-chatgpt-responses` provider config would be replaced or supplemented by the native harness config. + +### Coexistence with `codexOAuth` + +Define whether users can use both simultaneously (e.g., some models through the proxy path, others through the native harness) or whether the harness mode replaces the proxy path entirely when enabled. + +### Container security + +The Codex app-server may need a writable filesystem, network access, and potentially elevated capabilities for sandbox execution. Document the impact on pod security context and whether this is compatible with `readOnlyRootFilesystem` and the restricted seccomp profile. + +--- + +## Open Questions + +These will be resolved during the detailed design phase: + +1. How do we install the Codex app-server binary? Init container, managed download, or bundled image? +2. What is the minimum credential exposure needed for the app-server? Can we limit it to short-lived access tokens with proxy-mediated refresh? +3. Should this be a separate CRD section (`spec.codexHarness`) or an option on the existing `codexOAuth` credential? +4. What pod security changes are required? Is the Codex app-server compatible with our restricted security context? +5. How does this interact with network policies? Does the app-server need direct egress to `chatgpt.com` or can it go through the proxy? +6. What is the upgrade path for users currently using `codexOAuth` who want to switch to the native harness? + +--- + +## References + +- [Codex OAuth design](codex-oauth-design.md) — prerequisite credential type +- OpenClaw `extensions/codex/` — plugin source, agent harness, app-server client +- OpenClaw `extensions/codex/src/app-server/` — app-server lifecycle, auth bridge, binary management diff --git a/docs/proposals/codex-oauth-design.md b/docs/proposals/codex-oauth-design.md new file mode 100644 index 00000000..0ba0e005 --- /dev/null +++ b/docs/proposals/codex-oauth-design.md @@ -0,0 +1,325 @@ +# Codex OAuth Provider — Design Document + +**Status:** Draft — all design questions resolved, see [codex-oauth-questions.md](codex-oauth-questions.md) + +**Date:** 2026-06-03 + +--- + +## Overview + +OpenAI Codex supports two authentication paths: API keys (static `sk-...` tokens billed per-use on the Platform) and ChatGPT OAuth (short-lived JWTs included with a ChatGPT Plus/Pro/Team subscription). The operator already supports the API key path via the `openai` provider with a `bearer` credential type. This proposal adds support for the OAuth path. + +The core challenge is that Codex OAuth tokens expire (~24 hours) and must be refreshed using a long-lived `refresh_token`. Our security model requires that real credentials never reach the gateway container — the MITM proxy handles all credential injection. This design applies the same pattern we use for GCP service account credentials: bootstrap material (the `refresh_token`) is mounted on the proxy as a file, the proxy mints short-lived access tokens internally, and the gateway only ever sees placeholder values. + +### Codex OAuth at a glance + +| Concern | Value | +|---------|-------| +| Token endpoint | `https://auth.openai.com/oauth/token` | +| API endpoint | `https://chatgpt.com/backend-api/codex/responses` | +| Auth header | `Authorization: Bearer ` | +| Extra headers | `chatgpt-account-id: `, `originator: openclaw`, `OpenAI-Beta: responses=experimental` | +| Access token lifetime | ~24 hours (JWT) | +| Refresh grant | `grant_type=refresh_token`, `client_id=app_EMoamEEZ73f0CkXaXp7hrann` | +| Bootstrap file | `~/.codex/auth.json` (contains `refresh_token`, `account_id`) | + +--- + +## Design Principles + +1. **No real tokens in the gateway.** The gateway container must never hold OAuth access tokens, refresh tokens, or account identifiers. Only placeholder values are visible to the gateway process. + +2. **Follow existing patterns.** The implementation mirrors `GCPInjector` (file-mounted bootstrap material, proxy-side token refresh, bearer injection). No new architectural concepts are introduced. + +3. **Minimal CRD surface.** One new credential type with no additional config struct — only a `secretRef` to the auth.json file. Users who already understand GCP credentials will find the UX familiar. + +4. **Graceful degradation.** When the refresh token is revoked or expired, the proxy returns clear errors and the operator sets a condition on the CR. The gateway continues to function for other providers. + +--- + +## Architecture + +### Data flow + +``` +User workstation Kubernetes cluster +───────────────── ────────────────── + +codex login --device-auth + │ + ▼ +~/.codex/auth.json ──► kubectl create secret ──► Secret "codex-auth" + │ + │ (mounted as file) + ▼ + ┌─────────────┐ + │ claw-proxy │ + │ container │ + │ │ + │ reads auth.json + │ extracts refresh_token + account_id + │ calls auth.openai.com/oauth/token + │ caches access_token (auto-refresh) + │ │ + │ on request to chatgpt.com: + │ strips placeholder auth + │ injects Bearer + headers + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ gateway │ + │ container │ + │ │ + │ sends requests with + │ synthetic JWT placeholder + │ (contains account_id only) + │ to chatgpt.com via proxy + └─────────────┘ +``` + +### Comparison with GCP injector + +| Aspect | GCP (`injector_gcp.go`) | Codex OAuth (proposed) | +|--------|------------------------|------------------------| +| Bootstrap material | SA key JSON or ADC JSON | Codex `auth.json` | +| Stored as | File on proxy container | File on proxy container | +| Token minting | `google.CredentialsFromJSON` → `TokenSource` | `oauth2.Config` with `RefreshToken` → `TokenSource` | +| Token lifetime | ~1 hour | ~24 hours | +| Injected headers | `Authorization: Bearer` | `Authorization: Bearer` + `chatgpt-account-id` + `originator` + `OpenAI-Beta` | +| Token vending | Intercepts `oauth2.googleapis.com/token` | None (OpenClaw is in API-key mode) | +| Proxy egress needed | `oauth2.googleapis.com` | `auth.openai.com`, `chatgpt.com` | + +--- + +## OpenClaw Native Codex Support vs Proxy Approach + +OpenClaw has built-in Codex support through two independent mechanisms: + +1. **`codex` plugin / agent harness.** A plugin (`extensions/codex`) that spawns and manages a **Codex app-server binary** as a child process via stdio/websocket RPC. The app-server is essentially the Codex CLI agent runtime — it provides its own sandbox execution environment, native tool handling, approval flows, subagent orchestration, and code execution. When a model has `agentRuntime: { id: "codex" }`, OpenClaw delegates the entire agent turn to the app-server process rather than handling tool calls itself. + +2. **`openai-chatgpt-responses` LLM API provider.** A direct HTTP provider (`src/llm/providers/openai-chatgpt-responses.ts`) that makes `fetch()` calls to `chatgpt.com/backend-api/codex/responses` with SSE/WebSocket streaming, retry logic, and response parsing. This is the wire format layer — it handles HTTP transport and response parsing but does not provide its own agent runtime. Tool calls returned by the model are executed by OpenClaw's own tool infrastructure (MCP servers, built-in tools, terminal, etc.). + +Both mechanisms require real OAuth credentials at the gateway layer — the native flow reads tokens from `auth-profiles.json` on the PVC and the LLM provider reads an API key (actually the OAuth access token) from the model config. In both cases the gateway process holds the refresh token or access token in memory. + +**Our approach uses the `openai-chatgpt-responses` wire format (mechanism 2) with proxy-side credential injection.** This means: + +- **What we support:** Access to Codex models (GPT-5.5, GPT-5.4-mini, etc.) through OpenClaw's own agent runtime. OpenClaw sends prompts, receives tool call requests, and executes them using its own tools. Users get the full OpenClaw coding experience powered by Codex models. + +- **What we don't support (yet):** The Codex app-server agent harness (mechanism 1). This would require the Codex app-server binary installed in the container and real OAuth tokens available to the gateway for app-server authentication. Since our security model prohibits real tokens in the gateway, and the operator's purpose is to run OpenClaw (not Codex CLI), this is an intentional trade-off for this iteration. A future `spec.codexHarness` section (similar to how we handle Kubernetes support today) could add this capability — installing the binary via init container, configuring the plugin and agent harness, and determining the appropriate security boundary for app-server auth. + +The gateway sees only a synthetic placeholder API key and routes traffic through the MITM proxy. The proxy intercepts requests to `chatgpt.com`, strips the placeholder auth, and injects the real OAuth access token plus Codex-specific headers. This preserves the security boundary we maintain for every other provider. + +### Synthetic JWT placeholder + +OpenClaw's `openai-chatgpt-responses` provider extracts the `account_id` from the JWT payload of the API key **before making any HTTP request** (`extractOpenAICodexAccountId`). If the API key is not a valid JWT, the provider throws immediately — the request never reaches the proxy. + +To satisfy this client-side validation, the **controller** generates a **synthetic JWT** as the placeholder API key during reconciliation. The controller reads `auth.json` from the user's Secret, extracts the `account_id`, and builds a minimal unsigned JWT containing that claim. This JWT is set as the provider's `apiKey` in the OpenClaw config. OpenClaw successfully extracts the account ID and builds the request headers. The proxy then replaces the entire `Authorization` header and all Codex-specific headers with values derived from the real OAuth token before forwarding to `chatgpt.com`. + +The synthetic JWT: +- Contains only `{"sub":"placeholder","account_id":""}` — no secrets +- Uses the `none` algorithm (unsigned) — not usable against any real endpoint +- Is deterministic given the same `account_id` — stable across reconciles + +This means the gateway holds the `account_id` (which is non-secret metadata, not an access credential) but never the access token or refresh token. + +--- + +## Core Concepts + +### Credential type: `codexOAuth` + +A new `CredentialType` constant in the CRD (decided in Q1): + +```go +CredentialTypeCodexOAuth CredentialType = "codexOAuth" +``` + +No additional config struct is needed. The `account_id` is parsed from auth.json at proxy startup (decided in Q6), so the credential type requires only a `secretRef` pointing to the auth.json file — no CRD-level configuration fields beyond what `CredentialSpec` already provides. + +### Proxy injector: `CodexOAuthInjector` + +A new file `internal/proxy/injector_codex_oauth.go` implementing the `Injector` interface. Structurally identical to `GCPInjector`: + +1. **Init:** Reads auth.json from the mounted file path. Parses `refresh_token` and `account_id`. Fails fast with a clear error if either is missing. +2. **Token source:** Creates an `oauth2.Config` targeting `auth.openai.com/oauth/token` with the public client ID. Wraps the refresh token in an `oauth2.Token` and calls `Config.TokenSource()` to get an auto-refreshing `TokenSource`. +3. **Inject:** On each request, calls `TokenSource.Token()` (cached, auto-refreshes when near expiry), then sets: + - `Authorization: Bearer ` + - `chatgpt-account-id: ` + - `originator: openclaw` + - `OpenAI-Beta: responses=experimental` + - Any `DefaultHeaders` from the route config + +No token vending is implemented (decided in Q7). Since OpenClaw is configured in API-key mode (Q4), it won't attempt its own OAuth refresh. The proxy refreshes tokens via direct HTTP calls to `auth.openai.com`, bypassing its own route table. + +### Proxy route config + +New field on the `Route` struct in `internal/proxy/config.go`: + +```go +type Route struct { + // ... existing fields ... + CodexAuthFilePath string `json:"codexAuthFilePath,omitempty"` +} +``` + +The `account_id` is parsed from the auth.json file at proxy startup (Q6), not passed through the route config. + +And a corresponding new injector identifier in `internal/proxy/injector.go`: + +```go +case "codex_oauth": + return NewCodexOAuthInjector(route) +``` + +### Controller wiring + +The operator generates proxy config and mounts credentials following the GCP pattern: + +**Route generation** (`claw_proxy.go`): For a `codexOAuth` credential, the controller emits a proxy route targeting `chatgpt.com` with: +- `injector: "codex_oauth"` +- `codexAuthFilePath: "/etc/proxy/credentials//auth.json"` + +The `account_id` is parsed from the auth.json file at proxy startup (Q6). + +**Volume mounts** (`claw_proxy.go`): The Secret is mounted as a file on the proxy container at `/etc/proxy/credentials//auth.json`, identical to GCP's SA key mount pattern. + +**Provider config** (`claw_resource_controller.go`): The controller injects an OpenClaw provider entry using provider name `openai-oauth` with wire format `api: "openai-chatgpt-responses"` (Q2). OpenClaw is configured in API-key mode with a synthetic JWT placeholder (Q4) — no `auth-profiles.json` is generated. The synthetic JWT contains the `account_id` from auth.json so that OpenClaw's `extractOpenAICodexAccountId` validation passes client-side (see "Synthetic JWT placeholder" above). + +**Network policy**: No NetworkPolicy changes are needed. The existing `{instance}-proxy-egress` policy already allows all TCP/443 egress. The proxy's L7 route table is the real allowlist — `chatgpt.com` gets a route from the credential definition, and `auth.openai.com` is reached directly by the proxy's `oauth2.TokenSource` (bypassing the route table). This is consistent with how GCP credentials work. + +--- + +## User Experience + +### Setup flow + +```bash +# 1. Authenticate with Codex CLI (one-time, on user's workstation) +codex login --device-auth + +# 2. Create a Kubernetes Secret from the auth file +kubectl create secret generic codex-auth \ + --from-file=auth.json=$HOME/.codex/auth.json + +# 3. Reference it in the Claw CR +``` + +```yaml +apiVersion: claw.sandbox.redhat.com/v1alpha1 +kind: Claw +metadata: + name: instance +spec: + credentials: + - name: codex + type: codexOAuth + provider: openai-oauth + secretRef: + - name: codex-auth + key: auth.json +``` + +No additional config block is needed — the proxy parses `account_id` directly from the auth.json file (Q6). + +### Status reporting + +The operator reports Codex OAuth credential health via the existing `CredentialsResolved` condition. If the Secret is missing, the auth.json is malformed, or the account ID is absent, the condition transitions to `False` with a descriptive reason. + +Token refresh failures at runtime are logged by the proxy but do not directly update the CR status (the proxy is a separate process). The gateway will see HTTP 401 errors from `chatgpt.com` which surface in the OpenClaw UI. + +--- + +## Implementation Plan + +Single PR — the feature is small enough to land as one cohesive change. Most of the work is adding cases to existing switch statements following established patterns (`GCPInjector`, `CredentialTypeGCP`). + +**Proxy (`internal/proxy/`):** +1. Add `CodexAuthFilePath` field to `Route` struct in `config.go` +2. Create `internal/proxy/injector_codex_oauth.go` with `CodexOAuthInjector`: + - Parse auth.json (validate `auth_mode`, `refresh_token`, `account_id`) + - Create `oauth2.TokenSource` for auto-refresh via `auth.openai.com/oauth/token` + - Inject `Authorization`, `chatgpt-account-id`, `originator`, `OpenAI-Beta` headers +3. Add `codex_oauth` case to `NewInjector` in `injector.go` +4. Add `chatgpt-account-id` and `OpenAI-Beta` to `authHeaders` stripping in `injector.go` +5. Write unit tests in `injector_codex_oauth_test.go` + +**CRD (`api/v1alpha1/`):** +6. Add `CredentialTypeCodexOAuth` to `claw_types.go` (update kubebuilder enum marker) +7. Add `openai-oauth` to CEL type-inference rule so users can omit `type` when `provider: openai-oauth` +8. Run `make manifests generate` + +**Provider registry (`internal/controller/`):** +9. Add `openai-oauth` entry to `knownProviders` with dedicated model catalog, `CredType: codexOAuth`, `Domain: "chatgpt.com"`, `API: "openai-chatgpt-responses"` +10. Update `resolveProviderDefaults` for the new type (set domain to `chatgpt.com`) + +**Controller wiring (`internal/controller/`):** +11. Add `injectorCodexOAuth = "codex_oauth"` constant to `claw_proxy.go` +12. Add `CodexAuthFilePath` field to `proxyRoute` struct in `claw_proxy.go` (mirrors proxy `Route`) +13. Add route generation case in `buildCredentialRoute` for `codexOAuth` (set `CodexAuthFilePath`) +14. Add volume mount case in `configureProxyForCredentials` (file mount, like GCP's SA key) +15. Add validation case in `resolveCredentials` — parse auth.json from Secret, validate `auth_mode`, extract `tokens.account_id`, store on `resolvedCredential` +16. Update `injectProviders` to accept resolved credentials and generate a synthetic JWT `apiKey` for `codexOAuth` credentials using the parsed `account_id` + +**Tests and docs:** +17. Write controller tests +18. Update `PLATFORM.md` skill in `configmap.yaml` to document `codexOAuth` credential type and `openai-oauth` provider +19. Update `docs/user-guide.md` with Codex OAuth setup instructions + +--- + +## `auth.json` File Format + +The Codex CLI stores credentials at `~/.codex/auth.json`. The proxy expects a subset of this file: + +```json +{ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "eyJ...", + "refresh_token": "v1.MjE...", + "account_id": "acct_abc123def" + } +} +``` + +The proxy validates: +- `auth_mode` must be `"chatgpt"` (not API key mode) +- `tokens.refresh_token` must be non-empty +- `tokens.account_id` must be non-empty + +The `access_token` is used as the initial token to avoid an immediate refresh on startup. If it is expired, the `oauth2.TokenSource` will refresh it transparently on the first request. + +--- + +## Security Considerations + +1. **Refresh token is the crown jewel.** It is equivalent to a session credential for the user's ChatGPT account. It is stored in a Kubernetes Secret and only mounted on the proxy container — never the gateway. + +2. **Access tokens are short-lived.** Even if somehow leaked from the proxy's memory, they expire in ~24 hours. + +3. **Account ID is not secret** but is sensitive metadata. It identifies the ChatGPT account and is injected as a header. It does not grant access on its own. + +4. **Synthetic JWT prevents gateway-side credential access.** The gateway receives a synthetic JWT that contains only the `account_id` (non-secret metadata) — no access token, refresh token, or real signing key. OpenClaw's `extractOpenAICodexAccountId` succeeds, but the JWT is not usable against any real endpoint. The proxy replaces it with the real access token at the network layer. + +5. **Proxy egress scope.** Unlike the GCP wildcard domain issue (`.googleapis.com` matches thousands of APIs), Codex OAuth is scoped to exactly two domains: `chatgpt.com` and `auth.openai.com`. The attack surface is narrow. + +6. **Revocation.** If the user changes their ChatGPT password or revokes sessions, the refresh token becomes invalid. The proxy will log refresh failures and requests to `chatgpt.com` will fail with 401. The user must re-run `codex login` and update the Secret. + +--- + +## Resolved Design Questions + +All questions have been resolved. See [codex-oauth-questions.md](codex-oauth-questions.md) for full decision rationale. + +| # | Question | Decision | +|---|----------|----------| +| Q1 | Credential type naming | New dedicated `codexOAuth` type | +| Q2 | Provider identity and wire format | `openai-oauth` with `openai-chatgpt-responses` wire format | +| Q3 | Relationship to existing `openai` credential | Fully independent — no companion behavior | +| Q4 | OpenClaw auth-profiles.json configuration | API-key mode with synthetic JWT placeholder, no auth-profiles.json | +| Q5 | Network policy for auth.openai.com | No NP changes needed — proxy egress already allows TCP/443 | +| Q6 | Account ID source | Parse from auth.json at proxy startup | +| Q7 | Token vending for auth.openai.com | None needed — OpenClaw is in API-key mode | +| Q8 | Model catalog for Codex OAuth | Dedicated catalog; users can extend via `spec.config.raw` | +| Q9 | Coexistence with API key OpenAI credential | Allow both simultaneously | diff --git a/docs/proposals/codex-oauth-questions.md b/docs/proposals/codex-oauth-questions.md new file mode 100644 index 00000000..80c3e3ba --- /dev/null +++ b/docs/proposals/codex-oauth-questions.md @@ -0,0 +1,143 @@ +# Codex OAuth Provider — Design Questions + +**Status:** Resolved — all decisions finalized + +**Related:** [Design document](codex-oauth-design.md) + +Each question has options with trade-offs and a recommendation. Go through them one by one to form the design, then update the design document. + +--- + +## Q1: Credential type naming + +The new credential type needs a name in the CRD's `CredentialType` enum. This affects the user-facing API and the proxy config wire format. + +### Option A: New dedicated `codexOAuth` type +- **Pro:** Self-documenting — users immediately understand this is for Codex subscription auth +- **Pro:** Clean separation from the existing `oauth2` type (which is `client_credentials` grant) +- **Pro:** Codex-specific validation (auth.json format, `auth_mode: "chatgpt"`) belongs on its own type +- **Con:** Adds one more value to the `CredentialType` enum + +**Decision:** Option A — dedicated `codexOAuth` type. The flow is sufficiently distinct (file-based bootstrap, hardcoded public client ID, Codex-specific headers, auth.json parsing) that it deserves its own type. + +_Considered and rejected: Option B — extend existing `oauth2` with a `grantType` field (overloads `oauth2` with two different flows, complicates validation with conditional required fields)_ + +--- + +## Q2: Provider identity and wire format + +When the operator generates the `models.providers` entry in `openclaw.json`, what provider name and API wire format should it use? Upstream OpenClaw has renamed `openai-codex` / `openai-codex-responses` to `openai` / `openai-chatgpt-responses`. + +### Option C: Use a distinct provider name with the upstream wire format +- **Pro:** Avoids collision with the existing `openai` provider +- **Pro:** Uses the correct upstream wire format (`openai-chatgpt-responses`) +- **Con:** The provider name is operator-specific (upstream uses `openai` for everything, distinguishing by auth profile) + +**Decision:** Option C — distinct provider name `openai-oauth` with the upstream `openai-chatgpt-responses` wire format. Upstream OpenClaw uses a single `openai` provider for both API key and OAuth, differentiating at the auth profile layer. Our operator needs a distinct key because `knownProviders` drives proxy routing, credential injection, and target domain — one entry can't serve both `api.openai.com` (bearer) and `chatgpt.com` (OAuth refresh). `openai-oauth` mirrors upstream's mental model (same provider, different auth shape) while remaining clearly distinct from the existing `openai` entry. + +_Considered and rejected: Option A (legacy names may be deprecated upstream), Option B (collides with existing `openai` provider key at config, proxy route, and path prefix layers). Also considered `openai-chatgpt` and `chatgpt` as provider names; `openai-oauth` better reflects the auth-shape distinction that upstream uses._ + +--- + +## Q3: Relationship to existing `openai` credential + +Users may want both Codex OAuth (for ChatGPT subscription models) and an OpenAI API key (for Platform API features like embeddings, DALL-E, or as a fallback). How should these coexist? + +### Option A: Fully independent — user configures both separately +- **Pro:** Simple, explicit, no magic +- **Pro:** Each credential has its own provider entry, no ambiguity +- **Con:** User must configure two credentials for what is conceptually "OpenAI" + +**Decision:** Option A — fully independent. Each credential type (`bearer` for `openai`, `codexOAuth` for `openai-oauth`) is configured separately. No companion relationship between them. The existing `openai` + `openai-codex` companion behavior is unchanged for API key users. + +_Considered and rejected: Option B (companion pattern assumes shared credential, which is false for OAuth vs API key), Option C (adds complexity for marginal benefit — suppressing a companion is unnecessary when the provider names are already distinct)_ + +--- + +## Q4: OpenClaw auth-profiles.json configuration + +OpenClaw's native Codex OAuth support uses `auth-profiles.json` to store OAuth tokens and `auth.order` in `openclaw.json` to route models through OAuth. Since our proxy handles all auth, we need to decide how to configure OpenClaw's provider to use the proxy-injected credentials instead. + +### Option A: Configure as API-key provider (no auth-profiles.json) +- **Pro:** Simple — OpenClaw treats the Codex provider like any other API-key provider. It sends the placeholder key, proxy replaces it. +- **Pro:** No `auth-profiles.json` needed — zero risk of token leakage through config files +- **Pro:** Consistent with how all other providers work in the operator +- **Con:** OpenClaw might try to use the `openai-completions` or `openai-responses` wire format instead of `openai-chatgpt-responses` unless `api` is explicitly set + +**Decision:** Option A — configure as API-key provider with placeholder key, explicit `api: "openai-chatgpt-responses"` and `baseUrl`. No `auth-profiles.json` generated. The proxy intercepts the placeholder key and injects the real OAuth token. Consistent with all other providers in the operator. + +_Considered and rejected: Option B (dummy tokens in auth-profiles.json are confusing and OpenClaw may attempt to refresh them)_ + +--- + +## Q5: Network policy for auth.openai.com + +The proxy needs to reach `auth.openai.com:443` to refresh tokens. The operator manages egress NetworkPolicies that restrict which domains the pod can access. + +### Option A: Auto-add auth.openai.com as a builtin passthrough when codexOAuth is present +- **Pro:** Zero user config — the operator knows this is required and adds it +- **Pro:** Consistent with how GCP auto-adds `oauth2.googleapis.com` +- **Con:** None significant + +**Decision:** Option A — auto-add `auth.openai.com:443` and `chatgpt.com:443` egress when any `codexOAuth` credential is present. Follows the GCP pattern. + +_Considered and rejected: Option B (poor UX — forgetting causes silent token refresh failures, inconsistent with GCP behavior)_ + +--- + +## Q6: Account ID source + +The `chatgpt-account-id` header requires the user's ChatGPT account ID. This value can come from multiple sources. + +### Option A: Parse from auth.json at proxy startup +- **Pro:** Zero user config — the proxy reads `tokens.account_id` from the auth.json file +- **Pro:** Codex CLI always writes `account_id` to auth.json +- **Con:** If auth.json somehow lacks `account_id`, the proxy can't inject the header + +**Decision:** Option A — parse `account_id` from auth.json at proxy startup. The Codex CLI always writes it. No additional CRD fields needed beyond `secretRef`. Proxy fails fast with a clear error if the field is missing. + +_Considered and rejected: Option B (redundant — same info is in the Secret, bad UX to require manual extraction), Option C (over-engineered for a field that's always present)_ + +--- + +## Q7: Token vending for auth.openai.com + +The GCP injector intercepts `POST oauth2.googleapis.com/token` and returns a dummy response so that Google SDK clients (which try to fetch their own tokens using placeholder ADC) get a valid-looking response. Should we do the same for `auth.openai.com`? + +### Option B: No token vending — just passthrough for proxy's own refresh calls +- **Pro:** Simpler — no interception logic +- **Pro:** If we configure OpenClaw as API-key mode (Q4 decision), there are no token refresh attempts to intercept + +**Decision:** Option B — no token vending. Since OpenClaw is configured in API-key mode (Q4), it won't attempt OAuth refresh. The proxy makes its own direct HTTP calls to `auth.openai.com` for refresh via `oauth2.TokenSource`, bypassing the proxy route table. No interception needed. + +_Considered and rejected: Option A (unnecessary — OpenClaw doesn't know it's OAuth), Option C (over-engineered — blocking gateway from auth.openai.com requires splitting routes)_ + +--- + +## Q8: Model catalog for Codex OAuth + +The operator maintains a hardcoded model catalog per provider in `knownProviders`. What models should the Codex OAuth provider expose? + +### Option A: Dedicated Codex model catalog +- **Pro:** Accurate — Codex backend only supports specific models (gpt-5.5, gpt-5.4, gpt-5.4-mini, etc.) +- **Pro:** Users see the right models in the picker +- **Con:** Must be maintained as OpenAI adds/removes models + +**Decision:** Option A — dedicated Codex model catalog in `knownProviders`. The catalog provides sensible defaults (3-5 models) for the model picker. Users can always add more models via `spec.config.raw`, which merges into the OpenClaw config. + +_Considered and rejected: Option B (empty model picker by default, bad UX), Option C (model sets differ between Platform API and Codex backend)_ + +--- + +## Q9: Coexistence with API key OpenAI credential + +Can a user have both a `bearer` credential with `provider: "openai"` (API key) and a `codexOAuth` credential with the Codex OAuth provider active simultaneously? + +### Option A: Allow both — they are independent providers +- **Pro:** Users can use API key for embeddings/platform features and Codex OAuth for agent turns +- **Pro:** No collision if they use distinct provider names (per Q2) +- **Pro:** OpenClaw can use either based on model routing + +**Decision:** Option A — allow both. They target different domains (`api.openai.com` vs `chatgpt.com`), use different auth mechanisms, and map to different providers. No technical reason to prevent coexistence. + +_Considered and rejected: Option B (artificial restriction with no technical justification, limits flexibility)_ diff --git a/docs/user-guide.md b/docs/user-guide.md index 4820ac57..52945194 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -10,7 +10,7 @@ export NS=my-claw-namespace ## LLM Providers -For known providers (`google`, `anthropic`, `openai`, `xai`, `openrouter`), the operator automatically infers `type`, `domain`, and auth headers — you only need `name`, `provider`, and `secretRef`. You can still override any inferred field if needed (e.g., routing through a custom proxy or using a different credential type). +For known providers (`google`, `anthropic`, `openai`, `openai-oauth`, `xai`, `openrouter`), the operator automatically infers `type`, `domain`, and auth headers — you only need `name`, `provider`, and `secretRef`. You can still override any inferred field if needed (e.g., routing through a custom proxy or using a different credential type). The `provider` field also accepts arbitrary strings for custom/self-hosted providers — see [Custom / Self-Hosted Providers](#custom--self-hosted-providers) below. For the best experience with custom endpoints, use `spec.customProviders` which provides full control over `baseUrl`, wire format, and model registration. @@ -114,6 +114,46 @@ EOF > **GPT-5.x models:** OpenClaw routes newer GPT models (gpt-5.5, gpt-5.4, gpt-5.4-mini) through an internal provider called `openai-codex`. The operator handles this automatically — when you configure an `openai` credential, a companion `openai-codex` provider entry is created with the same endpoint and credentials. No additional configuration is needed. +### Codex OAuth (ChatGPT Subscription) + +Uses Codex models via your ChatGPT Plus/Pro/Team subscription. Instead of a pay-per-use API key, this uses OAuth refresh tokens from the Codex CLI. + +**1. Authenticate with the Codex CLI** (one-time, on your workstation): + +```sh +codex login --device-auth +``` + +**2. Create the Secret:** + +```sh +oc create secret generic codex-auth \ + --from-file=auth.json=$HOME/.codex/auth.json \ + -n $NS +``` + +**3. Apply the Claw CR:** + +```sh +oc apply -n $NS -f - < **Coexistence with OpenAI API keys:** The `openai-oauth` provider is fully independent from `openai`. You can use both simultaneously — Codex OAuth for subscription models (GPT-5.5, GPT-5.4 Mini) and OpenAI API key for platform features like embeddings. + ### xAI (Grok) Uses the xAI API with a bearer token. diff --git a/internal/assets/manifests/claw/configmap.yaml b/internal/assets/manifests/claw/configmap.yaml index 7f35b479..040c5f67 100644 --- a/internal/assets/manifests/claw/configmap.yaml +++ b/internal/assets/manifests/claw/configmap.yaml @@ -617,6 +617,34 @@ data: The proxy exchanges credentials for a bearer token and injects it on each request. + ## Codex OAuth (`type: codexOAuth`) + + For ChatGPT Plus/Pro/Team subscription users who want to use Codex models + (GPT-5.5, GPT-5.4 Mini) via the ChatGPT backend: + + ```yaml + - name: codex + type: codexOAuth + provider: openai-oauth + secretRef: + - name: codex-auth + key: auth.json + ``` + + Setup: + 1. Run `codex login --device-auth` on your workstation to authenticate + 2. Create a Secret: `kubectl create secret generic codex-auth --from-file=auth.json=$HOME/.codex/auth.json` + 3. Reference it in the Claw CR as shown above + + The Secret contains a Codex CLI `auth.json` file with `auth_mode: "chatgpt"`, + a `refresh_token`, and an `account_id`. The proxy auto-refreshes OAuth tokens + and injects `Authorization`, `chatgpt-account-id`, `originator`, and `OpenAI-Beta` + headers. The gateway never sees real tokens. + + The `openai-oauth` provider and `openai` provider are fully independent — you + can use both simultaneously (Codex OAuth for subscription models, OpenAI API key + for platform features like embeddings). + # Memory Search Memory search (semantic recall across sessions) is auto-configured when @@ -1042,8 +1070,8 @@ data: An exact domain (e.g., `api.example.com`) matches only that host. If the service requires credential injection (API key, bearer token), use the - corresponding credential type (`apiKey`, `bearer`, `oauth2`, `pathToken`) with a - `secretRef` pointing to a Kubernetes Secret containing the credential. + corresponding credential type (`apiKey`, `bearer`, `oauth2`, `codexOAuth`, `pathToken`) + with a `secretRef` pointing to a Kubernetes Secret containing the credential. # Plugins diff --git a/internal/controller/claw_configmap_test.go b/internal/controller/claw_configmap_test.go index 5e694667..2c590f2d 100644 --- a/internal/controller/claw_configmap_test.go +++ b/internal/controller/claw_configmap_test.go @@ -60,7 +60,7 @@ func TestInjectProvidersVertexSDK(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "anthropic-vertex") @@ -87,7 +87,7 @@ func TestInjectProvidersVertexSDK(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) av := providers["anthropic-vertex"].(map[string]any) @@ -109,7 +109,7 @@ func TestInjectProvidersVertexSDK(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "meta-vertex") @@ -133,7 +133,7 @@ func TestInjectProvidersVertexSDK(t *testing.T) { }, } - err := injectProviders(config, testClawWithCredentials(credentials)) + err := injectProviders(config, testClawWithCredentials(credentials), nil) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate provider") assert.Contains(t, err.Error(), "anthropic-vertex") @@ -154,7 +154,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "google") @@ -181,7 +181,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) assert.Contains(t, providers, "google") @@ -201,7 +201,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) assert.Empty(t, providers) @@ -222,7 +222,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "google") @@ -243,7 +243,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "anthropic") @@ -266,7 +266,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) assert.Empty(t, providers, "pathToken credentials should not generate provider entries") @@ -279,7 +279,7 @@ func TestInjectProviders(t *testing.T) { {Name: "gemini-2", Type: clawv1alpha1.CredentialTypeAPIKey, Provider: "google", Domain: "generativelanguage.googleapis.com"}, } - err := injectProviders(config, testClawWithCredentials(credentials)) + err := injectProviders(config, testClawWithCredentials(credentials), nil) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate provider") assert.Contains(t, err.Error(), "google") @@ -296,7 +296,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "openai") @@ -317,7 +317,7 @@ func TestInjectProviders(t *testing.T) { {Name: "openai", Type: clawv1alpha1.CredentialTypeAPIKey, Provider: "openai", Domain: "api.openai.com"}, } - err := injectProviders(config, testClawWithCredentials(credentials)) + err := injectProviders(config, testClawWithCredentials(credentials), nil) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate provider") assert.Contains(t, err.Error(), "openai-codex") @@ -335,7 +335,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "xai") @@ -355,7 +355,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "openrouter") @@ -377,7 +377,7 @@ func TestInjectProviders(t *testing.T) { }, } - require.NoError(t, injectProviders(config, testClawWithCredentials(credentials))) + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "custom-llm") @@ -386,6 +386,99 @@ func TestInjectProviders(t *testing.T) { assert.Equal(t, "ah-ah-ah-you-didnt-say-the-magic-word", entry["apiKey"]) assert.NotContains(t, entry, "api", "unknown providers should use OpenClaw default wire format") }) + + t.Run("should inject codexOAuth provider with synthetic JWT apiKey", func(t *testing.T) { + config := map[string]any{"models": map[string]any{"providers": map[string]any{}}} + credentials := []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + Domain: "chatgpt.com", + }, + } + resolvedCreds := []resolvedCredential{ + { + CredentialSpec: credentials[0], + CodexOAuth: &codexOAuthData{AccountID: "acct_test123"}, + }, + } + + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), resolvedCreds)) + + providers := providersFromConfig(t, config) + require.Contains(t, providers, "openai-oauth") + entry := providers["openai-oauth"].(map[string]any) + assert.Equal(t, "https://chatgpt.com", entry["baseUrl"]) + assert.Equal(t, "openai-chatgpt-responses", entry["api"]) + + apiKey, ok := entry["apiKey"].(string) + require.True(t, ok, "apiKey should be a string") + assert.NotEqual(t, placeholderAPIKey, apiKey, "codexOAuth should use synthetic JWT, not placeholder") + assert.Contains(t, apiKey, ".", "apiKey should look like a JWT (contain dots)") + }) + + t.Run("should use placeholder when codexOAuth resolvedCreds is nil", func(t *testing.T) { + config := map[string]any{"models": map[string]any{"providers": map[string]any{}}} + credentials := []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + Domain: "chatgpt.com", + }, + } + + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), nil)) + + providers := providersFromConfig(t, config) + require.Contains(t, providers, "openai-oauth") + entry := providers["openai-oauth"].(map[string]any) + assert.Equal(t, placeholderAPIKey, entry["apiKey"]) + }) + + t.Run("should inject openai-oauth model catalog", func(t *testing.T) { + config := map[string]any{"models": map[string]any{"providers": map[string]any{}}} + credentials := []clawv1alpha1.CredentialSpec{ + {Name: "codex", Type: clawv1alpha1.CredentialTypeCodexOAuth, Provider: "openai-oauth", Domain: "chatgpt.com"}, + } + + injectModelCatalog(config, testClawWithCredentials(credentials)) + + models := config["agents"].(map[string]any)["defaults"].(map[string]any)["models"].(map[string]any) + assert.Contains(t, models, "openai-oauth/gpt-5.5") + assert.Contains(t, models, "openai-oauth/gpt-5.4-mini") + entry := models["openai-oauth/gpt-5.5"].(map[string]any) + assert.Equal(t, "GPT-5.5", entry["alias"]) + }) + + t.Run("should inject both openai and openai-oauth providers independently", func(t *testing.T) { + config := map[string]any{"models": map[string]any{"providers": map[string]any{}}} + credentials := []clawv1alpha1.CredentialSpec{ + {Name: "openai", Type: clawv1alpha1.CredentialTypeBearer, Provider: "openai", Domain: "api.openai.com"}, + {Name: "codex", Type: clawv1alpha1.CredentialTypeCodexOAuth, Provider: "openai-oauth", Domain: "chatgpt.com"}, + } + resolvedCreds := []resolvedCredential{ + {CredentialSpec: credentials[0]}, + {CredentialSpec: credentials[1], CodexOAuth: &codexOAuthData{AccountID: "acct_dual"}}, + } + + require.NoError(t, injectProviders(config, testClawWithCredentials(credentials), resolvedCreds)) + + providers := providersFromConfig(t, config) + require.Contains(t, providers, "openai") + require.Contains(t, providers, "openai-oauth") + + openaiEntry := providers["openai"].(map[string]any) + assert.Equal(t, placeholderAPIKey, openaiEntry["apiKey"], + "openai should use placeholder, not synthetic JWT") + + oauthEntry := providers["openai-oauth"].(map[string]any) + apiKey := oauthEntry["apiKey"].(string) + assert.NotEqual(t, placeholderAPIKey, apiKey, + "openai-oauth should use synthetic JWT") + assert.Contains(t, apiKey, ".", "apiKey should be a JWT") + }) } // --- Model catalog injection tests --- @@ -721,7 +814,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - require.NoError(t, injectProviders(config, claw)) + require.NoError(t, injectProviders(config, claw, nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "my-vllm") @@ -751,7 +844,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - require.NoError(t, injectProviders(config, claw)) + require.NoError(t, injectProviders(config, claw, nil)) providers := providersFromConfig(t, config) ollama := providers["ollama"].(map[string]any) @@ -774,7 +867,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - require.NoError(t, injectProviders(config, claw)) + require.NoError(t, injectProviders(config, claw, nil)) providers := providersFromConfig(t, config) vllm := providers["my-vllm"].(map[string]any) @@ -801,7 +894,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - require.NoError(t, injectProviders(config, claw)) + require.NoError(t, injectProviders(config, claw, nil)) providers := providersFromConfig(t, config) assert.Contains(t, providers, "google") @@ -827,7 +920,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - err := injectProviders(config, claw) + err := injectProviders(config, claw, nil) require.Error(t, err) assert.Contains(t, err.Error(), "duplicate provider") assert.Contains(t, err.Error(), "google") @@ -853,7 +946,7 @@ func TestInjectCustomProviders(t *testing.T) { }, ) - require.NoError(t, injectProviders(config, claw)) + require.NoError(t, injectProviders(config, claw, nil)) providers := providersFromConfig(t, config) require.Contains(t, providers, "vllm") diff --git a/internal/controller/claw_credentials.go b/internal/controller/claw_credentials.go index 5d9b1e4a..3592758d 100644 --- a/internal/controller/claw_credentials.go +++ b/internal/controller/claw_credentials.go @@ -19,10 +19,13 @@ package controller import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "net/url" + "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -33,6 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" clawv1alpha1 "github.com/codeready-toolchain/claw-operator/api/v1alpha1" + "github.com/codeready-toolchain/claw-operator/internal/proxy" ) // kubeconfigCluster holds parsed cluster info from a kubeconfig. @@ -59,10 +63,16 @@ type kubeconfigData struct { RawBytes []byte } -// resolvedCredential wraps a CredentialSpec with parsed kubeconfig data (non-nil for kubernetes type only). +// codexOAuthData holds parsed data from a Codex auth.json file. +type codexOAuthData struct { + AccountID string +} + +// resolvedCredential wraps a CredentialSpec with parsed data for types that need it. type resolvedCredential struct { clawv1alpha1.CredentialSpec KubeConfig *kubeconfigData + CodexOAuth *codexOAuthData } // primarySecret returns the first SecretRefEntry, or nil if the slice is empty. @@ -237,6 +247,16 @@ func (r *ClawResourceReconciler) resolveCredentials(ctx context.Context, instanc } rc.KubeConfig = kd } + + if cred.Type == clawv1alpha1.CredentialTypeCodexOAuth { + auth, err := proxy.ParseCodexAuthJSON(data) + if err != nil { + errs = append(errs, fmt.Errorf("credential %q: %w", cred.Name, err)) + credFailed = true + continue + } + rc.CodexOAuth = &codexOAuthData{AccountID: auth.AccountID} + } } if credFailed { continue @@ -512,3 +532,30 @@ func hasKubernetesCredentials(creds []resolvedCredential) bool { } return false } + +// codexOAuthAccountID returns the account ID for the named codexOAuth credential, or empty string. +func codexOAuthAccountID(creds []resolvedCredential, credName string) string { + for i := range creds { + if creds[i].Name == credName && creds[i].CodexOAuth != nil { + return creds[i].CodexOAuth.AccountID + } + } + return "" +} + +// buildSyntheticJWT constructs an unsigned JWT containing the account_id claim. +// OpenClaw's openai-chatgpt-responses provider extracts account_id from the JWT +// payload before making requests. This JWT is never verified by any real endpoint — +// the proxy replaces the Authorization header with the real access token. +func buildSyntheticJWT(accountID string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + + payload := map[string]string{ + "sub": "placeholder", + "account_id": accountID, + } + payloadJSON, _ := json.Marshal(payload) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + + return strings.Join([]string{header, payloadB64, ""}, ".") +} diff --git a/internal/controller/claw_credentials_test.go b/internal/controller/claw_credentials_test.go index a2f3b7f0..ba460eb9 100644 --- a/internal/controller/claw_credentials_test.go +++ b/internal/controller/claw_credentials_test.go @@ -18,7 +18,9 @@ package controller import ( "context" + "encoding/base64" "encoding/json" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -993,3 +995,275 @@ func TestFindClawsReferencingSecret(t *testing.T) { require.Len(t, requests, 1, "should return exactly one request, not duplicate") }) } + +// --- Codex OAuth credential tests --- + +func TestCodexOAuthCredentialValidation(t *testing.T) { + ctx := context.Background() + + t.Run("should resolve codexOAuth credential with valid auth.json", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + authJSON := `{"auth_mode":"chatgpt","tokens":{"access_token":"eyJ...","refresh_token":"v1.abc","account_id":"acct_test123"}}` + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "codex-auth", Namespace: namespace}, + Data: map[string][]byte{"auth.json": []byte(authJSON)}, + } + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "codex-auth", Key: "auth.json"}}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKey{ + Name: testInstanceName, Namespace: namespace, + }}) + require.NoError(t, err) + + updated := &clawv1alpha1.Claw{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: testInstanceName, Namespace: namespace, + }, updated)) + + var credsOK bool + for _, c := range updated.Status.Conditions { + if c.Type == clawv1alpha1.ConditionTypeCredentialsResolved && c.Status == metav1.ConditionTrue { + credsOK = true + } + } + assert.True(t, credsOK, "CredentialsResolved should be True") + }) + + t.Run("should fail when codexOAuth auth.json has wrong auth_mode", func(t *testing.T) { + t.Cleanup(func() { + _ = deleteAndWait(&corev1.Secret{}, client.ObjectKey{Name: "codex-auth-bad", Namespace: namespace}) + deleteAndWaitAllResources(t, namespace) + }) + + authJSON := `{"auth_mode":"api_key","tokens":{"refresh_token":"v1.abc","account_id":"acct_123"}}` + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "codex-auth-bad", Namespace: namespace}, + Data: map[string][]byte{"auth.json": []byte(authJSON)}, + } + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "codex-auth-bad", Key: "auth.json"}}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKey{ + Name: testInstanceName, Namespace: namespace, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth_mode") + }) + + t.Run("should generate proxy config with codexOAuth route after reconciliation", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + authJSON := `{"auth_mode":"chatgpt","tokens":{"access_token":"eyJ...","refresh_token":"v1.abc","account_id":"acct_test456"}}` + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "codex-auth-proxy", Namespace: namespace}, + Data: map[string][]byte{"auth.json": []byte(authJSON)}, + } + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "codex-auth-proxy", Key: "auth.json"}}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + cm := &corev1.ConfigMap{} + waitFor(t, timeout, interval, func() bool { + return k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyConfigMapName(testInstanceName), Namespace: namespace, + }, cm) == nil + }, "proxy config ConfigMap should exist") + + var cfg proxyConfig + require.NoError(t, json.Unmarshal([]byte(cm.Data["proxy-config.json"]), &cfg)) + + var foundRoute bool + for _, r := range cfg.Routes { + if r.Domain == "chatgpt.com" { + foundRoute = true + assert.Equal(t, injectorCodexOAuth, r.Injector) + assert.Equal(t, "/etc/proxy/credentials/codex/auth.json", r.CodexAuthFilePath) + } + } + assert.True(t, foundRoute, "proxy config should contain chatgpt.com route with codex_oauth injector") + }) + + t.Run("should mount codexOAuth auth.json on proxy deployment", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + authJSON := `{"auth_mode":"chatgpt","tokens":{"access_token":"eyJ...","refresh_token":"v1.abc","account_id":"acct_mount"}}` + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "codex-auth-mount", Namespace: namespace}, + Data: map[string][]byte{"auth.json": []byte(authJSON)}, + } + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "codex-auth-mount", Key: "auth.json"}}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + deploy := &appsv1.Deployment{} + waitFor(t, timeout, interval, func() bool { + return k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyDeploymentName(testInstanceName), Namespace: namespace, + }, deploy) == nil + }, "proxy deployment should exist") + + var proxyContainer *corev1.Container + for i := range deploy.Spec.Template.Spec.Containers { + if deploy.Spec.Template.Spec.Containers[i].Name == ClawProxyContainerName { + proxyContainer = &deploy.Spec.Template.Spec.Containers[i] + break + } + } + require.NotNil(t, proxyContainer, "proxy container should exist") + + var foundMount bool + for _, vm := range proxyContainer.VolumeMounts { + if vm.Name == "cred-codex" && vm.MountPath == "/etc/proxy/credentials/codex" { + foundMount = true + assert.True(t, vm.ReadOnly) + } + } + assert.True(t, foundMount, "proxy should have codex auth volume mount") + + var foundVol bool + for _, vol := range deploy.Spec.Template.Spec.Volumes { + if vol.Name == "cred-codex" { + foundVol = true + require.NotNil(t, vol.Secret) + assert.Equal(t, "codex-auth-mount", vol.Secret.SecretName) + } + } + assert.True(t, foundVol, "proxy should have codex auth volume") + }) +} + +// --- Synthetic JWT tests --- + +func TestBuildSyntheticJWT(t *testing.T) { + jwt := buildSyntheticJWT("acct_test123") + parts := strings.Split(jwt, ".") + require.Len(t, parts, 3, "JWT should have 3 parts (header.payload.signature)") + assert.Empty(t, parts[2], "unsigned JWT should have empty signature") + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var payload map[string]string + require.NoError(t, json.Unmarshal(payloadBytes, &payload)) + assert.Equal(t, "placeholder", payload["sub"]) + assert.Equal(t, "acct_test123", payload["account_id"]) + + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + var header map[string]string + require.NoError(t, json.Unmarshal(headerBytes, &header)) + assert.Equal(t, "none", header["alg"]) + assert.Equal(t, "JWT", header["typ"]) +} + +func TestBuildSyntheticJWTDeterministic(t *testing.T) { + jwt1 := buildSyntheticJWT("acct_abc") + jwt2 := buildSyntheticJWT("acct_abc") + assert.Equal(t, jwt1, jwt2, "same account_id should produce same JWT") + + jwt3 := buildSyntheticJWT("acct_xyz") + assert.NotEqual(t, jwt1, jwt3, "different account_id should produce different JWT") +} + +func TestResolveProviderDefaultsCodexOAuth(t *testing.T) { + cred := clawv1alpha1.CredentialSpec{ + Name: "codex", + Provider: "openai-oauth", + } + err := resolveProviderDefaults(&cred) + require.NoError(t, err) + assert.Equal(t, clawv1alpha1.CredentialTypeCodexOAuth, cred.Type) + assert.Equal(t, "chatgpt.com", cred.Domain) +} + +func TestCodexOAuthAccountID(t *testing.T) { + creds := []resolvedCredential{ + { + CredentialSpec: clawv1alpha1.CredentialSpec{Name: "openai", Type: clawv1alpha1.CredentialTypeBearer}, + }, + { + CredentialSpec: clawv1alpha1.CredentialSpec{Name: "codex", Type: clawv1alpha1.CredentialTypeCodexOAuth}, + CodexOAuth: &codexOAuthData{AccountID: "acct_found"}, + }, + } + + t.Run("returns account ID for matching credential", func(t *testing.T) { + assert.Equal(t, "acct_found", codexOAuthAccountID(creds, "codex")) + }) + + t.Run("returns empty for non-matching name", func(t *testing.T) { + assert.Empty(t, codexOAuthAccountID(creds, "nonexistent")) + }) + + t.Run("returns empty for credential without CodexOAuth data", func(t *testing.T) { + assert.Empty(t, codexOAuthAccountID(creds, "openai")) + }) + + t.Run("returns empty for nil slice", func(t *testing.T) { + assert.Empty(t, codexOAuthAccountID(nil, "codex")) + }) +} diff --git a/internal/controller/claw_providers.go b/internal/controller/claw_providers.go index 6e38c9a8..9d282ac6 100644 --- a/internal/controller/claw_providers.go +++ b/internal/controller/claw_providers.go @@ -125,6 +125,15 @@ var knownProviders = map[string]providerDefaults{ "openai-codex": { API: "openai-codex-responses", }, + "openai-oauth": { + CredType: clawv1alpha1.CredentialTypeCodexOAuth, + Domain: "chatgpt.com", + API: "openai-chatgpt-responses", + Models: []modelEntry{ + {Name: "gpt-5.5", Alias: "GPT-5.5"}, + {Name: "gpt-5.4-mini", Alias: "GPT-5.4 Mini"}, + }, + }, "xai": { CredType: clawv1alpha1.CredentialTypeBearer, Domain: "api.x.ai", @@ -215,6 +224,13 @@ func resolveProviderDefaults(cred *clawv1alpha1.CredentialSpec) error { cred.Domain = ".googleapis.com" } + case clawv1alpha1.CredentialTypeCodexOAuth: + if defaults, ok := knownProviders[cred.Provider]; ok && defaults.Domain != "" { + if cred.Domain == "" { + cred.Domain = defaults.Domain + } + } + case clawv1alpha1.CredentialTypeKubernetes: return nil } diff --git a/internal/controller/claw_providers_test.go b/internal/controller/claw_providers_test.go index 4fc493c1..6142bdc7 100644 --- a/internal/controller/claw_providers_test.go +++ b/internal/controller/claw_providers_test.go @@ -93,9 +93,10 @@ func TestKnownProvidersConsistency(t *testing.T) { } } notOnVertex := map[string]bool{ - "openai": true, - "xai": true, - "openrouter": true, + "openai": true, + "openai-oauth": true, + "xai": true, + "openrouter": true, } for provider, defaults := range knownProviders { diff --git a/internal/controller/claw_proxy.go b/internal/controller/claw_proxy.go index 90b58173..ba118ac4 100644 --- a/internal/controller/claw_proxy.go +++ b/internal/controller/claw_proxy.go @@ -54,27 +54,29 @@ const ( injectorPathToken = "path_token" injectorOAuth2 = "oauth2" injectorKubernetes = "kubernetes" + injectorCodexOAuth = "codex_oauth" ) // proxyRoute is a single route entry in the proxy config JSON. type proxyRoute struct { - Domain string `json:"domain"` - Injector string `json:"injector"` - Header string `json:"header,omitempty"` - ValuePrefix string `json:"valuePrefix,omitempty"` - EnvVar string `json:"envVar,omitempty"` - SAFilePath string `json:"saFilePath,omitempty"` - GCPProject string `json:"gcpProject,omitempty"` - GCPLocation string `json:"gcpLocation,omitempty"` - PathPrefix string `json:"pathPrefix,omitempty"` - Upstream string `json:"upstream,omitempty"` - ClientID string `json:"clientID,omitempty"` - TokenURL string `json:"tokenURL,omitempty"` - Scopes []string `json:"scopes,omitempty"` - DefaultHeaders map[string]string `json:"defaultHeaders,omitempty"` - KubeconfigPath string `json:"kubeconfigPath,omitempty"` - CACert string `json:"caCert,omitempty"` - AllowedPaths []string `json:"allowedPaths,omitempty"` + Domain string `json:"domain"` + Injector string `json:"injector"` + Header string `json:"header,omitempty"` + ValuePrefix string `json:"valuePrefix,omitempty"` + EnvVar string `json:"envVar,omitempty"` + SAFilePath string `json:"saFilePath,omitempty"` + GCPProject string `json:"gcpProject,omitempty"` + GCPLocation string `json:"gcpLocation,omitempty"` + PathPrefix string `json:"pathPrefix,omitempty"` + Upstream string `json:"upstream,omitempty"` + ClientID string `json:"clientID,omitempty"` + TokenURL string `json:"tokenURL,omitempty"` + Scopes []string `json:"scopes,omitempty"` + DefaultHeaders map[string]string `json:"defaultHeaders,omitempty"` + KubeconfigPath string `json:"kubeconfigPath,omitempty"` + CACert string `json:"caCert,omitempty"` + AllowedPaths []string `json:"allowedPaths,omitempty"` + CodexAuthFilePath string `json:"codexAuthFilePath,omitempty"` } // proxyConfig is the top-level proxy configuration JSON. @@ -271,6 +273,9 @@ func buildCredentialRoute(cred clawv1alpha1.CredentialSpec) proxyRoute { route.TokenURL = cred.OAuth2.TokenURL route.Scopes = cred.OAuth2.Scopes } + case clawv1alpha1.CredentialTypeCodexOAuth: + route.Injector = injectorCodexOAuth + route.CodexAuthFilePath = "/etc/proxy/credentials/" + cred.Name + "/auth.json" } return route @@ -554,6 +559,29 @@ func configureProxyForCredentials(objects []*unstructured.Unstructured, instance "mountPath": "/etc/proxy/credentials/" + cred.Name, "readOnly": true, }) + + case clawv1alpha1.CredentialTypeCodexOAuth: + if ref == nil { + continue + } + volName := "cred-" + cred.Name + volumes = append(volumes, map[string]any{ + "name": volName, + "secret": map[string]any{ + "secretName": ref.Name, + "items": []any{ + map[string]any{ + "key": ref.Key, + "path": "auth.json", + }, + }, + }, + }) + volumeMounts = append(volumeMounts, map[string]any{ + "name": volName, + "mountPath": "/etc/proxy/credentials/" + cred.Name, + "readOnly": true, + }) } } diff --git a/internal/controller/claw_proxy_test.go b/internal/controller/claw_proxy_test.go index 04d191e4..d22b39ff 100644 --- a/internal/controller/claw_proxy_test.go +++ b/internal/controller/claw_proxy_test.go @@ -1117,6 +1117,73 @@ func TestConfigureProxyForCredentials(t *testing.T) { assert.True(t, envNames["CRED_OPENAI"], "should have CRED_OPENAI") }) + t.Run("should add codexOAuth volume with items projection", func(t *testing.T) { + const testCodexCredVolume = "cred-codex" + + instance, objects := buildObjects(t) + creds := []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "codex-auth", Key: "auth.json"}}, + Domain: "chatgpt.com", + }, + } + require.NoError(t, configureProxyForCredentials(objects, instance, toResolved(creds))) + + container := findProxyContainer(t, objects) + mounts, _, _ := unstructured.NestedSlice(container, "volumeMounts") + + var foundMount bool + for _, m := range mounts { + mount := m.(map[string]any) + if mount["name"] == testCodexCredVolume { + assert.Equal(t, "/etc/proxy/credentials/codex", mount["mountPath"]) + assert.Equal(t, true, mount["readOnly"]) + foundMount = true + } + } + assert.True(t, foundMount, "codexOAuth credential volume mount should be present") + + volumes := findVolumes(t, objects) + var foundVol bool + for _, v := range volumes { + vol := v.(map[string]any) + if vol["name"] == testCodexCredVolume { + foundVol = true + secret, ok := vol["secret"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "codex-auth", secret["secretName"]) + + items := secret["items"].([]any) + require.Len(t, items, 1) + item := items[0].(map[string]any) + assert.Equal(t, "auth.json", item["key"]) + assert.Equal(t, "auth.json", item["path"]) + } + } + assert.True(t, foundVol, "codexOAuth credential volume should be present") + }) + + t.Run("should skip codexOAuth credential with nil secretRef", func(t *testing.T) { + instance, objects := buildObjects(t) + creds := []clawv1alpha1.CredentialSpec{ + { + Name: "codex-no-ref", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Domain: "chatgpt.com", + }, + } + require.NoError(t, configureProxyForCredentials(objects, instance, toResolved(creds))) + + volumes := findVolumes(t, objects) + for _, v := range volumes { + vol := v.(map[string]any) + assert.NotEqual(t, "cred-codex-no-ref", vol["name"], + "should not add volume for codexOAuth without secretRef") + } + }) + t.Run("should add kubernetes kubeconfig volume mount", func(t *testing.T) { instance, objects := buildObjects(t) creds := []resolvedCredential{ @@ -1641,3 +1708,46 @@ func TestMcpCredentialRefRoutes(t *testing.T) { assert.True(t, found, "should have an api_key route for mcp-server") }) } + +// --- Codex OAuth proxy config tests --- + +func TestGenerateProxyConfigCodexOAuth(t *testing.T) { + t.Run("should generate config with codex_oauth route for chatgpt.com", func(t *testing.T) { + credentials := []clawv1alpha1.CredentialSpec{ + { + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Provider: "openai-oauth", + SecretRef: []clawv1alpha1.SecretRefEntry{{ + Name: "codex-auth", + Key: "auth.json", + }}, + Domain: "chatgpt.com", + }, + } + + data, err := generateProxyConfig(toResolved(credentials), nil, nil) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + route := findRouteByDomain(t, cfg.Routes, "chatgpt.com") + assert.Equal(t, injectorCodexOAuth, route.Injector) + assert.Equal(t, "/etc/proxy/credentials/codex/auth.json", route.CodexAuthFilePath) + assert.Equal(t, "/codex", route.PathPrefix) + assert.Equal(t, "https://chatgpt.com", route.Upstream) + }) +} + +func TestBuildCredentialRouteCodexOAuth(t *testing.T) { + cred := clawv1alpha1.CredentialSpec{ + Name: "codex", + Type: clawv1alpha1.CredentialTypeCodexOAuth, + Domain: "chatgpt.com", + } + + route := buildCredentialRoute(cred) + assert.Equal(t, injectorCodexOAuth, route.Injector) + assert.Equal(t, "/etc/proxy/credentials/codex/auth.json", route.CodexAuthFilePath) + assert.Empty(t, route.EnvVar, "codexOAuth should not use env var") +} diff --git a/internal/controller/claw_resource_controller.go b/internal/controller/claw_resource_controller.go index 76036358..6737c8bd 100644 --- a/internal/controller/claw_resource_controller.go +++ b/internal/controller/claw_resource_controller.go @@ -740,7 +740,7 @@ func (r *ClawResourceReconciler) enrichConfigAndNetworkPolicy( disableUpdateCheck(config) injectRouteHost(config, routeHost) injectAuthMode(config, instance) - if err := injectProviders(config, instance); err != nil { + if err := injectProviders(config, instance, resolvedCreds); err != nil { return fmt.Errorf("failed to inject providers: %w", err) } injectModelCatalog(config, instance) @@ -1259,7 +1259,7 @@ func injectRouteHost(config map[string]any, routeHost string) { // injectProviders dynamically builds the models.providers section from // credentials that have Provider set. Always-win: unconditionally overwrites // the providers map regardless of user config. -func injectProviders(config map[string]any, instance *clawv1alpha1.Claw) error { +func injectProviders(config map[string]any, instance *clawv1alpha1.Claw, resolvedCreds []resolvedCredential) error { providers := map[string]any{} for _, cred := range instance.Spec.Credentials { if cred.Provider == "" || cred.Type == clawv1alpha1.CredentialTypePathToken { @@ -1287,12 +1287,21 @@ func injectProviders(config map[string]any, instance *clawv1alpha1.Claw) error { } info := resolveProviderInfo(cred) baseURL := info.Upstream + info.BasePath - providers[cred.Provider] = buildProviderEntry(cred.Provider, baseURL, "ah-ah-ah-you-didnt-say-the-magic-word") + + apiKey := placeholderAPIKey + if cred.Type == clawv1alpha1.CredentialTypeCodexOAuth { + accountID := codexOAuthAccountID(resolvedCreds, cred.Name) + if accountID != "" { + apiKey = buildSyntheticJWT(accountID) + } + } + + providers[cred.Provider] = buildProviderEntry(cred.Provider, baseURL, apiKey) for _, companion := range knownProviders[cred.Provider].Companions { if _, exists := providers[companion]; exists { return fmt.Errorf("duplicate provider %q (companion of %q) in credentials", companion, cred.Provider) } - providers[companion] = buildProviderEntry(companion, baseURL, "ah-ah-ah-you-didnt-say-the-magic-word") + providers[companion] = buildProviderEntry(companion, baseURL, apiKey) } } } diff --git a/internal/proxy/config.go b/internal/proxy/config.go index 1c16ead6..f6d8d528 100644 --- a/internal/proxy/config.go +++ b/internal/proxy/config.go @@ -27,23 +27,24 @@ import ( // Route is a single route entry in the proxy config JSON. type Route struct { - Domain string `json:"domain"` - Injector string `json:"injector"` - Header string `json:"header,omitempty"` - ValuePrefix string `json:"valuePrefix,omitempty"` - EnvVar string `json:"envVar,omitempty"` - SAFilePath string `json:"saFilePath,omitempty"` - GCPProject string `json:"gcpProject,omitempty"` - GCPLocation string `json:"gcpLocation,omitempty"` - PathPrefix string `json:"pathPrefix,omitempty"` - Upstream string `json:"upstream,omitempty"` - ClientID string `json:"clientID,omitempty"` - TokenURL string `json:"tokenURL,omitempty"` - Scopes []string `json:"scopes,omitempty"` - DefaultHeaders map[string]string `json:"defaultHeaders,omitempty"` - KubeconfigPath string `json:"kubeconfigPath,omitempty"` - CACert string `json:"caCert,omitempty"` - AllowedPaths []string `json:"allowedPaths,omitempty"` + Domain string `json:"domain"` + Injector string `json:"injector"` + Header string `json:"header,omitempty"` + ValuePrefix string `json:"valuePrefix,omitempty"` + EnvVar string `json:"envVar,omitempty"` + SAFilePath string `json:"saFilePath,omitempty"` + GCPProject string `json:"gcpProject,omitempty"` + GCPLocation string `json:"gcpLocation,omitempty"` + PathPrefix string `json:"pathPrefix,omitempty"` + Upstream string `json:"upstream,omitempty"` + ClientID string `json:"clientID,omitempty"` + TokenURL string `json:"tokenURL,omitempty"` + Scopes []string `json:"scopes,omitempty"` + DefaultHeaders map[string]string `json:"defaultHeaders,omitempty"` + KubeconfigPath string `json:"kubeconfigPath,omitempty"` + CACert string `json:"caCert,omitempty"` + AllowedPaths []string `json:"allowedPaths,omitempty"` + CodexAuthFilePath string `json:"codexAuthFilePath,omitempty"` injector Injector `json:"-"` } diff --git a/internal/proxy/injector.go b/internal/proxy/injector.go index 1726e15b..afc17a1e 100644 --- a/internal/proxy/injector.go +++ b/internal/proxy/injector.go @@ -27,7 +27,10 @@ type Injector interface { Inject(req *http.Request) error } -const injectorGCP = "gcp" +const ( + injectorGCP = "gcp" + injectorCodexOAuth = "codex_oauth" +) // authHeaders are stripped from every request before injection (defense in depth). var authHeaders = []string{ @@ -38,6 +41,8 @@ var authHeaders = []string{ "Impersonate-User", "Impersonate-Group", "Impersonate-Uid", + "Chatgpt-Account-Id", + "Openai-Beta", } // StripAuthHeaders removes all known auth and impersonation headers from the request. @@ -70,6 +75,8 @@ func NewInjector(route *Route) (Injector, error) { return NewOAuth2Injector(route) case "kubernetes": return NewKubernetesInjector(route) + case injectorCodexOAuth: + return NewCodexOAuthInjector(route) default: return nil, fmt.Errorf("unknown injector type: %s", route.Injector) } diff --git a/internal/proxy/injector_codex_oauth.go b/internal/proxy/injector_codex_oauth.go new file mode 100644 index 00000000..09eed24f --- /dev/null +++ b/internal/proxy/injector_codex_oauth.go @@ -0,0 +1,157 @@ +/* +Copyright 2026 Red Hat. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + codexTokenURL = "https://auth.openai.com/oauth/token" + codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann" +) + +// codexAuthFile mirrors the structure of ~/.codex/auth.json. +type codexAuthFile struct { + AuthMode string `json:"auth_mode"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` +} + +// CodexOAuthInjector loads a Codex auth.json file, obtains OAuth2 tokens via +// refresh_token grant, and injects Authorization + Codex-specific headers. +// Tokens are cached and auto-refreshed by the oauth2.TokenSource. +type CodexOAuthInjector struct { + authFilePath string + defaultHeaders map[string]string + + once sync.Once + tokenSource oauth2.TokenSource + accountID string + initErr error +} + +func NewCodexOAuthInjector(route *Route) (*CodexOAuthInjector, error) { + if route.CodexAuthFilePath == "" { + return nil, fmt.Errorf("codex_oauth injector requires codexAuthFilePath") + } + return &CodexOAuthInjector{ + authFilePath: route.CodexAuthFilePath, + defaultHeaders: route.DefaultHeaders, + }, nil +} + +func (c *CodexOAuthInjector) init() { + data, err := os.ReadFile(c.authFilePath) + if err != nil { + c.initErr = fmt.Errorf("read codex auth file %s: %w", c.authFilePath, err) + return + } + + auth, err := ParseCodexAuthJSON(data) + if err != nil { + c.initErr = err + return + } + + c.accountID = auth.AccountID + + cfg := &oauth2.Config{ + ClientID: codexClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: codexTokenURL, + }, + } + + initialToken := &oauth2.Token{ + AccessToken: auth.AccessToken, + RefreshToken: auth.RefreshToken, + TokenType: "Bearer", + } + if auth.AccessToken != "" { + initialToken.Expiry = time.Now().Add(23 * time.Hour) + } + + httpClient := &http.Client{Timeout: 10 * time.Second} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + c.tokenSource = cfg.TokenSource(ctx, initialToken) +} + +func (c *CodexOAuthInjector) Inject(req *http.Request) error { + c.once.Do(c.init) + if c.initErr != nil { + return c.initErr + } + + token, err := c.tokenSource.Token() + if err != nil { + return fmt.Errorf("codex oauth token refresh failed: %w", err) + } + + for k, v := range c.defaultHeaders { + if strings.EqualFold(k, "Authorization") { + continue + } + req.Header.Set(k, v) + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("chatgpt-account-id", c.accountID) + req.Header.Set("originator", "openclaw") + req.Header.Set("OpenAI-Beta", "responses=experimental") + return nil +} + +// CodexAuthData holds the parsed fields from a Codex auth.json file. +type CodexAuthData struct { + AccessToken string + RefreshToken string + AccountID string +} + +// ParseCodexAuthJSON validates and extracts data from a Codex auth.json file. +func ParseCodexAuthJSON(data []byte) (*CodexAuthData, error) { + var auth codexAuthFile + if err := json.Unmarshal(data, &auth); err != nil { + return nil, fmt.Errorf("parse codex auth.json: %w", err) + } + if auth.AuthMode != "chatgpt" { + return nil, fmt.Errorf("codex auth.json: auth_mode must be \"chatgpt\", got %q", auth.AuthMode) + } + if auth.Tokens.RefreshToken == "" { + return nil, fmt.Errorf("codex auth.json: tokens.refresh_token is required") + } + if auth.Tokens.AccountID == "" { + return nil, fmt.Errorf("codex auth.json: tokens.account_id is required") + } + return &CodexAuthData{ + AccessToken: auth.Tokens.AccessToken, + RefreshToken: auth.Tokens.RefreshToken, + AccountID: auth.Tokens.AccountID, + }, nil +} diff --git a/internal/proxy/injector_codex_oauth_test.go b/internal/proxy/injector_codex_oauth_test.go new file mode 100644 index 00000000..1dc7ab6b --- /dev/null +++ b/internal/proxy/injector_codex_oauth_test.go @@ -0,0 +1,270 @@ +/* +Copyright 2026 Red Hat. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestParseCodexAuthJSON(t *testing.T) { + tests := []struct { + name string + json string + wantErr string + wantID string + }{ + { + name: "valid auth.json", + json: `{"auth_mode":"chatgpt","tokens":{"access_token":"eyJ...","refresh_token":"v1.abc","account_id":"acct_123"}}`, + wantID: "acct_123", + }, + { + name: "valid without access_token", + json: `{"auth_mode":"chatgpt","tokens":{"refresh_token":"v1.abc","account_id":"acct_456"}}`, + wantID: "acct_456", + }, + { + name: "wrong auth_mode", + json: `{"auth_mode":"api_key","tokens":{"refresh_token":"v1.abc","account_id":"acct_123"}}`, + wantErr: `auth_mode must be "chatgpt"`, + }, + { + name: "missing auth_mode", + json: `{"tokens":{"refresh_token":"v1.abc","account_id":"acct_123"}}`, + wantErr: `auth_mode must be "chatgpt"`, + }, + { + name: "missing refresh_token", + json: `{"auth_mode":"chatgpt","tokens":{"account_id":"acct_123"}}`, + wantErr: "refresh_token is required", + }, + { + name: "missing account_id", + json: `{"auth_mode":"chatgpt","tokens":{"refresh_token":"v1.abc"}}`, + wantErr: "account_id is required", + }, + { + name: "invalid JSON", + json: `not json`, + wantErr: "parse codex auth.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseCodexAuthJSON([]byte(tt.json)) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantID, result.AccountID) + }) + } +} + +func TestNewCodexOAuthInjector(t *testing.T) { + t.Run("requires codexAuthFilePath", func(t *testing.T) { + _, err := NewCodexOAuthInjector(&Route{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "codexAuthFilePath") + }) + + t.Run("accepts valid route", func(t *testing.T) { + inj, err := NewCodexOAuthInjector(&Route{ + CodexAuthFilePath: "/tmp/auth.json", + }) + require.NoError(t, err) + require.NotNil(t, inj) + }) +} + +func TestNewInjectorCodexOAuth(t *testing.T) { + inj, err := NewInjector(&Route{ + Injector: injectorCodexOAuth, + CodexAuthFilePath: "/tmp/auth.json", + }) + require.NoError(t, err) + require.NotNil(t, inj) + _, ok := inj.(*CodexOAuthInjector) + assert.True(t, ok) +} + +func TestCodexOAuthInjectorHappyPath(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"test-codex-token","token_type":"Bearer","expires_in":86400}`)) + })) + defer tokenServer.Close() + + authJSON := `{"auth_mode":"chatgpt","tokens":{"refresh_token":"v1.refresh","account_id":"acct_test123"}}` + authFile := filepath.Join(t.TempDir(), "auth.json") + require.NoError(t, os.WriteFile(authFile, []byte(authJSON), 0600)) + + inj := &CodexOAuthInjector{ + authFilePath: authFile, + defaultHeaders: map[string]string{"x-custom": "value"}, + } + // Override init to use test token server + inj.once.Do(func() { + data, err := os.ReadFile(inj.authFilePath) + if err != nil { + inj.initErr = err + return + } + auth, err := ParseCodexAuthJSON(data) + if err != nil { + inj.initErr = err + return + } + inj.accountID = auth.AccountID + + cfg := &testCodexConfig{tokenURL: tokenServer.URL + "/token"} + inj.tokenSource = cfg.tokenSource(auth.RefreshToken) + }) + + req, _ := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) + err := inj.Inject(req) + require.NoError(t, err) + + assert.Equal(t, "Bearer test-codex-token", req.Header.Get("Authorization")) + assert.Equal(t, "acct_test123", req.Header.Get("chatgpt-account-id")) + assert.Equal(t, "openclaw", req.Header.Get("originator")) + assert.Equal(t, "responses=experimental", req.Header.Get("OpenAI-Beta")) + assert.Equal(t, "value", req.Header.Get("x-custom")) +} + +func TestCodexOAuthInjectorSkipsAuthorizationDefaultHeader(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"real-token","token_type":"Bearer","expires_in":86400}`)) + })) + defer tokenServer.Close() + + authJSON := `{"auth_mode":"chatgpt","tokens":{"refresh_token":"v1.refresh","account_id":"acct_skip"}}` + authFile := filepath.Join(t.TempDir(), "auth.json") + require.NoError(t, os.WriteFile(authFile, []byte(authJSON), 0600)) + + inj := &CodexOAuthInjector{ + authFilePath: authFile, + defaultHeaders: map[string]string{"Authorization": "should-be-ignored", "x-extra": "kept"}, + } + inj.once.Do(func() { + data, _ := os.ReadFile(inj.authFilePath) + auth, _ := ParseCodexAuthJSON(data) + inj.accountID = auth.AccountID + cfg := &testCodexConfig{tokenURL: tokenServer.URL + "/token"} + inj.tokenSource = cfg.tokenSource(auth.RefreshToken) + }) + + req, _ := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) + require.NoError(t, inj.Inject(req)) + + assert.Equal(t, "Bearer real-token", req.Header.Get("Authorization")) + assert.Equal(t, "kept", req.Header.Get("x-extra")) +} + +func TestCodexOAuthInjectorMissingFile(t *testing.T) { + inj, err := NewCodexOAuthInjector(&Route{ + CodexAuthFilePath: "/nonexistent/auth.json", + }) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, "https://chatgpt.com/api", nil) + err = inj.Inject(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "read codex auth file") +} + +func TestCodexOAuthInjectorInvalidJSON(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + require.NoError(t, os.WriteFile(authFile, []byte("not json"), 0600)) + + inj, err := NewCodexOAuthInjector(&Route{ + CodexAuthFilePath: authFile, + }) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, "https://chatgpt.com/api", nil) + err = inj.Inject(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse codex auth.json") +} + +func TestStripAuthHeadersIncludesCodexHeaders(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil) + req.Header.Set("Chatgpt-Account-Id", "acct_123") + req.Header.Set("OpenAI-Beta", "responses=experimental") + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("Content-Type", "application/json") + + StripAuthHeaders(req) + + assert.Empty(t, req.Header.Get("Chatgpt-Account-Id")) + assert.Empty(t, req.Header.Get("OpenAI-Beta")) + assert.Empty(t, req.Header.Get("Authorization")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) +} + +// testCodexConfig is a helper that creates an oauth2.TokenSource using a test token URL. +type testCodexConfig struct { + tokenURL string +} + +func (c *testCodexConfig) tokenSource(refreshToken string) *codexTokenSource { + return &codexTokenSource{ + tokenURL: c.tokenURL, + refreshToken: refreshToken, + } +} + +// codexTokenSource fetches tokens from a test server for unit tests. +type codexTokenSource struct { + tokenURL string + refreshToken string +} + +func (s *codexTokenSource) Token() (*oauth2.Token, error) { + resp, err := http.Post(s.tokenURL, "application/x-www-form-urlencoded", nil) //nolint:noctx + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return nil, err + } + return &oauth2.Token{ + AccessToken: tok.AccessToken, + TokenType: tok.TokenType, + }, nil +} From ad5bf78e7864de9b0a3cdd3d98581f7649f8d3ef Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Wed, 3 Jun 2026 18:37:13 -0700 Subject: [PATCH 2/3] fix: address review findings on codex OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add language identifier to ASCII diagram code fence - Fix NP docs inconsistency: questions doc now matches design doc (no NP changes needed — proxy egress already allows TCP/443) - Add openai-oauth to PLATFORM.md known provider list - Remove synthetic 23h expiry on seed token — let TokenSource refresh immediately rather than guessing expiry Signed-off-by: Alexey Kazakov Co-authored-by: Cursor --- docs/proposals/codex-oauth-design.md | 2 +- docs/proposals/codex-oauth-questions.md | 4 ++-- internal/assets/manifests/claw/configmap.yaml | 4 ++-- internal/proxy/injector_codex_oauth.go | 4 ---- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/proposals/codex-oauth-design.md b/docs/proposals/codex-oauth-design.md index 0ba0e005..2477e68b 100644 --- a/docs/proposals/codex-oauth-design.md +++ b/docs/proposals/codex-oauth-design.md @@ -42,7 +42,7 @@ The core challenge is that Codex OAuth tokens expire (~24 hours) and must be ref ### Data flow -``` +```text User workstation Kubernetes cluster ───────────────── ────────────────── diff --git a/docs/proposals/codex-oauth-questions.md b/docs/proposals/codex-oauth-questions.md index 80c3e3ba..55ff9ff5 100644 --- a/docs/proposals/codex-oauth-questions.md +++ b/docs/proposals/codex-oauth-questions.md @@ -79,9 +79,9 @@ The proxy needs to reach `auth.openai.com:443` to refresh tokens. The operator m - **Pro:** Consistent with how GCP auto-adds `oauth2.googleapis.com` - **Con:** None significant -**Decision:** Option A — auto-add `auth.openai.com:443` and `chatgpt.com:443` egress when any `codexOAuth` credential is present. Follows the GCP pattern. +**Decision:** No NetworkPolicy changes needed. The existing `{instance}-proxy-egress` policy already allows all TCP/443 egress. The proxy's L7 route table is the real allowlist — `chatgpt.com` gets a route from the credential definition, and `auth.openai.com` is reached directly by the proxy's `oauth2.TokenSource` (bypassing the route table). This is consistent with how GCP credentials work — GCP also has no dedicated NP rule for `oauth2.googleapis.com`. -_Considered and rejected: Option B (poor UX — forgetting causes silent token refresh failures, inconsistent with GCP behavior)_ +_Considered and rejected: Option A auto-add (unnecessary — proxy egress already allows TCP/443; Option B manual config was also rejected for the same reason)_ --- diff --git a/internal/assets/manifests/claw/configmap.yaml b/internal/assets/manifests/claw/configmap.yaml index 040c5f67..264cf867 100644 --- a/internal/assets/manifests/claw/configmap.yaml +++ b/internal/assets/manifests/claw/configmap.yaml @@ -419,8 +419,8 @@ data: Claw CR. There are two ways providers contribute models: 1. **Built-in catalog providers** — credentials with `provider` set to a known - value (`google`, `anthropic`, `openai`, `xai`, `openrouter`) auto-populate the - picker with the operator's hardcoded model catalog. + value (`google`, `anthropic`, `openai`, `openai-oauth`, `xai`, `openrouter`) + auto-populate the picker with the operator's hardcoded model catalog. 2. **Custom providers** — `spec.customProviders` entries explicitly declare the `baseUrl`, wire format, and model list for any OpenAI-compatible endpoint. diff --git a/internal/proxy/injector_codex_oauth.go b/internal/proxy/injector_codex_oauth.go index 09eed24f..e7f01a6b 100644 --- a/internal/proxy/injector_codex_oauth.go +++ b/internal/proxy/injector_codex_oauth.go @@ -90,13 +90,9 @@ func (c *CodexOAuthInjector) init() { } initialToken := &oauth2.Token{ - AccessToken: auth.AccessToken, RefreshToken: auth.RefreshToken, TokenType: "Bearer", } - if auth.AccessToken != "" { - initialToken.Expiry = time.Now().Add(23 * time.Hour) - } httpClient := &http.Client{Timeout: 10 * time.Second} ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) From 8ef8f511a4e9832edfb1c9c2d11981a1df77cd5f Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Wed, 3 Jun 2026 18:40:20 -0700 Subject: [PATCH 3/3] comments --- internal/controller/claw_configmap_test.go | 7 ++++--- internal/controller/claw_providers.go | 3 ++- internal/proxy/injector_codex_oauth.go | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/controller/claw_configmap_test.go b/internal/controller/claw_configmap_test.go index 2c590f2d..ba606c36 100644 --- a/internal/controller/claw_configmap_test.go +++ b/internal/controller/claw_configmap_test.go @@ -446,10 +446,11 @@ func TestInjectProviders(t *testing.T) { injectModelCatalog(config, testClawWithCredentials(credentials)) models := config["agents"].(map[string]any)["defaults"].(map[string]any)["models"].(map[string]any) - assert.Contains(t, models, "openai-oauth/gpt-5.5") assert.Contains(t, models, "openai-oauth/gpt-5.4-mini") - entry := models["openai-oauth/gpt-5.5"].(map[string]any) - assert.Equal(t, "GPT-5.5", entry["alias"]) + assert.Contains(t, models, "openai-oauth/gpt-5.5") + assert.Contains(t, models, "openai-oauth/gpt-5.4") + entry := models["openai-oauth/gpt-5.4-mini"].(map[string]any) + assert.Equal(t, "GPT-5.4 Mini", entry["alias"]) }) t.Run("should inject both openai and openai-oauth providers independently", func(t *testing.T) { diff --git a/internal/controller/claw_providers.go b/internal/controller/claw_providers.go index 9d282ac6..83de28b7 100644 --- a/internal/controller/claw_providers.go +++ b/internal/controller/claw_providers.go @@ -130,8 +130,9 @@ var knownProviders = map[string]providerDefaults{ Domain: "chatgpt.com", API: "openai-chatgpt-responses", Models: []modelEntry{ - {Name: "gpt-5.5", Alias: "GPT-5.5"}, {Name: "gpt-5.4-mini", Alias: "GPT-5.4 Mini"}, + {Name: "gpt-5.5", Alias: "GPT-5.5"}, + {Name: "gpt-5.4", Alias: "GPT-5.4"}, }, }, "xai": { diff --git a/internal/proxy/injector_codex_oauth.go b/internal/proxy/injector_codex_oauth.go index e7f01a6b..4e3775de 100644 --- a/internal/proxy/injector_codex_oauth.go +++ b/internal/proxy/injector_codex_oauth.go @@ -89,6 +89,10 @@ func (c *CodexOAuthInjector) init() { }, } + // AccessToken is intentionally left empty so Token.Valid() returns false + // and the first call to TokenSource.Token() triggers an immediate refresh. + // Do NOT set AccessToken without also setting a real Expiry — a zero Expiry + // with a non-empty AccessToken causes the token to be reused forever. initialToken := &oauth2.Token{ RefreshToken: auth.RefreshToken, TokenType: "Bearer",