feat(ai): add ChatGPT subscription provider via Codex CLI OAuth flow#53
Conversation
PKCE (RFC 7636) verifier/challenge generator and a minimal JWT payload decoder, both used by the upcoming ChatGPT OAuth flow. Adds the codable ChatGPTTokenBundle value type that wraps access/refresh/id tokens plus account metadata.
Add saveChatGPTTokens / loadChatGPTTokens / deleteChatGPTTokens helpers on KeychainService, keyed by provider UUID. Includes unit tests for the PKCE/JWT/TokenBundle primitives and the new Keychain round-trip.
Wire the Codex CLI OAuth flow end-to-end: ProviderType.chatGPT enum case, loopback HTTP listener, OAuth service with token-refresh coalescing, and ChatGPTProvider talking to chatgpt.com/backend-api/codex/responses with SSE streaming. Factory/Registry/DependencyContainer pass the OAuth service through; 401/403 from the backend purges the keychain bundle.
ProviderEditSheet shows Sign in / Sign out buttons for chatGPT-type providers, AIChatView gates send-button when no token bundle is present, SettingsView purges the bundle when the provider row is removed.
Three review fixes for PR gridex#53. 1. **`defaultModel = "gpt-5.4"` → `gpt-5-codex`** (Core/Enums/ProviderType.swift) `gpt-5.4` is not a real OpenAI model slug. A user creating a fresh ChatGPT provider with `model: nil` would default to it, then fire a request before the live `/backend-api/codex/models` picker resolved — producing an immediate HTTP 400 from `/responses`. Switched to `gpt-5-codex`, the slug Codex CLI itself defaults to. 2. **`preconditionFailure` → in-band `GridexError`** (Services/AI/ProviderFactory.swift) Crashing the process when `chatGPTOAuthService` is nil for a `.chatGPT` config violates the project's "errors are GridexError, never raw crashes" invariant (AGENTS.md §0). The legacy `make(type:apiKey:baseURL:)` overload can reach this branch since it never threads the OAuth service through. Replaced with a private `MisconfiguredChatGPTProvider` stub that throws `GridexError.aiProviderError("ChatGPT provider misconfigured…")` from `stream()` / `availableModels()` / `validateAPIKey()`. The misconfig surfaces as a normal in-band error on first request rather than a process-wide crash. 3. **Cancel OAuth task on type-switch out of `.chatGPT`** (ProviderEditSheet.swift) `applyTypeDefaults` only reset state when switching INTO `.chatGPT`. Switching OUT (Add → pick ChatGPT → Sign in → browser slow → user changes mind → switch type back to OpenAI) left the in-flight OAuth task alive, with `isSigningIn` / `signInError` / `chatGPTStatus` / `dirtyChatGPTSignIn` stale. A tardy callback could still write tokens to `ai.chatgpt.tokens.<uuid>` after the form had moved on. The keychain converged via the Save / Cancel paths but the user- visible state was incoherent. Now the non-`.chatGPT` branch cancels the task and resets the four state flags. Tests - New `ProviderFactoryTests` (6 cases) covering the dispatch matrix: `.chatGPT` with OAuth service → `ChatGPTProvider`; `.chatGPT` without → `MisconfiguredChatGPTProvider` (stream/availableModels both throw `GridexError.aiProviderError`); `.anthropic` → `AnthropicProvider`; `.openAI` → `OpenAIProvider`; `defaultModel` regression assert pinning the literal `"gpt-5.4"` out and a known-real `gpt-5*` / `gpt-4o` slug in. - Existing 28 OAuth tests still pass. - 34 / 34 in the AI/auth families green; full suite green after a routine 1-day TLS cert renewal on the local PG :55435 fixture (unrelated to this PR). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed What changed1.
|
Maintainer confirmed against the live `/backend-api/codex/responses` endpoint that `gpt-5.4` is in fact a valid OpenAI model slug. My earlier guess that it was an invented decimal-style ID was wrong — I had no realtime access to OpenAI's current model catalog and shouldn't have assumed. Restore the original `gpt-5.4` default and drop the regression assertion in ProviderFactoryTests that pinned it out. Keep the non-empty assertion since `defaultModel` must hold *some* value for the form to send before the live `/models` picker resolves. The other two fixes from c9eb0f4 stand: - `MisconfiguredChatGPTProvider` stub instead of `preconditionFailure` - applyTypeDefaults now cancels the OAuth task on type-switch out Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ChatGPT subscription provider via the Codex CLI's public OAuth
client. Settings → Provider → 'OpenAI (Sign in)' opens the system
browser, runs the PKCE flow against auth.openai.com, captures the
redirect on a loopback HTTP listener, exchanges the code for tokens,
and stores them in a DPAPI-encrypted file under %APPDATA%\Gridex.
Subsequent chat calls use the bearer token against
chatgpt.com/backend-api/codex/responses with SSE streaming.
Files added:
- Models/ChatGPTTokenBundle.h token struct + JSON helpers
- Services/ChatGPTOAuth/PKCE.{h,cpp} verifier/challenge via BCryptGenRandom + EVP_sha256 (base64url)
- Services/ChatGPTOAuth/JwtDecoder.{h,cpp} base64url-decode payload, parse JSON, extract email + chatgpt_account_id + exp
- Services/ChatGPTOAuth/ChatGPTOAuthConstants.h client_id, scopes, port 1455, refresh skew
- Services/ChatGPTOAuth/ChatGPTOAuthService.{h,cpp} singleton: SignIn (browser + httplib::Server callback), refresh coalesced via mutex+shared_future, BearerToken/AccountId/CurrentEmail accessors
AiService:
- AiProvider::ChatGPT enum case (index 5)
- CallChatGPT — WinHTTP POST /backend-api/codex/responses, parses SSE
delta events into accumulated text. 401/403 wipes tokens.
- FetchModels ChatGPT case — WinHTTP GET /backend-api/codex/models
?client_version=1.0.0 with Bearer + ChatGPT-Account-ID. Filters to
models marked supported_in_api=true and visibility=list.
SettingsPage:
- New 'OpenAI (Sign in)' ComboBoxItem.
- ApiKeyPanel hides for ChatGPT; SignInPanel (status + Sign in/out)
takes its place. OllamaEndpointPanel hides unless provider=Ollama.
- Sign-in success auto-clears Model dropdown and fetches the live
list off /backend-api/codex/models so the previous provider's
model name doesn't leak into the new session.
Why WinHTTP for ChatGPT only:
- Vcpkg cpp-httplib 0.40.0 + OpenSSL 3.x DLL has an ABI / threading
mismatch — httplib::SSLClient destructor calls SSL_shutdown with
a corrupted handle (s = 0x2) on the worker thread, fast-failing
the process.
- Other providers (Anthropic / OpenAI / Ollama / Gemini / OpenRouter)
still use httplib::Client because httplib auto-creates an
SSLClient internally for https URLs and that path doesn't crash
in practice for those hosts. Could migrate them later if needed.
Deviations from mac:
- Single token file under %APPDATA%\Gridex\chatgpt-tokens.bin
(no per-provider UUID — Windows settings model is single-active).
- Loopback listener uses cpp-httplib::Server in plain HTTP mode
rather than mac's Network.framework + bespoke parser.
- redirect_uri uses 'localhost' (not '127.0.0.1') because the
registered Codex CLI client_id only accepts the literal hostname.
- Authorize URL + token-exchange body percent-encoded explicitly;
the scope string contains spaces and reserved chars in
redirect_uri.
* feat(grid): right-click 'Copy cell' menu copies full untruncated value Cells visually truncate at MAX_CELL_DISPLAY_CHARS, so a TextBlock- level Ctrl+C only saw the visible prefix. Added a 'Copy cell' item at the top of the data-grid context menu that pulls the FULL value from data_.rows directly — works regardless of how aggressively the cell is clipped for layout. Wire-up: - Each cell TextBlock now stamps Tag(int32 colIndex). - RightTapped on the row walks OriginalSource up the visual tree to find the cell TextBlock and stash its column in contextCol_. - Menu Opening gates the item on (selectedRow_ >= 0 && contextCol_ >= 0). - CopyCellToClipboard reads data_.rows[selectedRow_][colName] and ships a DataPackage with the full string. NULL sentinel renders as the literal 'NULL' so paste targets see something. * chore(release): bump windows version to 0.1.10 * feat(connection): persist environment tag + show coloured badge on cards The TagCombo dropdown (None/Production/Staging/Development/Testing/ Local) was rendered in ConnectionFormDialog.xaml but never wired — selection didn't round-trip on Save / Edit, and nothing in the UI reflected the tag. End-to-end fix: - ConnectionConfig: add 'tag' field (free-form wstring; empty = None so callers can ignore it cleanly). - ConnectionStore: ALTER TABLE adds 'tag TEXT DEFAULT ""'; SELECT reads col 25, INSERT binds placeholder 24. Legacy rows migrate to empty string. - ConnectionFormDialog: Save reads TagCombo.SelectedIndex (None=0 stays empty) and pulls ComboBoxItem.Content. SetConnectionConfig matches the saved tag against the option list to restore selection. - ConnectionCard: new EnvTagBadge next to the name with a per-tag colour (Production red / Staging orange / Development green / Testing purple / Local slate), Collapsed when tag is empty. * feat(ai): port 'OpenAI (Sign in)' OAuth provider from mac PR #53 Adds ChatGPT subscription provider via the Codex CLI's public OAuth client. Settings → Provider → 'OpenAI (Sign in)' opens the system browser, runs the PKCE flow against auth.openai.com, captures the redirect on a loopback HTTP listener, exchanges the code for tokens, and stores them in a DPAPI-encrypted file under %APPDATA%\Gridex. Subsequent chat calls use the bearer token against chatgpt.com/backend-api/codex/responses with SSE streaming. Files added: - Models/ChatGPTTokenBundle.h token struct + JSON helpers - Services/ChatGPTOAuth/PKCE.{h,cpp} verifier/challenge via BCryptGenRandom + EVP_sha256 (base64url) - Services/ChatGPTOAuth/JwtDecoder.{h,cpp} base64url-decode payload, parse JSON, extract email + chatgpt_account_id + exp - Services/ChatGPTOAuth/ChatGPTOAuthConstants.h client_id, scopes, port 1455, refresh skew - Services/ChatGPTOAuth/ChatGPTOAuthService.{h,cpp} singleton: SignIn (browser + httplib::Server callback), refresh coalesced via mutex+shared_future, BearerToken/AccountId/CurrentEmail accessors AiService: - AiProvider::ChatGPT enum case (index 5) - CallChatGPT — WinHTTP POST /backend-api/codex/responses, parses SSE delta events into accumulated text. 401/403 wipes tokens. - FetchModels ChatGPT case — WinHTTP GET /backend-api/codex/models ?client_version=1.0.0 with Bearer + ChatGPT-Account-ID. Filters to models marked supported_in_api=true and visibility=list. SettingsPage: - New 'OpenAI (Sign in)' ComboBoxItem. - ApiKeyPanel hides for ChatGPT; SignInPanel (status + Sign in/out) takes its place. OllamaEndpointPanel hides unless provider=Ollama. - Sign-in success auto-clears Model dropdown and fetches the live list off /backend-api/codex/models so the previous provider's model name doesn't leak into the new session. Why WinHTTP for ChatGPT only: - Vcpkg cpp-httplib 0.40.0 + OpenSSL 3.x DLL has an ABI / threading mismatch — httplib::SSLClient destructor calls SSL_shutdown with a corrupted handle (s = 0x2) on the worker thread, fast-failing the process. - Other providers (Anthropic / OpenAI / Ollama / Gemini / OpenRouter) still use httplib::Client because httplib auto-creates an SSLClient internally for https URLs and that path doesn't crash in practice for those hosts. Could migrate them later if needed. Deviations from mac: - Single token file under %APPDATA%\Gridex\chatgpt-tokens.bin (no per-provider UUID — Windows settings model is single-active). - Loopback listener uses cpp-httplib::Server in plain HTTP mode rather than mac's Network.framework + bespoke parser. - redirect_uri uses 'localhost' (not '127.0.0.1') because the registered Codex CLI client_id only accepts the literal hostname. - Authorize URL + token-exchange body percent-encoded explicitly; the scope string contains spaces and reserved chars in redirect_uri.
…ton (#68) * feat(grid): right-click 'Copy cell' menu copies full untruncated value Cells visually truncate at MAX_CELL_DISPLAY_CHARS, so a TextBlock- level Ctrl+C only saw the visible prefix. Added a 'Copy cell' item at the top of the data-grid context menu that pulls the FULL value from data_.rows directly — works regardless of how aggressively the cell is clipped for layout. Wire-up: - Each cell TextBlock now stamps Tag(int32 colIndex). - RightTapped on the row walks OriginalSource up the visual tree to find the cell TextBlock and stash its column in contextCol_. - Menu Opening gates the item on (selectedRow_ >= 0 && contextCol_ >= 0). - CopyCellToClipboard reads data_.rows[selectedRow_][colName] and ships a DataPackage with the full string. NULL sentinel renders as the literal 'NULL' so paste targets see something. * chore(release): bump windows version to 0.1.10 * feat(connection): persist environment tag + show coloured badge on cards The TagCombo dropdown (None/Production/Staging/Development/Testing/ Local) was rendered in ConnectionFormDialog.xaml but never wired — selection didn't round-trip on Save / Edit, and nothing in the UI reflected the tag. End-to-end fix: - ConnectionConfig: add 'tag' field (free-form wstring; empty = None so callers can ignore it cleanly). - ConnectionStore: ALTER TABLE adds 'tag TEXT DEFAULT ""'; SELECT reads col 25, INSERT binds placeholder 24. Legacy rows migrate to empty string. - ConnectionFormDialog: Save reads TagCombo.SelectedIndex (None=0 stays empty) and pulls ComboBoxItem.Content. SetConnectionConfig matches the saved tag against the option list to restore selection. - ConnectionCard: new EnvTagBadge next to the name with a per-tag colour (Production red / Staging orange / Development green / Testing purple / Local slate), Collapsed when tag is empty. * feat(ai): port 'OpenAI (Sign in)' OAuth provider from mac PR #53 Adds ChatGPT subscription provider via the Codex CLI's public OAuth client. Settings → Provider → 'OpenAI (Sign in)' opens the system browser, runs the PKCE flow against auth.openai.com, captures the redirect on a loopback HTTP listener, exchanges the code for tokens, and stores them in a DPAPI-encrypted file under %APPDATA%\Gridex. Subsequent chat calls use the bearer token against chatgpt.com/backend-api/codex/responses with SSE streaming. Files added: - Models/ChatGPTTokenBundle.h token struct + JSON helpers - Services/ChatGPTOAuth/PKCE.{h,cpp} verifier/challenge via BCryptGenRandom + EVP_sha256 (base64url) - Services/ChatGPTOAuth/JwtDecoder.{h,cpp} base64url-decode payload, parse JSON, extract email + chatgpt_account_id + exp - Services/ChatGPTOAuth/ChatGPTOAuthConstants.h client_id, scopes, port 1455, refresh skew - Services/ChatGPTOAuth/ChatGPTOAuthService.{h,cpp} singleton: SignIn (browser + httplib::Server callback), refresh coalesced via mutex+shared_future, BearerToken/AccountId/CurrentEmail accessors AiService: - AiProvider::ChatGPT enum case (index 5) - CallChatGPT — WinHTTP POST /backend-api/codex/responses, parses SSE delta events into accumulated text. 401/403 wipes tokens. - FetchModels ChatGPT case — WinHTTP GET /backend-api/codex/models ?client_version=1.0.0 with Bearer + ChatGPT-Account-ID. Filters to models marked supported_in_api=true and visibility=list. SettingsPage: - New 'OpenAI (Sign in)' ComboBoxItem. - ApiKeyPanel hides for ChatGPT; SignInPanel (status + Sign in/out) takes its place. OllamaEndpointPanel hides unless provider=Ollama. - Sign-in success auto-clears Model dropdown and fetches the live list off /backend-api/codex/models so the previous provider's model name doesn't leak into the new session. Why WinHTTP for ChatGPT only: - Vcpkg cpp-httplib 0.40.0 + OpenSSL 3.x DLL has an ABI / threading mismatch — httplib::SSLClient destructor calls SSL_shutdown with a corrupted handle (s = 0x2) on the worker thread, fast-failing the process. - Other providers (Anthropic / OpenAI / Ollama / Gemini / OpenRouter) still use httplib::Client because httplib auto-creates an SSLClient internally for https URLs and that path doesn't crash in practice for those hosts. Could migrate them later if needed. Deviations from mac: - Single token file under %APPDATA%\Gridex\chatgpt-tokens.bin (no per-provider UUID — Windows settings model is single-active). - Loopback listener uses cpp-httplib::Server in plain HTTP mode rather than mac's Network.framework + bespoke parser. - redirect_uri uses 'localhost' (not '127.0.0.1') because the registered Codex CLI client_id only accepts the literal hostname. - Authorize URL + token-exchange body percent-encoded explicitly; the scope string contains spaces and reserved chars in redirect_uri. * chore(release): bump windows version to 0.1.11 * feat(workspace): Create Database dialog + scrollable picker with sticky New button Three things shipped together: 1. Create-Database flow. ShowCreateDatabaseDialogAsync presents a ContentDialog with a name TextBox, executes CREATE DATABASE "<name>" (or backtick-quoted for MySQL/CH) on the active adapter, refreshes the picker, and switches to the new database. SQLite + Redis hide the entry entirely (engines without DB-level DDL). 2. Scrollable database picker. Replaced MenuFlyout with a custom Flyout: ScrollViewer + StackPanel holds the database list capped at MaxHeight=380, so connections with 30+ databases no longer overflow the screen. Each DB renders as a flat Button styled like a menu item (Flyout doesn't accept MenuFlyoutItem children). 3. Sticky New-database button. Lives in row 1 of the picker grid, outside the scroll region — always visible at the bottom no matter how far the user scrolls through the database list. Click handler wired once in init; LoadDatabasePicker only flips visibility based on engine support. Bonus: ellipsis literal moved to \u2026 escape so it survives the non-UTF-8-BOM source file. * chore(release): bump windows version to 0.1.12 * fix(windows): stabilize mongo connect and libssh2 deploy (#77)
Overview
Adds a new AI provider type — OpenAI (Sign in) — letting users authenticate
with their paid ChatGPT subscription instead of supplying an API key. Internally
this reuses the Codex CLI public OAuth client
to obtain Bearer tokens, then talks to
chatgpt.com/backend-api/codex/responseswith SSE streaming.
For users who already pay for ChatGPT Plus / Pro / Team this avoids the
per-token API spend and the friction of generating + rotating an API key.
Screenshots
1. New "OpenAI (Sign in)" entry in the provider type picker
2. Add Provider sheet — Authentication block before sign-in
3. Edit Provider sheet — signed in, models loaded from
/backend-api/codex/modelsHow it works
auth.openai.com/authorizewith PKCE (RFC 7636) parameters.http://localhost:1455/auth/callback—a single-shot loopback HTTP listener inside the app accepts the redirect.
access_token/refresh_token/id_token, decodes the JWT for account metadata (email, plan), and storesthe bundle in the macOS Keychain under
ai.chatgpt.tokens.<provider-uuid>.ChatGPTOAuthService.tokenBundle(...)checksexpandrefreshes via
auth.openai.com/oauth/tokenif within the skew window.Concurrent refreshes coalesce into a single
Taskto avoid token thrash.ChatGPTProviderstreams/responsesSSE deltas. A 401/403 from either/responsesor/modelspurges the keychain bundle and the UI revertsto the signed-out state.
Architecture — 4 atomic commits
Split for bisect-friendliness; each commit compiles standalone.
feat(ai-core)— PKCE verifier/challenge, JWT payload decoder,ChatGPTTokenBundlevalue type. No outward dependencies.feat(keychain)—KeychainServiceextension(
save/load/deleteChatGPTTokens) keyed by provider UUID, plus unit testsfor the primitives + Keychain round-trip.
feat(ai)—ProviderType.chatGPTenum case, loopback listener,OAuth service with refresh coalescing,
ChatGPTProvider, factory /registry / DI wiring.
feat(settings)—ProviderEditSheetSign-in / Sign-out UI,AIChatViewsend-button gating on token presence,SettingsViewkeychain cleanup on row removal.
Risk / caveats
OAuth client_id. If OpenAI rotates that client or changes the
/backend-api/codex/*shape, the provider breaks until the constants inChatGPTOAuthConstants.swiftare updated./backend-api/codex/responses.Network.framework;Windows/Linux ports would need their own implementation.
Keychain bundle; users have to re-authenticate.
Tests
ChatGPTOAuthTests.swift— PKCE / JWT / token-bundle codec / Keychainround-trip
ChatGPTOAuthServiceTests.swift— URL-protocol-mocked refresh path,concurrent-refresh coalescing, expired-token short-circuit,
missing-keychain throw, SSE happy path + error event,
/modelsfilter +401 sign-out
swift test --filter ChatGPTOAuth, ~0.5 s)UI (sheet state machine, send-button gating) is covered by manual exploratory
testing — see test plan below. The codebase has no precedent for SwiftUI view
unit tests, so adding ViewInspector/XCUITest scaffolding solely for this PR
felt like over-engineering.
Test plan
swift test --filter ChatGPTOAuth— 28 / 28 greenswift build— no new warnings./scripts/build-app.sh—Gridex.appv0.0.14 builds, ~77 MB,ad-hoc signed
(
security find-generic-password -s "Gridex" | grep ai.chatgpt.tokensreturns nothing)
(no rollback)
Cancel → bundle cleaned up
UI reverts to signed-out
ai.apikey.<uuid>andai.chatgpt.tokens.<uuid>purged