From 5683973c040e90b35f953f94c2ce538176f2f24f Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 20 Apr 2026 20:18:21 +0300 Subject: [PATCH] feat(codex): add app-server account management and polish native UX --- .../codex-app-server-account-feature-plan.md | 5195 +++++++++++++++++ .../codex-app-server-account-signoff.md | 303 + src/features/codex-account/contracts/api.ts | 15 + .../codex-account/contracts/channels.ts | 6 + src/features/codex-account/contracts/dto.ts | 83 + src/features/codex-account/contracts/index.ts | 3 + .../domain/evaluateCodexLaunchReadiness.ts | 124 + src/features/codex-account/index.ts | 3 + .../input/ipc/registerCodexAccountIpc.ts | 33 + .../CodexAccountSnapshotPresenter.ts | 19 + .../composition/createCodexAccountFeature.ts | 693 +++ src/features/codex-account/main/index.ts | 6 + .../CodexAccountAppServerClient.ts | 100 + .../infrastructure/CodexAccountEnvBuilder.ts | 66 + .../CodexLoginSessionManager.ts | 300 + .../detectCodexLocalAccountArtifacts.ts | 109 + .../preload/createCodexAccountBridge.ts | 40 + src/features/codex-account/preload/index.ts | 1 + .../renderer/hooks/useCodexAccountSnapshot.ts | 232 + src/features/codex-account/renderer/index.ts | 14 + .../mergeCodexCliStatusWithSnapshot.ts | 26 + .../mergeCodexProviderStatusWithSnapshot.ts | 216 + .../renderer/rateLimitDisplay.ts | 129 + .../createRecentProjectsFeature.ts | 6 +- .../codex/CodexAppServerClient.ts | 5 +- src/main/index.ts | 20 + src/main/ipc/configValidation.ts | 17 + .../infrastructure/CliInstallerService.ts | 114 +- .../services/infrastructure/ConfigManager.ts | 54 +- .../CodexAppServerSessionFactory.ts | 135 + .../codexAppServer}/CodexBinaryResolver.ts | 0 .../codexAppServer}/JsonRpcStdioClient.ts | 163 +- .../infrastructure/codexAppServer/index.ts | 29 + .../infrastructure/codexAppServer/protocol.ts | 113 + .../runtime/ProviderConnectionService.ts | 355 +- .../services/runtime/buildRuntimeBaseEnv.ts | 86 + .../services/runtime/providerAwareCliEnv.ts | 72 +- .../schedule/ScheduledTaskExecutor.ts | 4 +- src/main/services/team/TeamDataService.ts | 9 +- .../services/team/TeamProvisioningService.ts | 65 +- src/main/services/team/cliFlavor.ts | 2 +- src/preload/index.ts | 10 +- src/renderer/api/httpClient.ts | 24 + .../common/CliInstallWarningBanner.tsx | 15 +- .../components/dashboard/CliStatusBanner.tsx | 500 +- .../extensions/ExtensionStoreView.tsx | 154 +- .../extensions/apikeys/ApiKeysPanel.tsx | 75 +- .../extensions/common/InstallButton.tsx | 12 +- .../extensions/mcp/CustomMcpServerDialog.tsx | 14 +- .../extensions/mcp/McpServerCard.tsx | 13 +- .../extensions/mcp/McpServerDetailDialog.tsx | 13 +- .../extensions/mcp/McpServersPanel.tsx | 45 +- .../extensions/plugins/PluginCard.tsx | 16 +- .../extensions/plugins/PluginDetailDialog.tsx | 10 + .../extensions/plugins/PluginsPanel.tsx | 25 +- .../extensions/skills/SkillsPanel.tsx | 45 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 662 ++- .../runtime/providerConnectionUi.ts | 142 +- .../settings/hooks/useSettingsHandlers.ts | 4 +- .../settings/sections/CliStatusSection.tsx | 137 +- .../team/ProvisioningProgressBlock.tsx | 27 +- .../team/dialogs/CreateTeamDialog.tsx | 44 +- .../team/dialogs/LaunchTeamDialog.tsx | 44 +- .../team/dialogs/TeamModelSelector.tsx | 31 +- src/renderer/store/index.ts | 11 +- .../store/slices/cliInstallerSlice.ts | 68 +- src/renderer/store/slices/extensionsSlice.ts | 62 +- src/renderer/utils/memberRuntimeSummary.ts | 19 +- src/renderer/utils/refreshCliStatus.ts | 17 + src/renderer/utils/runtimeDisplayName.ts | 26 + src/renderer/utils/teamModelCatalog.ts | 10 +- .../utils/teamProvisioningPresentation.ts | 2 +- src/shared/types/api.ts | 3 +- src/shared/types/cliInstaller.ts | 27 +- src/shared/types/notifications.ts | 4 +- src/shared/utils/extensionNormalizers.ts | 6 +- .../core/evaluateCodexLaunchReadiness.test.ts | 137 + .../main/CodexAccountEnvBuilder.test.ts | 64 + .../main/CodexLoginSessionManager.test.ts | 237 + .../createCodexAccountFeature.live.test.ts | 52 + .../main/createCodexAccountFeature.test.ts | 604 ++ .../detectCodexLocalAccountArtifacts.test.ts | 90 + .../preload/createCodexAccountBridge.test.ts | 67 + .../mergeCodexCliStatusWithSnapshot.test.ts | 121 + ...rgeCodexProviderStatusWithSnapshot.test.ts | 231 + .../renderer/useCodexAccountSnapshot.test.ts | 244 + .../CodexAppServerClient.test.ts | 7 +- .../CliInstallerService.test.ts | 51 + .../ConfigManager.codexMigration.test.ts | 91 + .../ClaudeMultimodelBridgeService.test.ts | 4 +- .../runtime/ProviderConnectionService.test.ts | 325 +- .../runtime/providerAwareCliEnv.test.ts | 39 + .../schedule/ScheduledTaskExecutor.test.ts | 32 + .../TeamProvisioningServicePrompts.test.ts | 97 + .../api/httpClient.codexAccount.test.ts | 40 + .../cli/CliStatusVisibility.test.ts | 843 ++- .../common/CliInstallWarningBanner.test.tsx | 104 + .../extensions/ExtensionStoreView.test.ts | 350 +- .../extensions/apikeys/ApiKeysPanel.test.ts | 318 + .../extensions/mcp/McpServersPanel.test.ts | 96 +- .../extensions/plugins/PluginsPanel.test.ts | 272 + .../extensions/skills/SkillsPanel.test.ts | 183 + .../ProviderRuntimeSettingsDialog.test.ts | 809 ++- .../runtime/providerConnectionUi.test.ts | 241 +- .../team/ProvisioningProgressBlock.test.tsx | 80 + .../TeamModelSelectorDisabledState.test.ts | 141 +- .../ProvisioningProviderStatusList.test.ts | 1 + test/renderer/store/cliInstallerSlice.test.ts | 177 + test/renderer/store/extensionsSlice.test.ts | 110 +- .../utils/memberRuntimeSummary.test.ts | 10 +- test/renderer/utils/refreshCliStatus.test.ts | 33 + .../utils/teamModelAvailability.test.ts | 18 + .../teamProvisioningPresentation.test.ts | 1 + .../shared/utils/extensionNormalizers.test.ts | 10 + 114 files changed, 17003 insertions(+), 609 deletions(-) create mode 100644 docs/research/codex-app-server-account-feature-plan.md create mode 100644 docs/research/codex-app-server-account-signoff.md create mode 100644 src/features/codex-account/contracts/api.ts create mode 100644 src/features/codex-account/contracts/channels.ts create mode 100644 src/features/codex-account/contracts/dto.ts create mode 100644 src/features/codex-account/contracts/index.ts create mode 100644 src/features/codex-account/core/domain/evaluateCodexLaunchReadiness.ts create mode 100644 src/features/codex-account/index.ts create mode 100644 src/features/codex-account/main/adapters/input/ipc/registerCodexAccountIpc.ts create mode 100644 src/features/codex-account/main/adapters/output/presenters/CodexAccountSnapshotPresenter.ts create mode 100644 src/features/codex-account/main/composition/createCodexAccountFeature.ts create mode 100644 src/features/codex-account/main/index.ts create mode 100644 src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts create mode 100644 src/features/codex-account/main/infrastructure/CodexAccountEnvBuilder.ts create mode 100644 src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts create mode 100644 src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts create mode 100644 src/features/codex-account/preload/createCodexAccountBridge.ts create mode 100644 src/features/codex-account/preload/index.ts create mode 100644 src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts create mode 100644 src/features/codex-account/renderer/index.ts create mode 100644 src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.ts create mode 100644 src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts create mode 100644 src/features/codex-account/renderer/rateLimitDisplay.ts create mode 100644 src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts rename src/{features/recent-projects/main/infrastructure/codex => main/services/infrastructure/codexAppServer}/CodexBinaryResolver.ts (100%) rename src/{features/recent-projects/main/infrastructure/codex => main/services/infrastructure/codexAppServer}/JsonRpcStdioClient.ts (61%) create mode 100644 src/main/services/infrastructure/codexAppServer/index.ts create mode 100644 src/main/services/infrastructure/codexAppServer/protocol.ts create mode 100644 src/main/services/runtime/buildRuntimeBaseEnv.ts create mode 100644 src/renderer/utils/refreshCliStatus.ts create mode 100644 src/renderer/utils/runtimeDisplayName.ts create mode 100644 test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts create mode 100644 test/features/codex-account/main/CodexAccountEnvBuilder.test.ts create mode 100644 test/features/codex-account/main/CodexLoginSessionManager.test.ts create mode 100644 test/features/codex-account/main/createCodexAccountFeature.live.test.ts create mode 100644 test/features/codex-account/main/createCodexAccountFeature.test.ts create mode 100644 test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts create mode 100644 test/features/codex-account/preload/createCodexAccountBridge.test.ts create mode 100644 test/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.test.ts create mode 100644 test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts create mode 100644 test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts create mode 100644 test/main/services/infrastructure/ConfigManager.codexMigration.test.ts create mode 100644 test/renderer/api/httpClient.codexAccount.test.ts create mode 100644 test/renderer/components/common/CliInstallWarningBanner.test.tsx create mode 100644 test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts create mode 100644 test/renderer/components/extensions/plugins/PluginsPanel.test.ts create mode 100644 test/renderer/components/team/ProvisioningProgressBlock.test.tsx create mode 100644 test/renderer/utils/refreshCliStatus.test.ts diff --git a/docs/research/codex-app-server-account-feature-plan.md b/docs/research/codex-app-server-account-feature-plan.md new file mode 100644 index 00000000..880c72bc --- /dev/null +++ b/docs/research/codex-app-server-account-feature-plan.md @@ -0,0 +1,5195 @@ +# Codex App-Server Account Feature - Detailed Implementation Plan + +**Date**: 2026-04-20 +**Status**: Reference-quality implementation plan +**Primary repo**: `claude_team` +**Secondary repo**: `agent_teams_orchestrator` only for later parity, not on the first critical path +**Canonical architecture reference**: [FEATURE_ARCHITECTURE_STANDARD.md](../FEATURE_ARCHITECTURE_STANDARD.md) + +## Executive Summary + +We should restore the old strong Codex subscription UX, but we should **not** bring back the old legacy Codex transport or legacy OAuth semantics. + +The correct design is: + +- keep **execution** on the current `codex-native` runtime path +- introduce a new dedicated feature slice: `src/features/codex-account` +- use **official `codex app-server` account APIs** as the managed-account control plane +- keep **app-owned API key storage** in the app +- explicitly merge the three different truths: + - managed ChatGPT account truth from `codex app-server` + - API key availability truth from app secure storage and ambient env detection + - real execution truth from `codex exec` + +This feature should become the source of truth for: + +- autodetect of an already logged-in Codex / ChatGPT account +- login / cancel / logout UI flow +- plan type display +- rate limit display +- subscription-first connection copy +- launch-readiness policy for ChatGPT-backed Codex +- deterministic per-launch auth-mode forcing + +Core rule: + +- `codex exec` remains the execution seam +- `codex app-server` becomes the account control-plane seam +- legacy Codex transport stays deleted +- legacy Codex OAuth stays deleted +- direct `auth.json` parsing stays forbidden +- `chatgptAuthTokens` host-managed mode stays out of scope + +## Goals And Non-Goals + +### Goals + +- restore strong Codex subscription UX on top of the native runtime +- make managed ChatGPT account truth first-class again in the app +- keep API key support without letting it hijack subscription semantics +- keep runtime execution on `codex exec` +- keep ownership boundaries aligned with `FEATURE_ARCHITECTURE_STANDARD.md` +- make rollout safe through additive, testable composition + +### Non-goals + +This feature is **not** trying to: + +- revive legacy Codex transport +- revive legacy Codex OAuth implementation details +- parse `~/.codex/auth.json` +- add browser-mode local app-server support in the first wave +- add app-server-managed API key login in the first wave +- add per-member auth preferences or per-member Codex backend preferences +- redesign the whole CLI shell UX for other providers +- bundle plugin/app-server enrichment beyond account control-plane needs +- solve orchestrator parity in the same first implementation + +## Glossary + +These terms are used repeatedly in the plan and must stay consistent. + +### Preferred auth mode + +Persisted user intent. + +Allowed values: + +- `auto` +- `chatgpt` +- `api_key` + +### Effective auth mode + +The auth mode the next launch will actually use after runtime evaluation. + +Allowed values: + +- `chatgpt` +- `api_key` +- `null` + +### Snapshot state + +High-level UI/account state describing what the app currently believes about Codex account +availability. + +Examples: + +- `managed_account_connected` +- `both_available` +- `degraded` + +### Launch readiness + +Execution-oriented state used to decide whether the app should launch Codex and under which auth +mode. + +Examples: + +- `ready_chatgpt` +- `ready_api_key` +- `missing_auth` + +### Managed account + +A ChatGPT-authenticated Codex account owned and persisted by Codex itself. + +### API key availability + +Whether the app can supply an OpenAI API key from its own secure storage or ambient env detection. + +### Degraded + +A state where the control plane cannot fully verify Codex account truth right now, but the app may +still have partial or fresh-enough knowledge to present a careful status and in some cases still +launch. + +### Last-known-good snapshot + +The most recent successful account snapshot that came from a real app-server read and passed normal +merge and validation logic. + +### Freshness window + +A bounded period during which the feature may temporarily reuse last-known-good managed-account +truth while the app-server is degraded. + +### Control plane + +The account-management seam implemented via `codex app-server`. + +### Execution plane + +The actual task-running seam implemented via `codex exec`. + +## Chosen Plan Assessment + +Chosen plan: + +- full Codex app-server account seam as a dedicated feature slice, while keeping `codex-native` execution + +Assessment: + +- `🎯 9 🛡️ 9 🧠 7` +- estimated implementation size: `1800-3200` lines in `claude_team`, plus tests and docs + +## Top 3 Viable Shapes + +### 1. Full app-server account feature slice - chosen + +`🎯 9 🛡️ 9 🧠 7` +Estimated size: `1800-3200` lines + +Idea: + +- build a dedicated `codex-account` feature +- use `codex app-server` for account state, login lifecycle, and rate limits +- keep API keys app-owned +- keep execution on `codex exec` + +Why this wins: + +- best long-term architecture +- truthful subscription UX +- avoids legacy transport return +- creates a clean seam between account control plane and execution plane +- aligns with the repo's feature architecture standard + +Main cost: + +- more moving parts than simple CLI probing +- requires careful runtime-policy integration + +### 2. Hybrid read-via-app-server and login-via-cli + +`🎯 8 🛡️ 8 🧠 6` +Estimated size: `1200-2200` lines + +Idea: + +- `account/read` and rate limits via app-server +- login/logout still via `codex login` / `codex logout` + +Pros: + +- simpler login integration +- still gets rich autodetect and plan truth + +Cons: + +- split control plane +- less internally coherent +- more transitional than final + +### 3. CLI-only managed-account seam + +`🎯 8 🛡️ 8 🧠 4` +Estimated size: `700-1200` lines + +Idea: + +- use `codex login status`, `codex login`, `codex logout` +- build UI around plain CLI probing + +Pros: + +- simpler +- safer if we were optimizing only for speed + +Cons: + +- poorer structured account metadata +- weak rate-limit surface +- less extensible +- does not justify a full feature slice as well + +## Final Decision + +We are taking option 1. + +Reason: + +- the user requirement is not just "support subscription somehow" +- the requirement is "bring back the strong legacy-quality Codex subscription UX, but on the native runtime" +- for that requirement, the app-server account seam is the cleanest and most future-proof architecture + +## Resolved Decisions Register + +This section keeps the most important architectural choices explicit, so they do not get +re-litigated ad hoc during implementation. + +### Resolved - execution seam + +Decision: + +- keep execution on raw `codex exec` + +Why: + +- it matches the current cutover direction +- it avoids reopening the legacy transport question inside this feature + +### Resolved - control-plane seam + +Decision: + +- use `codex app-server` for account lifecycle and rate-limit truth + +Why: + +- it is the official structured surface +- it supports managed account autodetect and login lifecycle without file parsing + +### Resolved - secret ownership + +Decision: + +- ChatGPT managed auth belongs to Codex +- API key ownership remains app-owned + +Why: + +- avoids dual key stores +- keeps responsibility boundaries clear + +### Resolved - Codex connection naming + +Decision: + +- persisted preference uses `preferredAuthMode: "auto" | "chatgpt" | "api_key"` + +Why: + +- `oauth` is legacy wording and no longer the right semantic label for Codex + +### Resolved - browser mode + +Decision: + +- browser-mode app-server support is deferred + +Why: + +- desktop Electron path is the real target for the first implementation +- we should not hide platform limitations behind fake parity + +### Resolved - device code + +Decision: + +- ChatGPT browser flow is first-class +- `chatgptDeviceCode` is deferred unless required by a real blocker + +Why: + +- browser flow better matches the intended legacy-quality desktop UX + +### Resolved - degraded launch policy + +Decision: + +- degraded control-plane state may still be launchable only with positive current or sufficiently + fresh prior managed-account evidence + +Why: + +- prevents false hard blocks +- also prevents indefinite stale-account lies + +## Problem Statement + +### What is wrong today + +The current codebase has fully cut over to `codex-native` execution, but the account and UX layer was flattened too far. + +Current product truth: + +- Codex runtime lane is native-only +- Codex UI is effectively API-key-only +- Codex managed ChatGPT subscription autodetect is gone from app UX +- Codex login/logout from app UI was removed +- current launch-readiness policy still assumes API key credentials are required + +This creates a product and architecture mismatch: + +- the real Codex native runtime supports ChatGPT account auth +- but the app currently acts as if only API keys exist + +### Why this is dangerous + +If we only restore cosmetic UI copy and do not change runtime policy, we will create a worse failure mode: + +- UI says subscription is available +- but launch still fails because the app hard-gates on `OPENAI_API_KEY` or `CODEX_API_KEY` + +That would be a deceptive product state. + +So the plan must fix: + +1. managed account detection +2. managed login flow +3. runtime launch policy +4. UI presentation + +all together + +## Confirmed Facts And Constraints + +This section lists facts the plan relies on. + +## Official Codex facts + +Based on the current public OpenAI Codex docs, plus the protocol schemas generated from the +installed Codex binary on this machine: + +- Codex supports both ChatGPT account auth and API key auth +- `codex app-server` supports: + - `account/read` + - `account/login/start` + - `account/login/cancel` + - `account/logout` + - `account/rateLimits/read` + - `account/updated` + - `account/login/completed` + - `account/rateLimits/updated` +- `account/read` returns: + - `account | null` + - `requiresOpenaiAuth` +- when `account.type === "chatgpt"`, the current schema requires: + - `email` + - `planType` +- when `account.type === "apiKey"`, the current schema exposes only: + - `type` +- current generated `account/updated` schema includes nullable: + - `authMode` + - `planType` +- current documented `authMode` values include: + - `apikey` + - `chatgpt` + - `chatgptAuthTokens` + - `null` +- current generated `account/rateLimits/read` schema exposes: + - `rateLimits` + - `rateLimitsByLimitId` + - per-snapshot `planType | null` +- Codex config supports: + - `forced_login_method = "chatgpt"` + - `forced_login_method = "api"` +- Codex tools accept config overrides via top-level `-c key=value` +- current app-server docs explicitly show ChatGPT browser flow via: + - `type: "chatgpt"` +- current app-server docs explicitly show externally managed token mode via: + - `type: "chatgptAuthTokens"` +- the generated protocol types explicitly mark `chatgptAuthTokens` as unstable / internal-only, so + the first implementation must not depend on it + +Interpretation rule: + +- steady-state managed-account identity must still come from successful `account/read` +- notification fields are freshness accelerators, not a durable replacement read model + +Sources: + +- [Authentication - Codex](https://developers.openai.com/codex/auth) +- [App Server - Codex](https://developers.openai.com/codex/app-server) + +## Locally verified facts + +Verified on this machine on 2026-04-20: + +- `codex login status` returns `Logged in using ChatGPT` +- `codex app-server` starts locally +- `account/read` returned: + - `account.type = "chatgpt"` + - `email = "quantjumppro@gmail.com"` + - `planType = "pro"` + - `requiresOpenaiAuth = true` + +Practical implication: + +- managed-account autodetect is real +- it does not require reverse-engineering auth storage + +## Current repo facts + +Current relevant code facts: + +- `recent-projects` already uses `codex app-server` over short-lived JSON-RPC stdio sessions +- generic Codex runtime status currently sits in: + - `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` +- generic provider connection logic currently sits in: + - `src/main/services/runtime/ProviderConnectionService.ts` +- launch env assembly currently sits in: + - `src/main/services/runtime/providerAwareCliEnv.ts` +- Codex UI currently flattened to API-key-only sits in: + - `src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx` + - `src/renderer/components/runtime/providerConnectionUi.ts` + - `src/renderer/components/dashboard/CliStatusBanner.tsx` + - `src/renderer/components/settings/sections/CliStatusSection.tsx` + +## Current schema facts + +Current config and shared-type state: + +- `providerConnections.codex` is currently `Record` +- `configValidation` currently rejects real Codex connection fields except for ignored stale legacy fields +- shared `AppConfig` mirrors still declare Codex provider connections as empty + +Practical implication: + +- this feature requires deliberate config schema expansion and migration logic + +## App-Server Compatibility Facts + +Current local code and generated protocol schemas show: + +- `recent-projects` already initializes app-server with: + - `experimentalApi: false` + - `optOutNotificationMethods` +- generated `initialize` response includes: + - `codexHome` + - `platformFamily` + - `platformOs` +- generated login response for `type: "chatgpt"` includes: + - `loginId` + - `authUrl` + +Practical implication: + +- the feature can and should stay on the stable app-server surface +- compatibility should be decided by an initialize-plus-required-method handshake, not by semver + parsing alone +- auth-root diagnostics can use `initialize.codexHome` as a first-class observed fact instead of + only inferring from environment variables + +## Honest Confidence Hotspots + +These are the places where confidence is lower than the rest of the plan and where we should be +deliberately conservative. + +### Hotspot 1 - undocumented or lightly documented auth variants + +Assessment: + +- `🎯 6 🛡️ 9 🧠 3` + +What we know: + +- official app-server docs clearly show: + - `chatgpt` + - `apiKey` + - `chatgptAuthTokens` +- local schema / CLI evidence suggests more variants may exist + +Plan decision: + +- first implementation depends only on the clearly documented browser-flow `chatgpt` path +- do not build phase 1 around additional auth variants + +Why this is the safe choice: + +- avoids coupling to protocol surfaces that may be less stable or less publicly specified + +### Hotspot 2 - exact ordering of app-server notifications + +Assessment: + +- `🎯 7 🛡️ 9 🧠 4` + +What we know: + +- docs and local evidence show `account/login/completed` and `account/updated` +- but we should not assume stronger guarantees than we have to + +Plan decision: + +- notifications accelerate freshness +- explicit snapshot refresh remains the recovery source of truth + +Why this is the safe choice: + +- even if event order changes slightly, the feature still converges to the correct steady state + +### Hotspot 3 - rate-limits payload usefulness for the first UI + +Assessment: + +- `🎯 7 🛡️ 8 🧠 4` + +What we know: + +- app-server exposes ChatGPT rate-limit reads and updates +- exact display value of every field may not be necessary for phase 1 UX + +Plan decision: + +- keep rate-limits lazy +- present the minimal truthful subset first +- avoid making base account UX depend on rate-limit richness + +Why this is the safe choice: + +- avoids blocking the critical auth/launch story on secondary UI detail + +### Hotspot 4 - `forced_login_method` long-term contract stability + +Assessment: + +- `🎯 7 🛡️ 8 🧠 3` + +What we know: + +- the auth docs and local behavior support the approach +- the CLI reference page is not the strongest canonical place for this exact contract + +Plan decision: + +- isolate this override behind the coordinator/env builder seam +- do not scatter it through renderer or generic shell logic + +Why this is the safe choice: + +- if the exact override mechanism ever changes, the blast radius stays small + +### Hotspot 5 - precedence of account fields across read, notification, and rate-limit surfaces + +Assessment: + +- `🎯 7 🛡️ 9 🧠 4` + +What we know: + +- the generated protocol schema shows `account/updated` carries nullable `authMode` and `planType` +- `account/rateLimits/read` also carries `planType` +- notifications are best-effort and should not be treated as a durable replay log + +Plan decision: + +- the latest successful `account/read` owns steady-state account identity +- `account/updated` and rate-limit snapshots may refresh hints and trigger coalesced rereads +- they must never synthesize account presence on their own + +Why this is the safe choice: + +- avoids phantom login or phantom subscription UI caused by late, partial, or duplicated + notifications + +### Hotspot 6 - binary compatibility and stable-vs-experimental app-server surface + +Assessment: + +- `🎯 8 🛡️ 9 🧠 4` + +What we know: + +- official app-server docs describe `initialize.params.capabilities.experimentalApi` +- current local `recent-projects` integration already uses `experimentalApi: false` +- generated `initialize` response exposes `codexHome`, `platformFamily`, and `platformOs` + +Plan decision: + +- first-wave `codex-account` must depend only on stable account APIs +- feature readiness must be gated by successful initialize plus required stable method support +- do not make product behavior depend on parsing CLI semver strings alone + +Why this is the safe choice: + +- reduces risk from partial protocol drift across installed Codex binaries +- keeps compatibility logic tied to the actual negotiated surface + +### Hotspot 7 - managed workspace restriction behavior in admin-controlled installs + +Assessment: + +- `🎯 6 🛡️ 8 🧠 4` + +What we know: + +- official auth docs support `forced_chatgpt_workspace_id` +- official docs say mismatched credentials cause Codex to log the user out and exit +- the app does not currently own workspace selection or workspace switching for Codex + +Plan decision: + +- first wave treats workspace restriction as admin policy truth, not as generic missing-auth +- the UI must not invent a workspace picker or a fake remediation flow the app does not own + +Why this is the safe choice: + +- preserves truthful UX in managed environments without expanding scope into unsupported account + management + +### Hotspot 8 - trust boundary around `authUrl` and sensitive login metadata + +Assessment: + +- `🎯 7 🛡️ 9 🧠 3` + +What we know: + +- `account/login/start { type: "chatgpt" }` returns `authUrl` and `loginId` +- existing generic `shell:openExternal` allows `http`, `https`, and `mailto` +- the feature only needs browser auth URLs, not general-purpose URL opening + +Plan decision: + +- keep raw `authUrl` handling in main only +- require a stricter feature-specific validation policy before opening: + - scheme must be `https` + - no renderer round-trip for the raw URL + - no raw URL logging +- treat `loginId` as process-lifecycle metadata, not as user-facing state + +Why this is the safe choice: + +- reduces accidental leakage of login URLs or correlation ids across IPC, logs, and renderer state + +### Hotspot 9 - mutation races and app-server session topology + +Assessment: + +- `🎯 7 🛡️ 9 🧠 5` + +What we know: + +- `account/login/completed`, `account/login/cancel`, `account/logout`, and forced snapshot refreshes + can overlap in time +- app-server notifications are connection-scoped +- `recent-projects` already uses separate short-lived stdio app-server sessions + +Plan decision: + +- serialize mutating account operations in the main-process feature +- keep the login session on its own dedicated app-server connection +- keep passive reads on separate short-lived sessions +- let fresh steady-state snapshot truth settle races instead of trusting action intent alone + +Why this is the safe choice: + +- avoids cross-session notification bleed, duplicate side effects, and stale mutations reanimating + old UI state + +## Pre-Implementation Confidence Burn-Down Checklist + +These checks are the shortest path to reducing the remaining honest uncertainty before or during the +first implementation steps. + +### Burn-down check 1 - capture real browser-login notification sequence + +Goal: + +- observe the real order of: + - `account/login/completed` + - `account/updated` + - any adjacent auth-related notifications + +Why: + +- confirms our event-ordering assumptions are conservative enough + +Expected outcome: + +- even if order differs slightly from expectation, the explicit refresh model remains valid + +### Burn-down check 2 - capture one real `account/rateLimits/read` sample + +Goal: + +- verify the actual payload shape we want to expose in the first UI + +Why: + +- reduces uncertainty around which fields are worth surfacing in phase F + +Expected outcome: + +- confirm minimal truthful fields for initial UI +- defer decorative/secondary fields safely + +### Burn-down check 3 - re-verify ChatGPT launch path with ambient API keys present + +Goal: + +- prove that the chosen env sanitization plus auth override actually forces the intended ChatGPT path + +Why: + +- this is the highest-value correctness risk in the whole feature + +Expected outcome: + +- capture one signoff artifact that launch uses ChatGPT semantics even when API-key env vars are + present in the parent shell + +### Burn-down check 4 - verify logout semantics against stale cached state + +Goal: + +- prove that explicit logout wins over last-known-good snapshot reuse + +Why: + +- prevents the most embarrassing stale-account resurrection bug + +Expected outcome: + +- logout clears managed-account truth immediately +- degraded follow-up reads cannot resurrect it + +### Burn-down check 5 - confirm app-server and exec share identical auth-store roots + +Goal: + +- compare resolved `HOME`, `USERPROFILE`, and `CODEX_HOME` across both paths + +Why: + +- split auth store is a high-severity, low-visibility failure mode + +Expected outcome: + +- one centralized env normalization path is sufficient for both control plane and execution plane + +### Burn-down check 6 - capture one real `account/updated` payload sequence + +Goal: + +- observe whether `authMode` and `planType` arrive as expected across login, logout, and steady + state refreshes + +Why: + +- confirms the precedence rules stay conservative against nullable or partial notification payloads + +Expected outcome: + +- keep `account/read` as the steady-state owner +- use notifications only as accelerators and invalidation hints + +### Burn-down check 7 - verify initialize handshake on the stable surface + +Goal: + +- confirm the feature can derive a compatibility verdict from stable initialize plus required method + support + +Why: + +- prevents false-ready UI on installs where `codex app-server` exists but the required account + surface is too old or otherwise incompatible + +Expected outcome: + +- initialize succeeds with `experimentalApi: false` +- the feature records `codexHome`, `platformFamily`, and `platformOs` for diagnostics +- unsupported/mismatched installs become `app-server-incompatible`, not generic auth failure + +### Burn-down check 8 - capture one real ChatGPT login URL handling sample + +Goal: + +- validate the actual `authUrl` scheme/shape and prove the browser-open path can stay main-only + +Why: + +- closes the last trust-boundary uncertainty around login URL handling without expanding scope into + brittle host-specific assumptions + +Expected outcome: + +- the real login URL is `https` +- raw `authUrl` never needs to cross IPC +- logs can record only redacted/derived diagnostics such as scheme and hostname when necessary + +### Burn-down check 9 - exercise cancel/completion/logout race resolution + +Goal: + +- prove that overlapping account mutations still converge to one truthful steady state + +Why: + +- this is one of the easiest places to create zombie pending state or accidental false logout + +Expected outcome: + +- cancel followed by late login completion does not force a false disconnect +- logout during pending login still ends in logged-out truth +- stale pre-mutation reads cannot overwrite post-mutation state + +## Recommended Placement Of Burn-Down Checks In The Phase Plan + +To keep momentum, we should not treat all uncertainty as a separate research project. + +### Before or during Phase B + +- Burn-down check 1 +- Burn-down check 2 +- Burn-down check 6 +- Burn-down check 7 +- Burn-down check 8 + +Reason: + +- these directly shape contracts and event handling + +### Before or during Phase D + +- Burn-down check 3 +- Burn-down check 5 + +Reason: + +- these directly shape launch correctness and env routing + +### Before or during Phase E + +- Burn-down check 4 +- Burn-down check 9 + +Reason: + +- this directly shapes logout semantics, mutation ordering, and stale-state invalidation + +## Uncertainty Triage - What Must Be Settled Before Broad Coding + +Not every unknown deserves to block the feature. The plan should distinguish hard preconditions from +safe rollout follow-ups. + +### Must be explicit before the feature spreads across shell and renderer + +- account field precedence between `account/read`, `account/updated`, and `account/rateLimits/read` +- stable-surface compatibility gate for installed `codex app-server` +- containment rules for `authUrl`, `loginId`, and account email across main, IPC, and logs +- mutation serialization and race-settlement policy for login/cancel/logout +- shared auth-store root resolution for app-server and `codex exec` +- launch-time env sanitization when ambient API keys exist +- logout invalidation semantics and last-known-good clearing rules + +Why: + +- each of these can create silent false truth in UI or billing/auth mismatches at runtime + +### Can be refined during rollout without invalidating the architecture + +- how rich the first rate-limit UI should be +- whether degraded state needs an extra badge in addition to text +- whether we surface plan-type changes instantly from notifications or only after follow-up reads +- how aggressively background refresh should coalesce under bursty notification traffic +- whether managed-workspace restriction gets a dedicated visual badge or text-only treatment + +Why: + +- these change UX sharpness, not the core correctness contract + +## Known Current Mismatches The Plan Must Explicitly Eliminate + +These are not abstract concerns. They already exist in the current code and must be treated as +first-class implementation targets. + +### Mismatch 1 - Codex connection truth is still API-key-first + +Current reality: + +- `ProviderConnectionService` still treats Codex readiness as "API key exists" +- `getConfiguredConnectionIssue()` still says Codex native requires `OPENAI_API_KEY` or + `CODEX_API_KEY` + +Why this is dangerous: + +- once ChatGPT account UX is restored, launch policy can still lie and hard-fail incorrectly + +### Mismatch 2 - shell UI copy still flattens Codex to API key management + +Current reality: + +- `providerConnectionUi.ts` still uses: + - `Configure API key` + - `Saved API key available in Manage` + - `Codex native ready` +- there is no first-class Codex managed account summary + +Why this is dangerous: + +- the renderer will keep presenting the wrong mental model even if the runtime becomes correct + +### Mismatch 3 - shell login/logout flow is terminal-modal based + +Current reality: + +- `CliStatusBanner.tsx` +- `CliStatusSection.tsx` + +still drive provider login/logout through terminal modals and shell commands. + +Why this is dangerous: + +- even if app-server account logic exists, the visible UX would still route through the wrong seam + +### Mismatch 4 - config schema has no real Codex connection preference + +Current reality: + +- `providerConnections.codex` is still effectively empty +- validation only tolerates stale legacy keys instead of modeling the current desired state + +Why this is dangerous: + +- renderer state, persistence, and launch policy can drift because there is no canonical stored + preference + +### Mismatch 5 - app-server infrastructure is feature-owned by `recent-projects` + +Current reality: + +- generic JSON-RPC stdio transport is currently nested under `recent-projects` + +Why this is dangerous: + +- a second feature would either duplicate the transport or deep-import another feature's internals + +### Mismatch 6 - current multimodel shell status cannot be the Codex account source of truth + +Current reality: + +- `CliProviderStatus` is useful for binary/backend/model truth +- it is not sufficient for login lifecycle, plan display, or dual-surface auth truth + +Why this is dangerous: + +- forcing all Codex account truth into `CliProviderStatus` would create a leaky, provider-specific + blob in generic shell status contracts + +## Hard constraints + +The implementation must respect all of the following: + +1. Execution must stay on `codex-native` / `codex exec`. +2. The feature must not recreate legacy Codex transport. +3. The feature must not parse `~/.codex/auth.json`. +4. The feature must not duplicate API key storage responsibility into Codex-managed storage. +5. The feature must not hard-block launch only because app-server is transiently degraded. +6. The feature must keep auth truth and execution truth separate. +7. The feature must keep app-server and `codex exec` running against the same auth storage context. + +## Why This Must Be A Feature Slice + +This is not just another Codex-specific if-statement. + +This work: + +- spans `main -> preload -> renderer` +- owns transport wiring +- owns its own use cases +- owns provider-specific business policy +- is expected to grow + +That matches the feature standard's criteria for a full slice. + +So the implementation should **not** be buried into: + +- `ProviderConnectionService` +- `ProviderRuntimeSettingsDialog` +- `CliStatusBanner` +- `CliStatusSection` + +Those shell modules should consume this feature, not own it. + +## Feature Topology + +```text +src/features/codex-account/ + contracts/ + api.ts + channels.ts + dto.ts + events.ts + index.ts + core/ + domain/ + CodexConnectionPreference.ts + CodexManagedAccount.ts + CodexLaunchReadiness.ts + CodexConnectionSnapshot.ts + CodexLoginState.ts + application/ + ports/ + CodexManagedAccountSourcePort.ts + CodexManagedLoginPort.ts + CodexRateLimitSourcePort.ts + CodexApiKeyAvailabilityPort.ts + CodexBinaryResolverPort.ts + CodexShellEnvPort.ts + BrowserLauncherPort.ts + ClockPort.ts + LoggerPort.ts + use-cases/ + GetCodexConnectionSnapshotUseCase.ts + RefreshCodexConnectionSnapshotUseCase.ts + StartCodexChatgptLoginUseCase.ts + CancelCodexLoginUseCase.ts + LogoutCodexManagedAccountUseCase.ts + ReadCodexRateLimitsUseCase.ts + EvaluateCodexLaunchReadinessUseCase.ts + main/ + composition/ + createCodexAccountFeature.ts + adapters/ + input/ + ipc/registerCodexAccountIpc.ts + output/ + presenters/ + CodexConnectionSnapshotPresenter.ts + CodexRateLimitsPresenter.ts + CodexAccountEventPresenter.ts + runtime/ + ProviderConnectionApiKeySourceAdapter.ts + CodexLaunchReadinessRuntimeAdapter.ts + shell/ + ElectronBrowserLauncherAdapter.ts + infrastructure/ + cache/ + InMemoryCodexAccountCache.ts + codex/ + CodexAccountAppServerClient.ts + CodexLoginSessionManager.ts + CodexAccountEnvBuilder.ts + preload/ + createCodexAccountBridge.ts + index.ts + renderer/ + index.ts + adapters/ + codexAccountViewModel.ts + codexProviderShellAdapter.ts + hooks/ + useCodexAccount.ts + useCodexLoginFlow.ts + useCodexRateLimits.ts + ui/ + CodexAccountConnectionPanel.tsx + CodexLoginPendingPanel.tsx + CodexRateLimitsPanel.tsx + CodexConnectionSummaryBadge.tsx +``` + +## Responsibility Split By Layer + +### `contracts/` + +Contains: + +- DTOs +- event payloads +- IPC channel names +- preload API contract + +Must not contain: + +- Electron APIs +- runtime policy +- child-process details + +### `core/domain/` + +Contains: + +- preference model +- managed-account model +- launch-readiness model +- invariants for combining account and API key truth + +Must not contain: + +- `ipcRenderer` +- `electron` +- shell env access +- JSON-RPC transport + +### `core/application/` + +Contains: + +- use cases +- ports +- merge rules +- state transition rules + +Must not contain: + +- actual app-server spawn logic +- actual browser open logic +- app config singleton access + +### `main/composition/` + +Contains: + +- wiring of ports to infrastructure +- export of a small feature facade + +### `main/adapters/input/` + +Contains: + +- IPC registration only + +### `main/adapters/output/` + +Contains: + +- translation from existing shell services into feature ports +- presenters for IPC-safe DTOs + +### `main/infrastructure/` + +Contains: + +- app-server stdio JSON-RPC details +- login session lifecycle management +- env sanitization and assembly +- cache implementation + +### `preload/` + +Contains: + +- bridge methods and event subscriptions + +### `renderer/` + +Contains: + +- hooks +- view-model mapping +- Codex-specific UI pieces + +## Feature Facade And Public Contract Shape + +To keep SRP and interface segregation intact, the feature should expose a small facade rather than +letting shell code reach into individual infrastructure pieces. + +Recommended main-process facade shape: + +```ts +interface CodexAccountFeatureFacade { + getSnapshot(options?: { forceFresh?: boolean }): Promise; + refreshSnapshot(): Promise; + startChatgptLogin(): Promise; + cancelLogin(): Promise; + logout(): Promise; + getRateLimits(options?: { forceFresh?: boolean }): Promise; + evaluateLaunchReadiness(options: { + binaryPath?: string | null; + preferredAuthMode?: 'auto' | 'chatgpt' | 'api_key' | null; + }): Promise; + subscribe(listener: (event: CodexAccountEventDto) => void): () => void; +} +``` + +Design rule: + +- the shell should depend on this facade or its IPC equivalent +- the shell should not call: + - `CodexAccountAppServerClient` + - `CodexLoginSessionManager` + - cache objects + - low-level env builders + +This keeps the implementation open for extension without forcing broad shell rewrites later. + +## Shared Infrastructure Extraction Plan + +We already have generic app-server transport primitives hiding under `recent-projects`. + +That code should be extracted before `codex-account` is built, otherwise `recent-projects` becomes the owner of unrelated infrastructure. + +Recommended extraction target: + +```text +src/main/services/infrastructure/codex-app-server/ + JsonRpcStdioClient.ts + CodexAppServerSessionFactory.ts + codexAppServerDefaults.ts +``` + +What gets extracted: + +- generic stdio JSON-RPC client +- generic initialize/initialized session bootstrap +- default request timeout values +- default suppressed notification configuration + +What stays inside `recent-projects`: + +- thread-list request logic +- recent-projects-specific source adapter + +What stays inside `codex-account`: + +- account request logic +- login lifecycle logic +- rate-limits logic + +Important DRY rule: + +- extract transport primitives +- do **not** create one giant "CodexService" that mixes unrelated product features + +## Architecture Decision On Sources Of Truth + +This feature only works if source-of-truth boundaries are explicit. + +## Truth 1 - managed account truth + +Source: + +- `codex app-server account/read` + +Used for: + +- whether a managed ChatGPT account exists +- account email +- plan type +- account auth mode + +## Truth 1a - field precedence inside managed account truth + +The feature must not treat every app-server field as equally authoritative. + +Precedence rules: + +1. `account/read` owns steady-state identity: + - whether an account exists + - `account.type` + - `email` + - baseline `planType` + - `requiresOpenaiAuth` +2. `account/updated.authMode` may update an observed auth hint immediately, but must not by itself + create or delete a managed account. +3. `account/updated.planType` may refresh displayed subscription metadata when present, but if it + arrives as `null` or arrives without a known account, the feature should trigger a coalesced + follow-up `account/read` before changing steady-state account identity. +4. `account/rateLimits/read.planType` may corroborate subscription UI, but must not create account + presence or override a newer successful `account/read`. +5. explicit logout clears managed-account truth synchronously before any background refresh result + is accepted. +6. degraded reads may reuse last-known-good account truth only within the freshness window and only + if no explicit logout happened after that snapshot. + +Implementation consequence: + +- `planType` is a field with precedence and fallback rules +- it is not a standalone durable source of truth from notifications alone + +## Truth 2 - API key truth + +Source: + +- existing app API key storage plus ambient env detection + +Used for: + +- whether the app can launch Codex using API key mode +- whether an app-managed OpenAI key is stored +- where the key comes from + +## Truth 3 - execution truth + +Source: + +- `codex exec` + +Used for: + +- whether a real Codex run starts +- real failure or success at execution time + +## Truth 4 - renderer shell status truth + +Source: + +- composed presentation model from: + - generic runtime provider status + - Codex account feature snapshot + +Important design choice: + +- we should **not** force all Codex account fields into `CliProviderStatus` +- `CliProviderStatus` remains the source of truth for: + - binary/runtime/backend/model status +- the feature snapshot remains the source of truth for: + - managed account + - preferred and effective auth mode + - launch readiness + - login lifecycle + - rate limits + +The shell must compose these two bounded contexts at presentation time. + +This is not "two conflicting sources of truth". + +It is: + +- one runtime status context +- one account status context + +## Ownership Matrix + +This table is the practical anti-bug contract for the feature. + +| Concern | Canonical source | Owning layer | Persisted? | Cache TTL | Used by | Must not be inferred from | +| --- | --- | --- | --- | --- | --- | --- | +| Codex binary path / binary installed | existing multimodel runtime status | shell runtime services | no | existing shell TTL | shell cards, launch gating | `codex-account` snapshot | +| Codex backend lane | existing runtime config `runtime.providerBackends.codex` | shell runtime services | yes | n/a | launch, status, provisioning | account auth state | +| Codex preferred auth mode | `providerConnections.codex.preferredAuthMode` | `codex-account` feature | yes | n/a | account panel, launch policy | `CliProviderStatus.authMethod` | +| Managed account presence | `account/read` | `codex-account` feature | no | 3-10s | account panel, readiness | local file parsing | +| Managed account email | `account/read` | `codex-account` feature | no | 3-10s | account panel | renderer local state | +| Managed account plan type | latest successful `account/read`; nullable refresh hints from `account/updated` and `account/rateLimits/read` | `codex-account` feature | no | 3-10s | subscription UI, rate limits | static plan assumptions or notification-only state | +| Requires OpenAI auth | `account/read.requiresOpenaiAuth` | `codex-account` feature | no | 3-10s | readiness messaging | provider id alone | +| API key availability | app secure storage plus ambient env detection | provider connection adapter | no | 0-3s | readiness, secondary badges | app-server account state | +| Effective auth mode for next launch | `EvaluateCodexLaunchReadinessUseCase` | `codex-account` feature | no | request-scoped | launch env builder | raw config alone | +| Login pending state | `CodexLoginSessionManager` | `codex-account` feature | no | live | UI pending panels | cached snapshot | +| Rate limits | `account/rateLimits/read` | `codex-account` feature | no | 30-60s | account detail panel | plan type alone | +| Final execution success / failure | `codex exec` | runtime execution lane | no | live | launch result UX | account snapshot | + +Implementation rule: + +- if a field is not canonical in this table, the layer may display it but must not own it + +## Dependency Direction And Anti-Corruption Rules + +To stay aligned with `FEATURE_ARCHITECTURE_STANDARD.md`, the new feature must preserve these +directions: + +1. `renderer` depends on: + - `@features/codex-account/renderer` + - `@features/codex-account/contracts` +2. `preload` depends on: + - `@features/codex-account/contracts` +3. `main shell` depends on: + - `@features/codex-account/main` +4. `codex-account/core/*` depends only on: + - feature-local ports and domain models +5. `codex-account/main/infrastructure/*` may depend on: + - Electron + - child process / stdio + - shared shell env helpers +6. `recent-projects` must not become a transitive dependency of `codex-account` + +Anti-corruption rule: + +- the feature may adapt values out of `CliProviderStatus` +- it must not reshape its domain around `CliProviderStatus` + +This is important because `CliProviderStatus` is a generic shell contract, not the Codex account +domain model. + +## Dependency Enforcement Recommendations + +The architecture standard already gives the broad rules. For this feature, we should make the most +important ones operational. + +### Recommended import discipline + +- shell code imports only: + - `@features/codex-account/main` + - `@features/codex-account/contracts` + - `@features/codex-account/renderer` + - `@features/codex-account/preload` +- tests may deep-import internals when needed +- production code outside the feature should not deep-import internal adapters, ports, or + infrastructure + +### Recommended lint / review guardrails + +Watch specifically for: + +- `src/renderer/components/*` importing feature `main/*` +- feature `core/*` importing `electron`, child-process modules, or shell services +- unrelated features importing `codex-account/main/infrastructure/*` +- `recent-projects` becoming a transport owner again through back references + +### Practical enforcement rule + +If a shell file needs a new Codex-specific detail and that detail is not in the feature facade or +contracts yet: + +- extend the facade or contracts +- do not bypass the boundary with a deep import + +## Explicitly forbidden truth sources + +- `~/.codex/auth.json` +- legacy OAuth state +- terminal output parsing as the steady-state account model +- `chatgptAuthTokens` +- stale renderer-only cached assumptions + +## Domain Model + +## Connection preference + +New persisted preference: + +- `auto` +- `chatgpt` +- `api_key` + +Recommended config key: + +- `providerConnections.codex.preferredAuthMode` + +Important decision: + +- do **not** reuse legacy `oauth` naming for Codex + +Reason: + +- `oauth` is legacy wording from the old implementation +- `chatgpt` maps much more clearly to the new managed-account seam + +## Effective auth mode + +Resolved per snapshot: + +- `chatgpt` +- `api_key` +- `null` + +This can differ from user preference when: + +- preferred mode is `auto` +- one surface is unavailable + +## Snapshot state + +Recommended states: + +- `runtime_missing` +- `checking` +- `not_connected` +- `managed_account_connected` +- `api_key_available` +- `both_available` +- `login_in_progress` +- `logout_in_progress` +- `degraded` + +## Launch readiness + +Recommended states: + +- `ready_chatgpt` +- `ready_api_key` +- `ready_both` +- `warning_degraded_but_launchable` +- `missing_auth` +- `runtime_missing` + +## Domain invariants + +1. A managed ChatGPT account and an API key can both exist simultaneously. +2. API key presence must not erase managed account metadata. +3. Managed account presence must not erase API key availability. +4. Launch policy must resolve one effective auth mode per run. +5. Account metadata is display state, not secret state. +6. Login state is transient and process-owned. +7. A degraded app-server read must not automatically mean "logged out". + +## State Resolution Matrix + +This matrix is the fastest way to keep renderer, use cases, and runtime policy aligned. + +| Binary available | Managed account | API key available | App-server health | Preferred auth | Snapshot state | Launch readiness | Effective auth mode | UI headline | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| no | any | any | any | any | `runtime_missing` | `runtime_missing` | `null` | Codex runtime missing | +| yes | no | no | healthy | auto | `not_connected` | `missing_auth` | `null` | Connect ChatGPT account or add API key | +| yes | yes | no | healthy | auto | `managed_account_connected` | `ready_chatgpt` | `chatgpt` | ChatGPT account connected | +| yes | no | yes | healthy | auto | `api_key_available` | `ready_api_key` | `api_key` | API key available | +| yes | yes | yes | healthy | auto | `both_available` | `ready_both` | `chatgpt` | ChatGPT account connected - API key also available | +| yes | yes | yes | healthy | chatgpt | `both_available` | `ready_chatgpt` | `chatgpt` | ChatGPT account preferred | +| yes | yes | yes | healthy | api_key | `both_available` | `ready_api_key` | `api_key` | API key preferred - ChatGPT account also connected | +| yes | yes | no | degraded | auto or chatgpt | `degraded` | `warning_degraded_but_launchable` | `chatgpt` | ChatGPT account detected - unable to fully verify right now | +| yes | no | yes | degraded | auto or api_key | `degraded` | `ready_api_key` | `api_key` | API key available - account status degraded | +| yes | no | no | degraded | auto | `degraded` | `missing_auth` or warning only if last good account still fresh | `null` or last-good-derived | Unable to verify Codex account state | + +Interpretation rule: + +- `snapshot state` is broader UX truth +- `launch readiness` is stricter execution truth +- they are related but must not be collapsed into one boolean + +## Managed Workspace Restriction Policy + +Official Codex auth/config docs allow administrators to set: + +- `forced_login_method` +- `forced_chatgpt_workspace_id` + +Docs also state that if active credentials do not match the configured restriction, Codex logs the +user out and exits. + +### First-wave product policy + +- do not add a workspace picker or workspace-switch UI in this feature +- do not pretend the app can resolve admin-managed workspace policy on the user's behalf +- do surface workspace restriction as a distinct normalized policy state when detected + +### Expected UX treatment + +If the installed Codex runtime is restricted to another ChatGPT workspace: + +- do not show generic `Connect ChatGPT account` copy as the only explanation +- do not claim the subscription is simply missing +- show an admin-policy-oriented message such as: + - `This Codex installation is restricted to a different ChatGPT workspace` + +### Architecture implication + +Workspace restriction should be modeled as: + +- a normalized error/policy category +- potentially `not_connected` or `missing_auth` at the raw launch-readiness layer +- but with policy-specific renderer messaging + +This avoids exploding the core state machine while still keeping the UX honest. + +## Config And Migration Plan + +## New config shape + +Update: + +- `ConfigManager.ProviderConnectionsConfig` +- shared `AppConfig` +- config validation +- settings reset defaults + +Recommended new shape: + +```ts +providerConnections: { + anthropic: { + authMode: 'auto' | 'oauth' | 'api_key' + }, + codex: { + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' + } +} +``` + +## Migration rules + +On config read: + +1. If `providerConnections.codex.preferredAuthMode` is missing: + - default to `auto` +2. If stale legacy `providerConnections.codex.authMode === 'oauth'`: + - migrate to `preferredAuthMode = 'chatgpt'` +3. If stale legacy `providerConnections.codex.authMode === 'api_key'`: + - migrate to `preferredAuthMode = 'api_key'` +4. If stale legacy `providerConnections.codex.authMode === 'auto'`: + - migrate to `preferredAuthMode = 'auto'` +5. Ignore `apiKeyBetaEnabled` completely after migration +6. Write back only the new shape + +## Migration algorithm - exact behavior + +This part needs to be exact because configuration drift is one of the easiest ways to create +hard-to-debug launch mismatches. + +### Read-time normalization order + +When config is loaded: + +1. load raw JSON from disk +2. apply generic config defaults +3. normalize `runtime.providerBackends.codex` +4. normalize `providerConnections.codex` +5. validate the normalized shape +6. persist normalized config only if it changed materially + +### Exact Codex connection normalization rules + +Given `raw.providerConnections.codex`: + +- if it is missing or not an object: + - replace with `{ preferredAuthMode: "auto" }` +- if `preferredAuthMode` exists and is one of: + - `auto` + - `chatgpt` + - `api_key` + - keep it +- otherwise, inspect stale keys in this order: + - `authMode === "oauth"` -> `preferredAuthMode = "chatgpt"` + - `authMode === "api_key"` -> `preferredAuthMode = "api_key"` + - `authMode === "auto"` -> `preferredAuthMode = "auto"` + - everything else -> `preferredAuthMode = "auto"` + +Then: + +- drop `authMode` +- drop `apiKeyBetaEnabled` +- drop any unknown Codex connection keys + +### Backward-compatibility rule + +Old configs must be: + +- readable +- normalizable +- writable into the new shape + +but they must not keep legacy Codex connection fields alive after one clean save cycle. + +### Migration idempotency and corrupt-input hardening rules + +Normalization must be safe under repeated reads and partially corrupted user config. + +Rules: + +1. repeated load -> normalize -> save cycles must converge to the same Codex connection subtree +2. non-object `providerConnections.codex` values must normalize to: + - `{ preferredAuthMode: "auto" }` +3. malformed or unknown Codex connection keys must not block app startup +4. already-normalized config should not be rewritten just because the normalizer ran again +5. backup/restore and import paths must reuse the same Codex normalizer instead of re-implementing + migration logic separately + +Practical consequence: + +- migration is a deterministic cleanup step, not an ongoing source of config churn + +### Migration safety rule + +Config migration must never infer: + +- "user prefers API key" merely because an API key exists +- "user prefers ChatGPT" merely because a managed account exists + +Preference is persisted user intent. Availability is runtime-observed fact. They must remain +distinct. + +## Validation rules + +`configValidation` must: + +- accept only `preferredAuthMode` under `providerConnections.codex` +- accept values: + - `auto` + - `chatgpt` + - `api_key` +- tolerate stale legacy keys during migration path only if they are normalized before persistence + +## Critical Runtime Policy + +This is the highest-risk part of the feature. + +### Why current policy is wrong + +Today Codex launch policy effectively means: + +- if no `OPENAI_API_KEY` or `CODEX_API_KEY`, Codex launch is treated as not ready + +That is incompatible with the desired product once ChatGPT-managed auth comes back. + +### New launch policy + +The feature must own launch-readiness evaluation. + +Inputs: + +- Codex binary availability +- managed account presence +- API key availability +- preferred auth mode +- app-server health + +Outputs: + +- launch readiness state +- effective auth mode +- env mutation instructions +- user-facing advisory message + +### Policy rules + +1. If preferred mode is `chatgpt` and managed account exists: + - launch is ready + - effective auth mode is `chatgpt` +2. If preferred mode is `api_key` and API key exists: + - launch is ready + - effective auth mode is `api_key` +3. If preferred mode is `auto`: + - prefer ChatGPT when managed account exists + - otherwise use API key when available + - otherwise missing-auth +4. If app-server is degraded: + - do not hard-fail launch automatically unless there is explicit evidence auth is absent + - return warning-level degraded readiness where appropriate + +### Why app-server degradation must not hard-block launch + +`codex app-server` is an account control-plane seam. + +It is **not** the execution seam. + +A transient app-server issue must not cause the app to say: + +- "Codex cannot launch" + +when `codex exec` itself could still work. + +That would create a false negative and a serious product bug. + +## Launch Policy Decision Table + +This table should directly drive the implementation of +`EvaluateCodexLaunchReadinessUseCase`. + +| Preferred auth | Managed account detected | API key available | App-server degraded | Resulting readiness | Effective auth | Required exec env policy | User-facing message | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `chatgpt` | yes | any | no | `ready_chatgpt` | `chatgpt` | strip API keys, `forced_login_method="chatgpt"` | Launch using ChatGPT account | +| `chatgpt` | yes | any | yes | `warning_degraded_but_launchable` | `chatgpt` | strip API keys, `forced_login_method="chatgpt"` | ChatGPT account detected - verification degraded | +| `chatgpt` | no | yes | no | `missing_auth` | `null` | no launch | Preferred ChatGPT account is not connected | +| `chatgpt` | no | no | no | `missing_auth` | `null` | no launch | Connect a ChatGPT account to use the selected auth mode | +| `api_key` | any | yes | any | `ready_api_key` | `api_key` | inject key, `forced_login_method="api"` | Launch using API key | +| `api_key` | any | no | any | `missing_auth` | `null` | no launch | Add an API key to use the selected auth mode | +| `auto` | yes | yes | no | `ready_both` | `chatgpt` | strip API keys, `forced_login_method="chatgpt"` | Auto selected ChatGPT account | +| `auto` | yes | no | no | `ready_chatgpt` | `chatgpt` | strip API keys, `forced_login_method="chatgpt"` | Auto selected ChatGPT account | +| `auto` | no | yes | no | `ready_api_key` | `api_key` | inject key, `forced_login_method="api"` | Auto selected API key | +| `auto` | yes | yes | yes | `warning_degraded_but_launchable` | `chatgpt` | strip API keys, `forced_login_method="chatgpt"` | Auto selected ChatGPT account - account verification degraded | +| `auto` | no | yes | yes | `ready_api_key` | `api_key` | inject key, `forced_login_method="api"` | Auto selected API key - account verification degraded | +| `auto` | no | no | yes | `missing_auth` unless last-good account freshness rule applies | `null` | no launch | Unable to verify Codex authentication | + +Important interpretation: + +- degraded app-server state does not grant permission to guess a missing managed account forever +- the only acceptable degraded-launch case is when there is positive current or sufficiently fresh + prior evidence that the managed account exists + +## Core Algorithm Sketches + +These sketches are intentionally close to implementation logic so that multiple contributors do not +invent subtly different policies. + +### Snapshot merge algorithm - pseudocode + +```ts +function buildCodexSnapshot(input: { + binaryAvailable: boolean; + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; + managedAccountResult: + | { kind: 'success'; account: ManagedAccount | null; requiresOpenaiAuth: boolean } + | { kind: 'degraded'; reason: string }; + apiKeyAvailability: ApiKeyAvailability; + loginState: LoginState; + lastKnownGoodManagedAccount: ManagedAccount | null; + lastKnownGoodObservedAt: number | null; + now: number; + freshnessWindowMs: number; +}): CodexConnectionSnapshot { + if (!input.binaryAvailable) { + return runtimeMissingSnapshot(input.preferredAuthMode, input.loginState, input.now); + } + + const managedContext = resolveManagedAccountContext({ + managedAccountResult: input.managedAccountResult, + lastKnownGoodManagedAccount: input.lastKnownGoodManagedAccount, + lastKnownGoodObservedAt: input.lastKnownGoodObservedAt, + now: input.now, + freshnessWindowMs: input.freshnessWindowMs, + }); + + return mergeManagedAndApiKeyTruth({ + preferredAuthMode: input.preferredAuthMode, + managedContext, + apiKeyAvailability: input.apiKeyAvailability, + loginState: input.loginState, + now: input.now, + }); +} +``` + +### Launch readiness algorithm - pseudocode + +```ts +function evaluateLaunchReadiness(snapshot: CodexConnectionSnapshot): CodexLaunchReadiness { + if (!snapshot.binaryAvailable) { + return { state: 'runtime_missing', effectiveAuthMode: null, message: 'Codex runtime missing' }; + } + + switch (snapshot.preferredAuthMode) { + case 'chatgpt': + if (snapshot.managedAccount?.type === 'chatgpt') { + return snapshot.state === 'degraded' + ? degradedChatgptReady() + : chatgptReady(); + } + return missingChatgptAuth(); + + case 'api_key': + return snapshot.apiKey.available ? apiKeyReady() : missingApiKeyAuth(); + + case 'auto': + if (snapshot.managedAccount?.type === 'chatgpt') { + return snapshot.state === 'degraded' + ? degradedChatgptReady() + : (snapshot.apiKey.available ? readyBoth() : chatgptReady()); + } + if (snapshot.apiKey.available) { + return apiKeyReady(); + } + return missingAnyAuth(); + } +} +``` + +### Account event reconciliation algorithm - pseudocode + +```ts +function onAccountUpdated(event: { authMode: AuthMode | null; planType: PlanType | null }) { + cache.lastObservedAuthMode = event.authMode ?? cache.lastObservedAuthMode ?? null; + + if (event.planType !== null) { + cache.lastObservedPlanTypeHint = event.planType; + } + + scheduleCoalescedSnapshotRefresh('account-updated'); +} +``` + +Critical rule: + +- notification handlers must not clear `managedAccount` only because `authMode` or `planType` + arrives as `null` +- destructive transitions belong to explicit logout handling and successful follow-up `account/read` + results + +### Exec env mutation algorithm - pseudocode + +```ts +function buildExecEnv(baseEnv: Env, readiness: CodexLaunchReadiness, apiKey: string | null): Env { + const env = { ...baseEnv }; + + if (readiness.effectiveAuthMode === 'chatgpt') { + delete env.OPENAI_API_KEY; + delete env.CODEX_API_KEY; + env.CODEX_FORCED_LOGIN_METHOD = 'chatgpt'; + return env; + } + + if (readiness.effectiveAuthMode === 'api_key') { + if (apiKey) { + env.OPENAI_API_KEY = apiKey; + env.CODEX_API_KEY = apiKey; + } + env.CODEX_FORCED_LOGIN_METHOD = 'api'; + return env; + } + + return env; +} +``` + +Implementation note: + +- the real implementation should pass `forced_login_method` via Codex config override arguments, not + invent a new environment variable contract +- the pseudocode uses a symbolic field only to make the decision process readable + +## Extremely important env semantics + +This section is subtle and non-optional. + +### Problem 1 - ambient API keys can poison managed-account autodetect + +If an app-server child process inherits: + +- `OPENAI_API_KEY` +- `CODEX_API_KEY` + +then account reads may reflect API-key auth instead of the managed ChatGPT account the user expects to see. + +### Problem 2 - execution can silently use the wrong auth surface + +If ChatGPT mode is selected but execution still inherits: + +- `OPENAI_API_KEY` +- `CODEX_API_KEY` + +then the run may silently go through API-key auth. + +That creates the worst possible bug: + +- UI says subscription is active +- but runtime actually bills via API key + +### Required env policy + +For **managed-account control-plane sessions**: + +- sanitize `OPENAI_API_KEY` +- sanitize `CODEX_API_KEY` +- do not set `forced_login_method` +- preserve the same resolved auth storage context as execution + +Reason: + +- control-plane reads should discover unbiased managed-account truth + +For **execution sessions in `chatgpt` mode**: + +- sanitize `OPENAI_API_KEY` +- sanitize `CODEX_API_KEY` +- pass `-c forced_login_method="chatgpt"` + +For **execution sessions in `api_key` mode**: + +- inject the resolved API key env +- pass `-c forced_login_method="api"` + +For **execution sessions in `auto -> chatgpt` resolution**: + +- same as `chatgpt` + +For **execution sessions in `auto -> api_key` resolution**: + +- same as `api_key` + +### Shared auth storage context rule + +`account/read`, login, logout, and `codex exec` must use the same resolved: + +- `HOME` +- `USERPROFILE` +- `CODEX_HOME` + +or the UI can observe one auth store while execution uses another. + +That would create a second class of severe bug: + +- UI sees a logged-in account +- but `codex exec` runs against a different auth store and fails + +So the feature must centralize Codex env resolution. + +## Platform-Specific Runtime Notes + +This feature is cross-process and cross-platform enough that platform rules should be explicit. + +### Auth-store env precedence + +Recommended precedence for auth-store-related env resolution: + +1. explicit per-call overrides provided by the app +2. resolved interactive shell env +3. process env fallback + +### `HOME`, `USERPROFILE`, `CODEX_HOME` + +Rules: + +- always resolve a single canonical auth root +- materialize both `HOME` and `USERPROFILE` consistently to avoid child-process drift +- preserve an explicit `CODEX_HOME` if one exists +- do not silently invent different auth roots for app-server vs exec + +Diagnostics rule: + +- when available, compare the app's resolved auth root with `initialize.codexHome` +- treat disagreement as a first-class diagnostic signal, not as a silent implementation detail + +### Windows nuance + +On Windows, child processes may consult `USERPROFILE` even when `HOME` is also set. + +Implementation implication: + +- the env builder should not treat `HOME` alone as sufficient on Windows-capable flows + +### macOS and Linux nuance + +On Unix-like systems, `HOME` is usually the primary root. + +Implementation implication: + +- if `USERPROFILE` is absent, we should still fill it consistently once we have a canonical root so + both app-server and exec see the same store contract + +### Browser-launch nuance + +Desktop login should use the Electron/browser-launch adapter, not shell command guessing. + +Implementation implication: + +- browser opening belongs to the feature infrastructure adapter +- it should not be scattered across banner/section/dialog components + +### PATH and shell env nuance + +The feature should inherit enough environment to find the Codex binary and preserve normal shell +behavior, but auth-critical env values must still be rewritten deterministically. + +Implementation implication: + +- preserve normal shell-derived env where safe +- sanitize only the auth-critical variables the policy explicitly owns + +## Concurrency, Ordering, And Race Policy + +This section is necessary because the feature will combine: + +- cached reads +- explicit refresh +- login notifications +- logout actions +- shell status refreshes + +Without a strict ordering contract, the UI can easily regress into stale or contradictory state. + +### Snapshot sequencing rule + +Every refresh path should carry: + +- `requestId` +- `startedAt` +- `observedAt` + +The feature should only publish a snapshot if it is newer than the last settled snapshot for the +same source epoch. + +Practical rule: + +- a slow degraded read must not overwrite a newer successful read +- a pre-login cached snapshot must not overwrite a post-login snapshot +- a pre-logout cached snapshot must not overwrite a post-logout snapshot + +### Single-flight rule + +Passive snapshot refreshes from: + +- dashboard banner +- settings dialog +- provider card refresh + +must collapse into one in-flight promise per main-process feature instance. + +### Login lifecycle exclusivity rule + +At most one login session may be live at a time. + +If the user clicks login repeatedly: + +- return the current pending login state +- do not start parallel app-server login sessions + +### Renderer subscription rule + +Renderer hooks must treat `snapshot-updated` and `login-state-changed` as additive feature events, +not as implicit replacement for shell runtime status. + +This avoids one subtle class of bug: + +- account snapshot event arrives +- shell status is still loading +- UI accidentally treats feature snapshot as full provider status + +### Cache invalidation rule + +Invalidate snapshot cache immediately on: + +- login start +- login completion +- logout success +- preferred auth mode change +- explicit manual refresh + +Do not wait for TTL expiry after user-initiated auth transitions. + +## Freshness Window And Last-Known-Good Policy + +This is intentionally separated from generic cache policy because it affects safety semantics, not +just performance. + +### Recommended default + +- keep a `lastKnownGoodManagedAccountSnapshot` +- keep a `lastKnownGoodObservedAt` +- use a default freshness window around `60 seconds` + +Why not longer by default: + +- too long and the app starts lying after real logout or auth expiry + +Why not shorter by default: + +- too short and transient app-server failures collapse the UX into false logout too easily + +### What may be carried forward + +During a degraded control-plane read, the feature may carry forward only: + +- managed account presence +- managed account email +- managed account plan type +- effective auth mode only if it was derived from the managed account path + +### What must be recomputed fresh + +The feature must recompute or re-read fresh: + +- API key availability +- binary/runtime availability +- current preferred auth mode from config +- login pending state +- logout in progress state + +### What must never be carried forward + +Do not carry forward: + +- pending login state +- failed login state +- logout in progress +- rate limit snapshots beyond their own TTL +- a degraded state as if it were a successful state + +### Freshness expiration rule + +Once the freshness window expires: + +- the feature may still show `degraded` +- but it must stop treating stale managed-account evidence as sufficient for launchability + +### Post-logout rule + +After explicit logout success: + +- clear the last-known-good managed-account snapshot immediately +- do not allow degraded reads to resurrect the old account state + +## Feature Data Contracts + +## DTOs + +### `CodexConnectionSnapshotDto` + +Fields: + +- `state` +- `preferredAuthMode` +- `effectiveAuthMode` +- `binaryAvailable` +- `requiresOpenaiAuth` +- `managedAccount` +- `apiKey` +- `launchReadiness` +- `degradedReason` +- `login` +- `observedAt` + +### `CodexManagedAccountDto` + +Fields: + +- `type: "chatgpt" | "apiKey" | null` +- `email?: string | null` +- `planType?: "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown" | null` + +Important note: + +- we are not planning to use app-server `apiKey` login mode, but the DTO should still model it defensively because the protocol supports it + +### `CodexApiKeyAvailabilityDto` + +Fields: + +- `available: boolean` +- `source: "stored" | "environment" | null` +- `label?: string | null` + +### `CodexLaunchReadinessDto` + +Fields: + +- `state` +- `message` +- `effectiveAuthMode` + +### `CodexLoginStateDto` + +Fields: + +- `state: "idle" | "starting" | "awaiting_browser" | "pending" | "completed" | "cancelled" | "failed"` +- `loginId?: string | null` +- `message?: string | null` + +Sensitive-field rule: + +- `loginId` is process-lifecycle metadata and should not be rendered to the user +- renderer-facing contracts may omit `loginId` entirely if cancellation and status updates can be + driven by main-owned session state +- raw `authUrl` must never appear in renderer-facing DTOs + +### `CodexRateLimitsDto` + +Fields: + +- `rateLimits` +- `rateLimitsByLimitId?` +- `planType?` +- `observedAt` + +## Event contract + +Recommended event union: + +- `snapshot-updated` +- `login-state-changed` +- `rate-limits-updated` +- `degraded` + +These should be emitted over one feature event channel and consumed by renderer hooks. + +## DTO And Event Shape Examples + +The plan should include concrete examples so that main, preload, and renderer do not each invent +their own interpretation. + +### Example `CodexConnectionSnapshotDto` + +```json +{ + "state": "both_available", + "preferredAuthMode": "auto", + "effectiveAuthMode": "chatgpt", + "binaryAvailable": true, + "requiresOpenaiAuth": true, + "managedAccount": { + "type": "chatgpt", + "email": "user@example.com", + "planType": "pro" + }, + "apiKey": { + "available": true, + "source": "stored", + "label": "Stored in app" + }, + "launchReadiness": { + "state": "ready_both", + "message": "ChatGPT account connected - API key also available", + "effectiveAuthMode": "chatgpt" + }, + "degradedReason": null, + "login": { + "state": "idle", + "loginId": null, + "message": null + }, + "observedAt": 1776640000000 +} +``` + +### Example degraded snapshot + +```json +{ + "state": "degraded", + "preferredAuthMode": "auto", + "effectiveAuthMode": "chatgpt", + "binaryAvailable": true, + "requiresOpenaiAuth": true, + "managedAccount": { + "type": "chatgpt", + "email": "user@example.com", + "planType": "pro" + }, + "apiKey": { + "available": false, + "source": null, + "label": null + }, + "launchReadiness": { + "state": "warning_degraded_but_launchable", + "message": "ChatGPT account detected - verification degraded", + "effectiveAuthMode": "chatgpt" + }, + "degradedReason": "app-server-timeout", + "login": { + "state": "idle", + "loginId": null, + "message": null + }, + "observedAt": 1776640005000 +} +``` + +### Example `login-state-changed` event + +```json +{ + "type": "login-state-changed", + "payload": { + "state": "pending", + "message": "Waiting for ChatGPT browser login to complete" + }, + "observedAt": 1776640002000 +} +``` + +### Example `snapshot-updated` event + +```json +{ + "type": "snapshot-updated", + "payload": { + "state": "managed_account_connected", + "preferredAuthMode": "chatgpt", + "effectiveAuthMode": "chatgpt" + }, + "observedAt": 1776640008000 +} +``` + +Contract rule: + +- event payloads may be smaller than full DTOs +- snapshot read methods must still return the full DTO +- renderer must not assume an event payload is a complete replacement snapshot unless the contract + explicitly says so + +Sensitive-field containment rule: + +- `authUrl` must never be emitted over the feature event channel +- incremental events should not include full account email unless a full snapshot read is actually + required for UI rendering +- `loginId` should stay main-owned unless there is a concrete renderer need that cannot be solved by + process-owned cancel/status APIs + +## Error Normalization Matrix + +The feature should normalize raw transport/process failures into stable categories so renderer copy +and metrics stay coherent. + +| Raw failure family | Normalized category | Typical feature impact | UI treatment | +| --- | --- | --- | --- | +| app-server initialize timeout | `app-server-timeout` | degraded snapshot or failed login start | degraded / retryable message | +| app-server process spawn failure | `app-server-unavailable` | degraded snapshot or hard login failure | binary/runtime dependent messaging | +| app-server initialize succeeds but required stable account surface is unavailable | `app-server-incompatible` | feature hidden/locked or hard login failure | update-runtime / incompatible-runtime messaging | +| login returned unsafe or unsupported browser URL | `unsafe-auth-url` | login fails before browser open | explicit security-oriented error, no open attempt | +| browser open failure | `browser-open-failed` | login failed | explicit action error | +| login cancelled by user | `login-cancelled` | login state settles to cancelled | non-destructive informational state | +| login completed with error | `login-failed` | login failed, snapshot refresh follows | explicit error | +| logout RPC failure | `logout-failed` | logout stays unresolved, snapshot preserved | explicit error | +| admin-managed workspace or login policy rejects current account | `workspace-restricted` | login blocked or account cleared by Codex policy | policy-specific guidance, not generic auth-missing | +| app restarted or shut down while login was pending | `login-session-lost` | pending login abandoned, fresh snapshot required on next startup | informational recovery message, settle to idle | +| rate-limits read failure | `rate-limits-unavailable` | rate-limit panel degraded only | non-blocking warning | +| stale result received after newer state | `stale-result-ignored` | no user-visible state change | debug-level logging only | + +Normalization rule: + +- renderer copy should key off normalized categories +- raw stderr / transport text may be attached for diagnostics, but should not drive UX wording + +## Event Ordering And Delivery Rules + +These rules matter because most user-visible bugs in this feature will come from correct data +arriving in the wrong order. + +### Preferred event ordering + +For login success: + +1. `login-state-changed: starting` +2. `login-state-changed: awaiting_browser` +3. `login-state-changed: pending` +4. app-server `account/login/completed success=true` +5. `login-state-changed: completed` +6. forced snapshot refresh +7. `snapshot-updated` +8. optional `rate-limits-updated` after explicit or lazy read +9. `login-state-changed: idle` once settled + +For logout success: + +1. local logout action enters pending state +2. `account/logout` +3. clear last-known-good managed-account snapshot +4. forced snapshot refresh +5. `snapshot-updated` +6. local logout pending state clears + +### Delivery rule + +Feature event delivery should be best-effort and additive: + +- missing one event must not make the renderer permanently stale +- the renderer must always be able to recover by reading a fresh snapshot + +### Idempotency rule + +Renderer and main-side subscribers should tolerate: + +- duplicate `login-state-changed` +- duplicate `snapshot-updated` +- late degraded events that are older than the currently rendered snapshot + +### Staleness rejection rule + +If an incoming event or refresh result is older than the settled snapshot already in memory: + +- ignore it +- log a low-level diagnostic if useful + +Do not let older results reanimate older UI state. + +## Cross-Window And Subscriber Coherence Rules + +The main process must remain the single owner of mutable Codex account state. + +### Main-process ownership rule + +The following state must live in one main-process feature instance, not in renderer-local stores: + +- latest settled snapshot +- last-known-good managed account snapshot +- active login session state +- rate-limit cache +- compatibility verdict for the installed app-server seam + +### Renderer subscription rule + +Any renderer may subscribe late, unsubscribe early, or restart independently. + +Therefore: + +- late subscribers must bootstrap from `getSnapshot()` and not rely on having seen past events +- feature events are accelerators, not the only source of truth +- one renderer closing must not cancel a login session started by another renderer + +### Broadcast rule + +If multiple renderer surfaces are open at once: + +- all should receive the same normalized feature events from the same main-process owner +- no renderer should start a second login flow just because it missed an earlier local UI state + +### Shutdown rule + +If the last renderer unsubscribes while a login is pending: + +- the main-process feature may keep the login session alive until: + - completion + - explicit cancel + - timeout + - app shutdown + +This avoids coupling correctness to whichever window happened to open the flow. + +## App Restart, Crash, And Pending Login Recovery Policy + +Login session state is explicitly process-owned, not durable product state. + +### Startup recovery rule + +On app startup: + +- do not restore a previously pending login from persisted config or renderer state +- initialize login state as `idle` +- perform a fresh snapshot read to determine the actual steady state + +### Shutdown rule + +On app shutdown while login is pending: + +- do not block app quit waiting for remote login completion +- a best-effort `account/login/cancel` is optional, but correctness must not depend on it +- local session state should settle as lost/abandoned for diagnostics only + +### Crash/reload rule + +If the renderer reloads or the app restarts during login: + +- the next session should not show a phantom permanently pending state +- the feature should converge via fresh `account/read`, not by trying to resume an old `loginId` + +### Persistence rule + +Do not persist: + +- pending login state +- `loginId` +- `authUrl` + +Persist only durable user intent: + +- preferred auth mode + +## Operation Serialization And Race Resolution Policy + +The feature must treat auth mutations as serialized control-plane operations, not as unrelated UI +button handlers. + +### Serialization rule + +Serialize these operations through one main-process account-operation gate: + +- start login +- cancel login +- logout +- explicit auth-recovery refresh that escalates to `refreshToken = true` + +Passive reads may coalesce, but mutating operations must not run in parallel. + +### Race resolution rule + +If mutation intent and observed remote completion disagree, final truth comes from the freshest +successful post-mutation snapshot, not from the earlier button click alone. + +### Required race outcomes + +1. cancel requested, then late `login/completed success=true` arrives: + - do a forced snapshot refresh + - if the snapshot shows a connected managed account, show connected state + - do not force logout just to honor the earlier cancel intent +2. logout requested during pending login: + - best-effort cancel login first if needed + - then run logout + - final logged-out truth must win over any stale pre-logout account evidence +3. stale read started before logout settles after logout: + - ignore the stale read result + - never resurrect the old managed account from that stale result + +### Preference-change rule + +Changing preferred auth mode during a pending login affects future launch choice only. + +It must not: + +- silently cancel an in-flight login unless product explicitly chooses that UX later +- rewrite the meaning of a login session that already started under the prior preference + +## Protocol Assumptions We Intentionally Avoid Relying On + +The feature should stay robust even if some secondary protocol details vary. + +Do not rely on: + +- `account/updated` always arriving before or after `account/login/completed` +- rate-limit notifications being delivered during every app lifecycle +- undocumented auth variants being available in every Codex build +- raw CLI reference pages being the only authoritative source for auth override details +- exact raw transport error text remaining stable enough for renderer copy + +Instead, rely on: + +- explicit snapshot refresh for steady-state truth +- normalized error categories +- narrow, documented auth variants for first-wave behavior + +## IPC channels + +Recommended channels: + +- `CODEX_ACCOUNT_GET_SNAPSHOT` +- `CODEX_ACCOUNT_REFRESH_SNAPSHOT` +- `CODEX_ACCOUNT_START_CHATGPT_LOGIN` +- `CODEX_ACCOUNT_CANCEL_LOGIN` +- `CODEX_ACCOUNT_LOGOUT` +- `CODEX_ACCOUNT_GET_RATE_LIMITS` +- `CODEX_ACCOUNT_EVENT` + +## IPC Request / Response Matrix + +This matrix keeps the cross-process contract explicit and prevents renderer/main drift. + +| Channel | Request shape | Response shape | Side effects | Cache interaction | Failure shape | +| --- | --- | --- | --- | --- | --- | +| `CODEX_ACCOUNT_GET_SNAPSHOT` | `{ forceFresh?: boolean }` or no payload | `CodexConnectionSnapshotDto` | none | may serve cached snapshot unless `forceFresh` | returns rejected IPC promise with normalized error message | +| `CODEX_ACCOUNT_REFRESH_SNAPSHOT` | no payload | `CodexConnectionSnapshotDto` | forces control-plane refresh | bypasses normal snapshot TTL | returns rejected IPC promise with normalized error message | +| `CODEX_ACCOUNT_START_CHATGPT_LOGIN` | no payload | `CodexLoginStateDto` | starts login session, may open browser | invalidates snapshot cache on state transitions | returns failed login state or rejected IPC promise for hard failures | +| `CODEX_ACCOUNT_CANCEL_LOGIN` | no payload | `CodexLoginStateDto` | cancels active login if present | no steady-state cache effect except follow-up refresh | safe no-op if no active login | +| `CODEX_ACCOUNT_LOGOUT` | no payload | `CodexConnectionSnapshotDto` | logs out managed account and refreshes snapshot | clears last-known-good managed-account snapshot | rejected IPC promise or error-bearing snapshot depending on final implementation choice | +| `CODEX_ACCOUNT_GET_RATE_LIMITS` | `{ forceFresh?: boolean }` or no payload | `CodexRateLimitsDto \| null` | none | may serve rate-limit cache unless `forceFresh` | returns `null` or rejected IPC promise depending on error-handling contract | +| `CODEX_ACCOUNT_EVENT` | subscription only | `CodexAccountEventDto` | none | n/a | best-effort delivery only | + +Contract rule: + +- all IPC errors should be normalized into stable, user-safe messages +- renderer code must not depend on raw transport/process error text + +## Electron API integration + +Extend: + +- `src/shared/types/api.ts` +- `src/preload/index.ts` + +Pattern should match existing feature slices like `recent-projects`. + +## Preload And Renderer API Shape + +The renderer-facing API should be explicit so we do not leak main-process internals into UI code. + +Recommended preload-facing contract: + +```ts +export interface CodexAccountElectronApi { + getSnapshot: (options?: { forceFresh?: boolean }) => Promise; + refreshSnapshot: () => Promise; + startChatgptLogin: () => Promise; + cancelLogin: () => Promise; + logout: () => Promise; + getRateLimits: (options?: { forceFresh?: boolean }) => Promise; + onEvent: (callback: (event: CodexAccountEventDto) => void) => () => void; +} +``` + +Integration rule: + +- this contract belongs under `src/features/codex-account/contracts` +- `src/preload/index.ts` should only bridge it +- renderer hooks should consume this contract through the app API abstraction, not directly through + ad hoc `window.electronAPI` calls spread across components + +## Account Control-Plane Flows + +## App-Server Method Matrix + +This matrix documents how the feature is expected to use the official app-server surface. + +| Method / notification | Used in phase | Typical caller | Input | Expected output | Notes | +| --- | --- | --- | --- | --- | --- | +| `account/read` | B+ | snapshot use cases | `{ refreshToken?: boolean }` | current account plus `requiresOpenaiAuth` | passive reads should default `refreshToken` to `false` | +| `account/login/start` with `type: "chatgpt"` | E+ | login use case | no extra payload | `loginId` plus `authUrl` | browser flow is first-class | +| `account/login/cancel` | E+ | cancel use case / session manager | active `loginId` | success or no-op | safe to call only when login is active | +| `account/logout` | E+ | logout use case | none | logout acknowledgement | should be followed by forced snapshot refresh | +| `account/rateLimits/read` | F+ | rate-limit use case | none or method-specific default params | plan/rate-limit snapshot | should stay lazy by default | +| `account/updated` | E+ | login session manager / event bridge | notification only | auth mode plus plan changes | may arrive outside explicit reads | +| `account/login/completed` | E+ | login session manager | notification only | success or error for a specific `loginId` | must drive pending-state settlement | +| `account/rateLimits/updated` | F+ | optional rate-limit subscription handling | notification only | updated rate-limit view | should not be required for base snapshot correctness | + +Usage rule: + +- `account/read` is the canonical steady-state read path +- notifications are accelerators for freshness, not replacements for a recoverable read model + +## `refreshToken` Usage Policy + +Official app-server docs and local schema expose `account/read { refreshToken?: boolean }`. + +This flag is powerful enough that the plan should constrain it explicitly. + +### Default rule + +- passive background reads use `refreshToken = false` +- normal explicit refresh also starts with `refreshToken = false` + +### Escalation rule + +Allow a one-time `refreshToken = true` read only when there is a concrete auth-staleness reason, +for example: + +- a just-completed login has not converged after the first normal snapshot read +- explicit user recovery action after an auth-related degraded state + +### Forbidden rule + +Do not: + +- set `refreshToken = true` on every poll +- loop repeated token-refresh reads in the background +- use token refresh as a substitute for the normal snapshot model + +### Operational reason + +- overusing token refresh increases latency and creates another path to false logout or confusing + transient state + +## App-Server Compatibility Gate + +The feature must not equate "binary exists" with "account seam is supported". + +### Stable-surface rule + +First-wave `codex-account` should initialize app-server with: + +- `experimentalApi: false` +- only the notification subscriptions it actually needs + +Do not opt into experimental API just to make the first wave easier. + +### Required handshake contract + +Before the feature reports the managed-account seam as supported, it must prove: + +1. `codex app-server` starts +2. `initialize` succeeds on the stable surface +3. the initialize response yields diagnostics we can record: + - `codexHome` + - `platformFamily` + - `platformOs` +4. required stable methods behave as expected: + - `account/read` + - `account/login/start` + - `account/login/cancel` + - `account/logout` + - `account/rateLimits/read` + +### Compatibility verdict rule + +If initialize works but one of the required stable account methods is absent, rejected as +experimental-only, or otherwise incompatible with the expected shape: + +- classify the feature state as `app-server-incompatible` +- do not classify it as generic missing auth +- do not offer misleading login or subscription controls + +### Versioning rule + +Prefer capability/protocol evidence over string-parsed `codex --version`. + +Semver may still be logged for diagnostics, but: + +- semver alone must not unlock the feature +- semver alone must not disable the feature when the stable handshake succeeds + +## Flow 1 - autodetect existing account + +```mermaid +sequenceDiagram + participant UI as Renderer Hook + participant IPC as Feature IPC + participant UC as Get Snapshot Use Case + participant APP as App Server Client + participant KEY as API Key Adapter + + UI->>IPC: getCodexConnectionSnapshot() + IPC->>UC: execute() + UC->>APP: account/read + UC->>KEY: getApiKeyAvailability() + APP-->>UC: managed account state + KEY-->>UC: api key availability + UC-->>IPC: merged snapshot + IPC-->>UI: snapshot dto +``` + +## Flow 2 - start ChatGPT login + +```mermaid +sequenceDiagram + participant UI as Renderer + participant IPC as Feature IPC + participant UC as Start Login Use Case + participant LOGIN as Login Session Manager + participant APP as App Server + participant BROWSER as Browser Launcher + + UI->>IPC: startCodexChatgptLogin() + IPC->>UC: execute() + UC->>LOGIN: acquire or create login session + LOGIN->>APP: account/login/start(type=chatgpt) + APP-->>LOGIN: authUrl + loginId + LOGIN->>BROWSER: openExternal(authUrl) + LOGIN-->>IPC: login pending + IPC-->>UI: login state changed + APP-->>LOGIN: account/login/completed + LOGIN-->>IPC: snapshot refresh + login completed event + IPC-->>UI: snapshot updated +``` + +## Flow 3 - launch Codex + +```mermaid +sequenceDiagram + participant SHELL as Runtime Shell + participant FACADE as Codex Account Feature + participant READY as Launch Readiness UC + participant ENV as Provider Env Builder + participant EXEC as codex exec + + SHELL->>FACADE: evaluateLaunchReadiness() + FACADE->>READY: execute() + READY-->>FACADE: readiness + effectiveAuthMode + env policy + FACADE-->>SHELL: launch policy + SHELL->>ENV: build env using policy + ENV-->>SHELL: sanitized/injected env + SHELL->>EXEC: launch codex exec +``` + +## Login State Machine + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> starting: start login + starting --> awaiting_browser: authUrl received + awaiting_browser --> pending: browser opened + pending --> completed: account/login/completed success=true + pending --> failed: account/login/completed success=false + pending --> cancelled: account/login/cancel + awaiting_browser --> failed: browser open failed + starting --> failed: start request failed + completed --> idle: refresh settled + cancelled --> idle: refresh settled + failed --> idle: reset +``` + +## Subscription Lifecycle Semantics + +This section describes the intended steady-state lifecycle of a managed ChatGPT-backed Codex +subscription as the app should understand it. + +### Lifecycle phases + +1. no managed account detected +2. login initiated +3. browser auth pending +4. managed account connected +5. temporarily degraded verification +6. explicit logout + +### Important semantic rules + +- `managed account connected` means the control plane has positive evidence of a ChatGPT-backed + account +- `degraded` does not mean disconnected +- explicit logout is stronger than cached prior evidence and must clear it immediately +- API key availability may coexist with any lifecycle phase except `runtime_missing` + +### User-visible implication + +The UI should present the managed account as: + +- connected +- pending +- degraded +- disconnected + +and should not collapse these into one binary `authenticated` flag for Codex. + +## Main-Side Use Cases + +### `GetCodexConnectionSnapshotUseCase` + +Responsibilities: + +- get a cached or fresh merged snapshot +- merge managed account and API key availability +- derive effective auth mode and launch readiness + +Must not: + +- open browser +- mutate config + +### `RefreshCodexConnectionSnapshotUseCase` + +Responsibilities: + +- force a fresh read from app-server +- optionally request proactive token refresh only on explicit user action + +Important nuance: + +- do **not** set `refreshToken = true` on every passive read +- reserve that for explicit manual refresh or post-login reconciliation + +### `StartCodexChatgptLoginUseCase` + +Responsibilities: + +- ensure a single in-flight login +- start login via app-server +- open browser +- update login state + +Must handle: + +- duplicate click while login already pending +- browser open failure +- login timeout + +First implementation decision: + +- browser flow via `type: "chatgpt"` is in scope +- device code flow via `type: "chatgptDeviceCode"` is explicitly deferred unless we discover a real + Electron/browser-launch blocker + +Reason: + +- browser flow matches the prior UX expectation more closely +- device-code support is valuable, but it is not required to restore the intended desktop UX + +### `CancelCodexLoginUseCase` + +Responsibilities: + +- cancel active login if any +- cleanly tear down pending session + +### `LogoutCodexManagedAccountUseCase` + +Responsibilities: + +- perform app-server `account/logout` +- refresh snapshot + +### `ReadCodexRateLimitsUseCase` + +Responsibilities: + +- load rate limits lazily +- avoid blocking basic connection UI + +### `EvaluateCodexLaunchReadinessUseCase` + +Responsibilities: + +- compute launch policy from feature truth +- return: + - readiness state + - effective auth mode + - env mutation instructions + - user-facing advisory + +This use case must become the shell's single source of truth for Codex launch auth policy. + +## Main Infrastructure Design + +## `CodexAccountEnvBuilder` + +Purpose: + +- build consistent env for app-server account sessions +- build deterministic env mutation instructions for execution + +Inputs: + +- resolved shell env +- binary path +- selected auth mode +- API key value or absence + +Outputs: + +- account-session env +- exec-session env policy + +This module exists because generic `buildProviderAwareCliEnv()` currently knows only the old Codex API-key-only world. + +## `CodexAccountAppServerClient` + +Purpose: + +- short-lived request client for: + - `account/read` + - `account/logout` + - `account/rateLimits/read` + +Behavior: + +- request-scoped sessions only +- initialize and dispose per request + +## `CodexLoginSessionManager` + +Purpose: + +- own one long-lived login session while login is pending + +Responsibilities: + +- start login +- observe notifications +- cancel login +- timeout pending login +- emit feature events + +Important rule: + +- there can be at most one active login session at a time + +## Module Responsibility Matrix + +This table is the SRP-oriented version of the implementation design. + +| Module | Owns | Must not own | Typical collaborators | +| --- | --- | --- | --- | +| `CodexAccountAppServerClient` | request-scoped app-server RPC for account/read, logout, rate limits | login session lifecycle, renderer events, config | session factory, logger | +| `CodexLoginSessionManager` | long-lived login session, login notifications, timeout, cancel | generic account/read cache, API key storage, shell copy | app-server transport, browser launcher, logger | +| `CodexAccountEnvBuilder` | auth-store env normalization, auth-sensitive env mutation policy | launch decision semantics, config migration | shell env port, binary resolver port | +| `GetCodexConnectionSnapshotUseCase` | merge snapshot truth and caching policy | browser opening, low-level process logic | managed account source, api key source, cache, clock | +| `EvaluateCodexLaunchReadinessUseCase` | effective auth mode selection and readiness semantics | actual child-process spawning | snapshot/domain models | +| `CodexConnectionCoordinator` | shell-facing launch integration and env assembly handoff | account lifecycle, renderer subscriptions | feature facade, provider connection service | +| presenter adapters | stable DTO/event shaping | domain policy changes | use cases, contracts | +| renderer hooks | subscription orchestration and action wiring | business truth invention | preload API, view-model adapters | +| feature UI components | rendering | transport, config mutation, process logic | hooks, view models | + +Review rule: + +- if a new change makes one row start owning another row's responsibilities, stop and split the + concern before continuing + +## Session And Timeout Policy + +### Short-lived read sessions + +Use for: + +- account/read +- rateLimits/read +- logout + +Recommended timeouts: + +- initialize timeout: aligned with existing app-server defaults +- request timeout: short and bounded + +### Long-lived login session + +Use for: + +- start login +- wait for `account/login/completed` + +Recommended timeout: + +- hard max pending duration around `10 minutes` + +Reason: + +- long enough for browser auth +- short enough to avoid zombie sessions + +## App-Server Session Topology Rule + +The feature should make session ownership explicit so connection-scoped notifications do not leak +between concerns. + +### Topology + +- passive reads: + - short-lived dedicated app-server sessions +- rate-limits reads: + - short-lived dedicated app-server sessions or reused read-session helper, but not the live login + session +- login flow: + - one dedicated long-lived session that owns: + - `account/login/start` + - login notifications + - optional `account/login/cancel` + +### No-pooling rule + +Do not multiplex in the first wave: + +- `recent-projects` and `codex-account` over one shared live app-server child +- login notifications and passive reads over one generic pooled session + +### Cleanup rule + +When a feature-owned app-server session is disposed: + +- kill the child deterministically +- reject outstanding requests +- ignore any late results or notifications from that disposed session generation + +This keeps process cleanup and notification ownership unambiguous. + +### Notification suppression policy + +For read sessions: + +- suppress noisy thread notifications + +For login session: + +- do **not** suppress: + - `account/login/completed` + - `account/updated` + +## Timeout Defaults Table + +These defaults should remain centralized so different callers do not invent incompatible timing. + +| Interaction | Recommended default | Why | +| --- | --- | --- | +| app-server initialize for read sessions | align with existing shared app-server defaults | keeps transport consistent with `recent-projects` | +| `account/read` request timeout | short and bounded | passive reads should fail fast into degraded state | +| `account/logout` request timeout | short and bounded | logout should resolve or fail clearly | +| `account/rateLimits/read` timeout | short-medium | secondary UI should not hang the page | +| login pending max duration | around `10 minutes` | enough for browser auth, short enough to avoid zombie state | +| snapshot cache TTL | around `3-10 seconds` | enough dedupe without stale-feeling UI | +| rate-limits cache TTL | around `30-60 seconds` | secondary UI can be less fresh | +| freshness window for last-known-good managed account | around `60 seconds` | balances resilience and honesty | + +Consistency rule: + +- do not hardcode these values independently in multiple modules +- centralize them in feature-local configuration/constants or shared transport defaults where + appropriate + +## Retry And Backoff Policy + +Retries are one of the easiest ways to accidentally hide truth or create duplicate state +transitions. This feature should be conservative. + +### What may retry automatically + +- passive `account/read` refresh after a transient initialization or timeout failure + +Recommended default: + +- at most one immediate retry for passive reads +- only when the failure is clearly transport-level + +### What should not auto-retry + +- login start +- logout +- cancel login +- manual refresh button actions +- any request that already has a user-visible action outcome + +Why: + +- auto-retrying user actions can create duplicate browser flows, duplicate state transitions, or + surprising side effects + +### Backoff rule + +If passive background refresh keeps failing: + +- do not spin +- let the feature surface `degraded` +- wait for next normal refresh trigger or explicit user action + +### Timeout handling rule + +Timeout must be surfaced as a first-class degraded reason category, not collapsed into generic +"not connected". + +## Caching And Refresh Policy + +### Snapshot cache + +Recommended: + +- in-memory cache in main feature +- small TTL around `3-10 seconds` +- single-flight refresh collapse + +Reason: + +- dashboard banner and settings dialog can ask for the same snapshot nearly simultaneously + +### Rate-limits cache + +Recommended: + +- separate cache +- longer TTL around `30-60 seconds` + +Reason: + +- rate limits are secondary UI, not critical hot-path state + +### Post-action invalidation + +After: + +- login success +- logout success +- explicit refresh + +invalidate the snapshot cache immediately. + +## Shell Integration Plan + +This section makes the integration concrete. + +## Main process composition + +Add feature creation in: + +- `src/main/index.ts` + +Pattern: + +- create the feature alongside `recent-projects` +- register its IPC handlers after feature construction + +Later, if browser-mode support is desired: + +- wire the feature into `src/main/standalone.ts` +- add HTTP adapter endpoints + +## Preload integration + +Add to: + +- `src/preload/index.ts` + +Pattern: + +- same as `recent-projects` +- spread `createCodexAccountBridge()` into `window.electronAPI` + +## Shared API type integration + +Extend: + +- `src/shared/types/api.ts` + +with a new feature API contract interface. + +## Renderer integration + +Shell components that must stop owning Codex business logic: + +- `ProviderRuntimeSettingsDialog` +- `CliStatusBanner` +- `CliStatusSection` +- `providerConnectionUi.ts` + +How they should change: + +1. `ProviderRuntimeSettingsDialog` + - for provider `codex`, render `CodexAccountConnectionPanel` + - stop hardcoding Codex as API-key-only +2. `CliStatusBanner` + - stop using terminal modal login/logout for Codex + - use feature hook actions +3. `CliStatusSection` + - same as banner +4. `providerConnectionUi.ts` + - stop flattening Codex auth summary + - use feature adapter output for Codex-specific text + +## Legacy UX Parity Policy + +The user requirement here is not just "make auth work". It is also "restore the good legacy Codex +subscription UX while keeping the new native runtime". + +That means we should reuse the current shell surfaces and preserve their visual grammar, instead of +inventing a new Codex settings screen. + +### Required UX shape + +The feature should plug into the existing surfaces: + +- provider manage dialog +- dashboard CLI banner +- settings CLI section + +It should not introduce: + +- a separate standalone Codex settings page +- a second disconnected login modal system +- a renderer-only fake status card + +### Required visible information + +For Codex, the composed UI should be able to show: + +- current preferred auth mode +- managed account connected or not +- account email when available +- plan type when available +- API key also available or not +- effective launch mode in auto +- pending login / cancelling / logout states +- degraded-but-still-launchable states + +### Required action set + +For Codex, the composed UI should expose: + +- connect ChatGPT account +- cancel login while pending +- disconnect managed account +- choose preferred auth mode +- manage API key without implying it is the only path +- refresh account state + +### Copy policy + +When Codex is managed-account-backed: + +- do not flatten everything to "Connected via API key" +- do not label the primary action as `Configure API key` +- do not use Anthropic-specific `OAuth` wording + +Preferred wording family: + +- `ChatGPT account` +- `Codex subscription` +- `Plan` +- `API key also available` +- `Auto - prefer ChatGPT account` + +### Visual ownership rule + +The feature owns Codex-specific content blocks. + +The shell owns: + +- container cards +- section framing +- generic button spacing/layout primitives +- provider ordering + +This preserves legacy familiarity without duplicating layout systems. + +## Surface-By-Surface UI Contract + +Each visible shell surface should have a clear responsibility so the same state is not explained in +three conflicting ways. + +### `ProviderRuntimeSettingsDialog` + +Purpose: + +- authoritative management surface for Codex auth preference and account state + +Must show: + +- preferred auth mode selector +- managed account summary +- API key secondary availability +- login / cancel / logout actions +- degraded state explanation +- rate-limit section when requested or expanded + +Must not: + +- pretend to be a generic provider card when provider is `codex` + +### `CliStatusBanner` + +Purpose: + +- concise dashboard summary and quick action entry point + +Must show: + +- one-line Codex status summary derived from the feature +- manage/open action into the richer settings surface +- degraded warning when appropriate + +Must not: + +- become the full account management UI + +### `CliStatusSection` + +Purpose: + +- settings-level operational summary for the installed runtime + +Must show: + +- consistent Codex account summary +- launch-relevant status +- path into full manage dialog + +Must not: + +- use different wording than the manage dialog for the same auth truth + +### Shared UI consistency rule + +For the same underlying snapshot: + +- headline wording +- effective auth mode wording +- degraded wording +- connect/disconnect affordances + +must all stay semantically consistent across surfaces, even if the amount of detail differs. + +## What remains shell-owned + +- generic provider card structure +- generic runtime/backend status +- generic model availability presentation + +## What becomes feature-owned + +- Codex account summary copy +- Codex connect/disconnect actions +- Codex login pending UI +- Codex rate-limit presentation +- Codex auth-mode selection UI + +## Renderer Hook Composition Contract + +To keep renderer code aligned with the feature standard, hooks and adapters should have explicit +roles. + +### `useCodexAccount` + +Responsibilities: + +- fetch and subscribe to the current snapshot +- expose refresh action +- expose derived loading/error/degraded flags for feature UI + +Must not: + +- open browser directly +- compute shell layout copy inline + +### `useCodexLoginFlow` + +Responsibilities: + +- expose login, cancel, and logout actions +- surface pending action state and latest action error + +Must not: + +- own snapshot caching +- own rate-limit fetching + +### `useCodexRateLimits` + +Responsibilities: + +- fetch rate limits lazily +- respect dedicated rate-limit TTL and pending state + +Must not: + +- block base snapshot rendering + +### `codexAccountViewModel` / `codexProviderShellAdapter` + +Responsibilities: + +- merge feature DTOs with generic shell provider status into stable view models +- keep wording and badge semantics consistent across surfaces + +Must not: + +- call transport directly +- mutate app config + +### UI component rule + +Feature UI components should be as close to pure renderers as possible: + +- inputs in +- callbacks out + +This keeps renderer complexity low and makes snapshot-state regression tests much easier. + +## Browser-mode policy + +Initial implementation recommendation: + +- Electron / preload path is the first-class path +- browser-mode support is explicitly deferred unless product requires it immediately + +If browser mode is visible: + +- Codex account feature should degrade honestly as unsupported or unavailable +- do not silently attempt local app-server control through browser mode without explicit HTTP support + +This keeps the architecture clean and avoids half-working local machine assumptions in browser sessions. + +## Browser Auth URL Handling Policy + +The feature should treat login URLs as sensitive, short-lived control-plane data. + +### Open rule + +For Codex ChatGPT login: + +- open the URL from main process only +- validate the URL before opening +- require `https:` scheme +- reject `http:`, `mailto:`, custom schemes, or malformed URLs for this feature-specific path + +### Trust rule + +Do not hardcode a hostname allowlist in the first wave unless official docs start guaranteeing a +stable host set. + +Instead: + +- trust app-server as the source of the URL +- enforce `https` scheme +- avoid logging the full URL +- record only derived diagnostics when needed, such as scheme/hostname + +### IPC rule + +- renderer asks to start login +- main starts login and opens the validated URL +- renderer never receives the raw `authUrl` + +### Failure rule + +If URL validation fails: + +- classify as `unsafe-auth-url` +- fail login cleanly +- do not attempt browser open + +## Runtime Integration Plan + +This is where the feature touches existing runtime services. + +## Existing problem + +`providerAwareCliEnv.ts` and `ProviderConnectionService.ts` currently encode the rule: + +- Codex readiness requires API key presence + +That must change. + +## Recommended integration pattern + +Introduce a small runtime-side coordinator: + +- `src/main/services/runtime/CodexConnectionCoordinator.ts` + +Responsibilities: + +- ask the feature for launch readiness +- ask `ProviderConnectionService` for API key value resolution when needed +- apply auth-mode-specific env mutation policy + +Why a coordinator is better than stuffing more into `ProviderConnectionService`: + +- keeps provider-generic code smaller +- avoids turning `ProviderConnectionService` into a God object +- keeps Codex feature policy close to the feature seam + +## Responsibilities after the coordinator exists + +### `ProviderConnectionService` keeps responsibility for: + +- app-owned API key discovery +- app-owned API key injection primitives +- Anthropic-specific connection mode handling + +### `CodexConnectionCoordinator` owns: + +- choosing ChatGPT vs API key for Codex launch +- deciding which env vars must be sanitized +- deciding which `forced_login_method` override to pass + +### `providerAwareCliEnv.ts` becomes: + +- provider-generic env assembly plus delegation to Codex coordinator when provider is `codex` + +### `ClaudeMultimodelBridgeService.ts` becomes: + +- generic runtime status reader +- plus additive merge of Codex account snapshot for Codex-specific presentation + +## Detailed Touch Points + +Likely first-wave touch points: + +- `src/main/index.ts` +- `src/preload/index.ts` +- `src/shared/types/api.ts` +- `src/main/services/infrastructure/ConfigManager.ts` +- `src/main/ipc/configValidation.ts` +- `src/shared/types/notifications.ts` +- `src/main/services/runtime/providerAwareCliEnv.ts` +- `src/main/services/runtime/ProviderConnectionService.ts` +- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` +- `src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx` +- `src/renderer/components/runtime/providerConnectionUi.ts` +- `src/renderer/components/dashboard/CliStatusBanner.tsx` +- `src/renderer/components/settings/sections/CliStatusSection.tsx` + +Important design rule: + +- shell files should mostly lose Codex-specific conditional logic, not gain more of it + +## File-Level Implementation Map + +This section translates the architecture into concrete repo edits so the work stays transparent. + +### New feature files + +Expected first-wave additions: + +- `src/features/codex-account/contracts/api.ts` +- `src/features/codex-account/contracts/channels.ts` +- `src/features/codex-account/contracts/dto.ts` +- `src/features/codex-account/contracts/events.ts` +- `src/features/codex-account/contracts/index.ts` +- `src/features/codex-account/core/domain/*` +- `src/features/codex-account/core/application/ports/*` +- `src/features/codex-account/core/application/use-cases/*` +- `src/features/codex-account/main/composition/createCodexAccountFeature.ts` +- `src/features/codex-account/main/adapters/input/ipc/registerCodexAccountIpc.ts` +- `src/features/codex-account/main/adapters/output/presenters/*` +- `src/features/codex-account/main/adapters/output/runtime/*` +- `src/features/codex-account/main/infrastructure/cache/*` +- `src/features/codex-account/main/infrastructure/codex/*` +- `src/features/codex-account/preload/createCodexAccountBridge.ts` +- `src/features/codex-account/preload/index.ts` +- `src/features/codex-account/renderer/index.ts` +- `src/features/codex-account/renderer/adapters/*` +- `src/features/codex-account/renderer/hooks/*` +- `src/features/codex-account/renderer/ui/*` + +### Existing files that should mostly gain composition hooks, not deep new business logic + +#### `src/main/index.ts` + +Should: + +- instantiate the feature +- register IPC +- expose a small facade to shell services + +Should not: + +- implement account/read logic inline +- manage login state inline + +#### `src/preload/index.ts` + +Should: + +- merge the feature bridge into `window.electronAPI` + +Should not: + +- contain auth logic +- transform account domain state into shell copy + +#### `src/main/services/infrastructure/ConfigManager.ts` + +Should: + +- add persisted `providerConnections.codex.preferredAuthMode` +- normalize stale legacy Codex connection values + +Should not: + +- resolve runtime effective auth mode + +#### `src/main/ipc/configValidation.ts` + +Should: + +- accept only the new normalized Codex connection field +- reject new unknown fields after migration normalization + +Should not: + +- infer defaults that belong to `ConfigManager` + +#### `src/main/services/runtime/ProviderConnectionService.ts` + +Should: + +- remain owner of app API key storage lookup and injection primitives + +Should not: + +- remain final authority for Codex launch readiness +- own ChatGPT managed-account policy + +#### `src/main/services/runtime/providerAwareCliEnv.ts` + +Should: + +- delegate Codex-specific env policy to the coordinator / feature seam + +Should not: + +- continue hardcoding API-key-only Codex logic + +#### `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` + +Should: + +- keep generic provider/runtime status probing +- optionally compose Codex account snapshot into Codex-facing presentation + +Should not: + +- become the owner of Codex account lifecycle + +#### `src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx` + +Should: + +- host the feature-owned Codex panel + +Should not: + +- directly own Codex login flow logic +- directly derive Codex copy from generic provider flags alone + +#### `src/renderer/components/runtime/providerConnectionUi.ts` + +Should: + +- become thinner for Codex +- consume feature adapters for Codex-specific labels + +Should not: + +- remain the place where Codex account semantics are invented + +#### `src/renderer/components/dashboard/CliStatusBanner.tsx` +#### `src/renderer/components/settings/sections/CliStatusSection.tsx` + +Should: + +- call feature actions / hooks +- render feature-composed Codex status segments + +Should not: + +- keep normal Codex login/logout on terminal modal commands once the feature is complete + +## Failure Modes And Safety Policy + +### Failure mode: no Codex binary + +Behavior: + +- state becomes `runtime_missing` +- login actions disabled +- connection panel explains binary missing + +### Failure mode: app-server initialize failure + +Behavior: + +- managed-account state becomes degraded +- API key availability remains visible +- do not auto-mark user as logged out +- do not hard-stop launch if execution could still work + +Additional rule: + +- if the last successful snapshot is still within the acceptable freshness window, prefer showing + `degraded` over collapsing to `not_connected` + +### Failure mode: account read timeout + +Behavior: + +- same as degraded +- last good snapshot may be reused briefly if not stale beyond TTL + +### Failure mode: login start failure + +Behavior: + +- `login.state = failed` +- snapshot remains refreshable + +### Failure mode: unsafe auth URL + +Behavior: + +- `login.state = failed` +- do not attempt browser open +- surface a security-oriented error category rather than a generic browser failure + +### Failure mode: browser open failure + +Behavior: + +- `login.state = failed` +- no implicit retry loop +- preserve returned login metadata in memory long enough to support explicit retry or diagnostics + +### Failure mode: login completed false + +Behavior: + +- keep explicit error message +- invalidate login session +- refresh snapshot once + +### Failure mode: logout failure + +Behavior: + +- keep current snapshot +- surface error + +### Failure mode: app restart or shutdown during pending login + +Behavior: + +- next app session starts from `idle` +- no persisted phantom pending state +- fresh snapshot determines whether login actually completed elsewhere or must be retried + +### Failure mode: rate-limits failure + +Behavior: + +- degrade only the rate-limit panel +- do not mark account disconnected + +## Diagnostics And Logging Policy + +We want enough observability to debug auth issues, but not enough to leak secrets or create false +confidence from logs. + +### Recommended structured events + +Add additive logs around: + +- account snapshot refresh started +- account snapshot refresh settled +- login started +- login browser open attempted +- login completed +- login cancelled +- logout started +- logout settled +- launch readiness resolved +- execution auth mode resolved +- degraded account read + +### Safe fields to log + +- provider id +- backend id +- preferred auth mode +- effective auth mode +- snapshot state +- readiness state +- requiresOpenaiAuth +- binary available +- degraded reason category + +### Fields to avoid logging verbatim + +- `authUrl` +- API keys +- refresh tokens +- full account email if logging policy treats it as sensitive +- raw `loginId` unless support/debug mode explicitly needs it + +Recommended compromise for email: + +- either do not log it +- or log only a redacted form for support diagnostics + +Recommended compromise for `loginId`: + +- either do not log it +- or log only a short fingerprint / suffix that cannot be used as a live control token + +Recommended compromise for `authUrl`: + +- log at most: + - URL scheme + - hostname + - whether validation passed +- never log query parameters or full path verbatim + +### Important anti-lie rule + +Logs must distinguish: + +- `app-server degraded` +- `managed account absent` +- `execution failed` + +These are different operational truths and must not be collapsed into one generic auth error. + +## Telemetry And Operational Metrics + +We do not need heavy telemetry to build the feature, but we should design enough observability to +see whether rollout is healthy. + +### Recommended counters + +- `codex_account_snapshot_refresh_total` +- `codex_account_snapshot_refresh_degraded_total` +- `codex_account_login_start_total` +- `codex_account_login_success_total` +- `codex_account_login_failure_total` +- `codex_account_login_cancel_total` +- `codex_account_logout_total` +- `codex_account_launch_ready_chatgpt_total` +- `codex_account_launch_ready_api_key_total` +- `codex_account_launch_missing_auth_total` + +### Recommended timers / histograms + +- snapshot refresh latency +- app-server initialize latency +- `account/read` latency +- login time to completion +- logout latency + +### Recommended rollout health signals + +During rollout, we should watch for: + +- degraded refresh rate +- login failure rate +- mismatch rate between expected and effective auth mode +- launch failures after a `ready_chatgpt` decision + +### Anti-goal + +Do not let telemetry become a hidden source of truth for auth semantics. + +It is for diagnosis only, not for deciding whether the user is connected. + +## Troubleshooting Playbook + +When this feature misbehaves, these are the most likely symptom clusters and where to look first. + +### Symptom - UI says ChatGPT account connected, but launch behaves like API key + +Check: + +- ChatGPT launch env sanitization +- `forced_login_method="chatgpt"` override actually being passed +- whether ambient `OPENAI_API_KEY` or `CODEX_API_KEY` survived into exec env + +Likely owner: + +- `CodexConnectionCoordinator` +- `CodexAccountEnvBuilder` + +### Symptom - UI loses account state after a transient refresh failure + +Check: + +- degraded-path handling +- freshness window logic +- last-known-good snapshot clearing behavior + +Likely owner: + +- snapshot merge use case +- cache / freshness policy + +### Symptom - login opens browser twice or gets stuck pending + +Check: + +- login session exclusivity guard +- duplicate-click handling +- notification delivery and timeout cleanup + +Likely owner: + +- `CodexLoginSessionManager` + +### Symptom - login never resumes after app restart + +Check: + +- whether pending login was incorrectly persisted +- whether startup incorrectly restored stale renderer-local login state +- whether the first fresh snapshot after restart was skipped + +Likely owner: + +- startup recovery policy +- main-process feature initialization + +### Symptom - cancel appears to work, but account still becomes connected + +Check: + +- whether cancel raced with a login that had already completed upstream +- whether the final post-cancel snapshot was used as the source of truth +- whether UI incorrectly treated cancel intent as stronger than fresh steady-state truth + +Likely owner: + +- operation serialization policy +- login session manager +- post-mutation snapshot settlement + +### Symptom - renderer surfaces disagree about current state + +Check: + +- feature view-model adapters +- whether one surface is reading raw provider status instead of the feature snapshot +- whether stale event ordering is overriding newer state + +Likely owner: + +- renderer adapters / hooks +- shell composition boundary + +### Symptom - app-server sees account, but exec does not + +Check: + +- resolved `HOME` +- resolved `USERPROFILE` +- resolved `CODEX_HOME` +- whether app-server and exec are built from the same auth-root normalization path + +Likely owner: + +- env builder +- shell env source adapter + +## Security And Privacy Rules + +These are non-negotiable. + +1. Do not parse `auth.json`. +2. Do not copy Codex-managed tokens into app storage. +3. Do not log auth URLs verbatim if they may include sensitive values. +4. Do not persist login session ids beyond process lifetime. +5. Keep account metadata in memory by default. +6. Keep API keys in the app's existing secure storage only. +7. Do not use `account/login/start { type: "apiKey" }` in the first implementation. +8. Do not send raw `authUrl` over IPC to renderer. +9. Do not persist `loginId` or pending login state across restarts. +10. Validate login URLs with a feature-specific `https`-only policy before browser open. + +### Why we should not use app-server API-key login mode + +Because it would create two overlapping secret owners: + +- app secure storage +- Codex internal storage + +That violates: + +- DRY +- single responsibility +- clear ownership + +So the rule is: + +- managed ChatGPT account is owned by Codex +- API key is owned by the app + +## Security Review Checklist + +Before rollout, the implementation should be reviewed against this checklist. + +### Secret handling + +- no API key written into feature logs +- no auth URL logged verbatim +- no ChatGPT managed token copied into app storage +- no legacy auth files parsed directly + +### Process/env handling + +- ChatGPT launches strip `OPENAI_API_KEY` +- ChatGPT launches strip `CODEX_API_KEY` +- API-key launches inject only the intended key +- app-server and exec use the same auth-store env roots + +### Persistence handling + +- only `preferredAuthMode` is persisted for Codex connection preference +- login ids are not persisted across process lifetime +- last-known-good managed account cache is memory-only + +### UI honesty + +- degraded control-plane state is not shown as confirmed logout +- API-key availability is not shown as managed-account connection +- managed-account connection is not shown as API-key billing + +### Rollout safety + +- no hidden automatic fallback from ChatGPT launch failure to API-key launch +- no normal UI path silently invokes legacy Codex transport + +## Testing Strategy + +## Unit tests - feature core + +Add tests for: + +- managed account + API key merge rules +- effective auth mode resolution +- launch readiness resolution +- degraded-state behavior +- migration defaults + +## Unit tests - main infrastructure + +Add tests for: + +- `CodexAccountEnvBuilder` +- `CodexAccountAppServerClient` +- `CodexLoginSessionManager` +- cache TTL and single-flight behavior +- app-server timeout handling +- env sanitization rules + +Critical cases: + +1. managed account exists and ambient API key is present +2. ChatGPT mode must strip API keys +3. API-key mode must inject API key +4. account read must not inherit ambient API keys +5. same HOME / USERPROFILE / CODEX_HOME resolution used for read and exec + +## Unit tests - renderer + +Add tests for: + +- ChatGPT connected state +- API-key only state +- both-available state +- degraded state +- runtime missing state +- login pending state +- plan type display +- rate-limit panel visibility + +## Integration tests - shell + +Update existing shell tests: + +- `ProviderRuntimeSettingsDialog.test.ts` +- `CliStatusVisibility.test.ts` +- `providerAwareCliEnv.test.ts` +- `ProviderConnectionService.test.ts` +- `ClaudeMultimodelBridgeService.test.ts` + +## Test Matrix - must-cover scenarios + +This matrix should be used to ensure we are not only testing the happy path. + +| Scenario | Snapshot expectation | Launch expectation | UI expectation | Test level | +| --- | --- | --- | --- | --- | +| Binary missing | `runtime_missing` | blocked | login disabled, missing runtime copy | unit + renderer | +| Managed account only | `managed_account_connected` | ChatGPT launch | plan/email visible | unit + integration | +| API key only | `api_key_available` | API-key launch | API key available copy | unit + integration | +| Both available with `auto` | `both_available` | ChatGPT launch | Auto prefers ChatGPT | unit + integration | +| Both available with `api_key` | `both_available` | API-key launch | API key preferred copy | unit + integration | +| Managed account detected but app-server degraded | `degraded` | warning launchable | degraded banner, not false logout | unit + integration | +| No auth and app-server degraded | `degraded` or `not_connected` depending freshness | blocked unless freshness rule applies | unable to verify copy | unit | +| App-server stable handshake incompatible | feature locked or degraded with incompatibility verdict | no misleading login affordance | update-runtime / incompatible-runtime copy | unit + integration | +| Login pending | `login_in_progress` or pending login state | no duplicate login start | cancel action visible | unit + renderer | +| Two renderer subscribers during one login flow | shared pending state in both surfaces | one login only | consistent pending/cancel UI in both places | unit + renderer integration | +| Pending login lost on app restart | fresh snapshot wins, no phantom pending state | login can be retried cleanly | informational recovery or silent idle reset | unit + integration | +| Unsafe login URL returned | login fails before open | no browser open side effect | explicit safe error state | unit | +| Cancel races with late login completion | freshest post-race snapshot wins | no forced false logout | pending clears into truthful connected or idle state | unit + integration | +| Logout races with stale pre-logout read | post-logout truth wins | no resurrection of old account | disconnected state remains stable | unit + integration | +| Corrupted legacy Codex config subtree | normalizes to safe default | no launch-preference crash | settings load with sane default copy | unit | +| Browser open failure | failed login state | no launch behavior change | explicit error surfaced | unit + renderer | +| Logout success | no managed account | auto may fall back to API key | UI clears managed account | integration | +| Managed workspace restriction | no phantom managed account | launch blocked with policy truth | workspace-restricted copy, no fake workspace switcher | unit + renderer | +| Stale slow read after fast successful read | latest successful snapshot preserved | none | no regression flicker | unit | +| Ambient API key present during ChatGPT launch | managed account still primary | keys stripped | no API-key-primary wording | unit + integration | + +## Test doubles and harness requirements + +To keep the implementation testable, we should plan the following fakes: + +- fake app-server client for deterministic `account/read`, `account/logout`, and rate-limit reads +- fake app-server initialize handshake that can: + - succeed with stable account support + - succeed but reject required methods as incompatible + - return diagnostic `codexHome` / platform metadata +- fake login session transport that can emit: + - success + - failure + - timeout + - duplicate notification + - late success after cancel intent +- fake browser launcher that can: + - succeed + - fail +- fake login URL validator that can: + - accept valid `https` URLs + - reject unsafe schemes or malformed values +- fake clock for TTL and freshness testing +- fake API key source adapter +- fake shell env source for `HOME` / `USERPROFILE` / `CODEX_HOME` determinism +- fake config input fixtures covering: + - missing Codex subtree + - stale legacy keys + - malformed non-object Codex subtree + +Important rule: + +- do not make most tests spawn the real `codex app-server` +- reserve real app-server checks for live signoff and a very small number of high-value integration tests + +## Live signoff + +Required live or semi-live signoff: + +1. already logged-in ChatGPT account autodetects without relogin +2. launch succeeds with ChatGPT auth and no API key +3. launch succeeds with API key mode +4. both-available state prefers ChatGPT in auto mode +5. chatgpt mode strips API keys from the exec env +6. login opens browser and completes +7. logout clears managed account state +8. initialize diagnostics capture the expected `codexHome` and compatibility verdict +9. if a managed-policy environment is available, workspace restriction surfaces policy-specific copy +10. restarting during a pending login does not leave the next app session stuck pending +8. app-server degradation does not falsely report logged out +9. app-server degradation does not falsely hard-block `codex exec` + +## Manual QA And Failure Injection Checklist + +In addition to automated tests, the following manual checks are high value because they exercise +real process boundaries and browser behavior. + +### Happy-path manual checks + +- open the app with an already logged-in ChatGPT-backed Codex account +- verify autodetect without relogin +- verify `auto` chooses ChatGPT +- verify explicit `api_key` preference still works when an API key exists + +### Failure-injection manual checks + +- break or suspend app-server startup and verify the UI shows `degraded`, not false logout +- remove the stored API key and verify `api_key` preference becomes `missing_auth` +- keep an ambient API key in the shell env and verify explicit ChatGPT launch still strips it +- trigger login and close/cancel before completion +- trigger login and force browser-open failure if possible via a fake or test harness +- logout and verify stale degraded reads do not resurrect the old account state + +### Visual parity checks + +- compare dashboard banner, settings section, and manage dialog for the same state +- verify wording consistency for: + - connected ChatGPT account + - API-key-only availability + - both available + - degraded + - runtime missing + +## Signoff Artifacts And Evidence Discipline + +To keep rollout transparent, each significant phase should leave behind explicit evidence. + +### Recommended evidence file + +Create and maintain: + +- `docs/research/codex-app-server-account-signoff-evidence.md` + +### What evidence should include + +For each completed phase: + +- date +- branch / commit SHA +- what scenarios were exercised +- what test suites were run +- what live manual checks were run +- whether any known gaps remain + +### Minimum evidence snippets + +Capture: + +- one managed-account autodetect result +- one API-key-only result +- one both-available result +- one degraded-path result +- one ChatGPT launch proof with API keys absent from effective exec env + +### Anti-handwave rule + +Do not mark a phase "done" based only on: + +- unit tests +- visual inspection +- a single happy-path local login + +We need evidence that the failure and degraded paths behave truthfully too. + +## Recommended Signoff Commands + +These commands should be treated as the default signoff baseline for this repo unless implementation +details require a small adjustment. + +### Targeted runtime and UI suite + +```bash +pnpm test -- \ + test/main/services/runtime/providerAwareCliEnv.test.ts \ + test/main/services/runtime/ProviderConnectionService.test.ts \ + test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \ + test/main/ipc/configValidation.test.ts \ + test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \ + test/renderer/components/runtime/providerConnectionUi.test.ts \ + test/renderer/components/cli/CliStatusVisibility.test.ts +``` + +### Targeted `recent-projects` safety suite + +```bash +pnpm test -- \ + test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts \ + test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts \ + test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts \ + test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts \ + test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts +``` + +### Typecheck baseline + +```bash +pnpm typecheck +``` + +### Lint baseline + +```bash +pnpm lint +``` + +### Full repo safety pass before wider rollout + +```bash +pnpm test +pnpm typecheck +pnpm lint +``` + +### Live manual signoff + +There is no single universal script for this yet, so the plan should expect a small manual desktop +signoff pass covering: + +- autodetect existing ChatGPT account +- ChatGPT-backed launch without API key +- API-key-backed launch +- degraded-path truthfulness +- logout and stale-state non-resurrection + +## Release Readiness Checklist + +Before wider rollout or merge into the main delivery branch, the implementation should satisfy this +checklist end-to-end. + +### Contract readiness + +- feature contracts compile cleanly +- preload bridge matches contracts +- renderer hooks consume only the supported API surface + +### Runtime readiness + +- ChatGPT and API-key launches both work +- exec env sanitization is verified +- app-server degradation is truthful and non-destructive + +### UI readiness + +- dashboard, settings, and manage dialog agree on the same state +- Codex wording no longer flattens everything to API key +- terminal-modal Codex login path is removed or intentionally retained only per rollout policy + +### Safety readiness + +- security checklist is green +- troubleshooting playbook scenarios have at least been spot-checked +- signoff evidence file has current results + +### Regression readiness + +- targeted runtime/UI suites pass +- targeted `recent-projects` safety suite passes +- typecheck and lint pass + +## Post-Release Triage Checklist + +If issues appear after internal rollout, use this triage order before making design changes. + +### 1. Determine the symptom class + +- wrong UI state +- wrong launch auth mode +- login lifecycle bug +- degraded-state regression +- rate-limit-only issue + +### 2. Confirm whether steady-state snapshot is correct + +Ask: + +- does a forced fresh snapshot show the right account truth? + +If yes: + +- the bug is more likely in event ordering, caching, or renderer composition + +If no: + +- the bug is more likely in control-plane reads, env routing, or migration/config + +### 3. Confirm whether execution env matches launch decision + +Ask: + +- did the launch decision say `chatgpt` or `api_key`? +- did the actual exec env honor that? + +### 4. Confirm whether auth-store roots match + +Ask: + +- did app-server and exec use the same `HOME` / `USERPROFILE` / `CODEX_HOME`? + +### 5. Only then consider rollback or feature hiding + +The first response should usually be: + +- diagnose +- patch the narrow seam + +not: + +- broaden fallback +- revive legacy transport + +## Rollout Plan + +## Recommended rollout shape + +1. land feature behind internal enablement +2. wire read-only autodetect first +3. wire launch policy next +4. wire login/logout UI after launch policy is correct +5. remove Codex terminal modal login path only after feature login is green + +## Why this order matters + +The most dangerous intermediate state is: + +- UI claims subscription support +- runtime still hard-requires API key + +So launch policy must land before or together with user-facing subscription affordances. + +## Safe Disable And Rollback Policy + +This feature should be additive enough that we can disable exposure without corrupting runtime +truth. + +### Safe disable rule + +If the feature must be temporarily hidden during rollout: + +- hide Codex managed-account UI affordances +- keep persisted `preferredAuthMode` readable +- do not auto-rewrite user preference +- do not silently remap ChatGPT preference to API-key preference + +### What rollback must not do + +Do not respond to rollout stress by: + +- reintroducing legacy Codex transport +- auto-falling back from failed ChatGPT launch to API key without user intent +- erasing Codex account metadata from config or UI state + +### Acceptable temporary rollback shape + +If we need a temporary rollback during early rollout: + +- feature UI can be hidden or marked internal +- launch policy can remain additive and guarded +- the old terminal-modal Codex login path may remain only until the feature is fully proven + +This preserves correctness without lying about runtime semantics. + +## Remaining Open Questions And Recommended Defaults + +The plan is intentionally decisive, but a few implementation choices can still remain configurable. +These should not block the first pass because we already have recommended defaults. + +### Open question 1 - exact freshness window duration + +Recommended default: + +- `60 seconds` + +Rationale: + +- long enough to survive brief app-server instability +- short enough to avoid long-lived stale-account lies + +### Open question 2 - whether rate limits should auto-load or stay lazy + +Recommended default: + +- lazy-load rate limits + +Rationale: + +- account summary and launch readiness are higher priority +- rate limits should not slow the base snapshot path + +### Open question 3 - whether device-code login should land in phase 1 + +Recommended default: + +- no, keep it deferred + +Rationale: + +- browser flow is more aligned with the intended UX +- device code adds scope without unblocking the primary desktop path + +### Open question 4 - whether to expose degraded state as a separate badge vs text-only warning + +Recommended default: + +- keep degraded as explicit text/state first, add badge only if it materially improves clarity + +Rationale: + +- textual honesty is more important than visual flourish + +### Open question 5 - whether to hard-pin a minimum Codex binary version + +Recommended default: + +- no hard semver pin for the first wave beyond diagnostics and troubleshooting + +Rationale: + +- protocol/capability handshake is a safer unlock mechanism than guessing from version strings +- hard pins are still possible later if rollout evidence shows a real compatibility floor + +### Open question 6 - whether Codex account snapshot should be folded more deeply into generic runtime status + +Recommended default: + +- no, keep it as a separate feature snapshot and compose at the shell boundary + +Rationale: + +- preserves clean bounded contexts +- avoids poisoning generic provider contracts with Codex-specific semantics + +### Open question 7 - whether to hard-allowlist ChatGPT login hostnames + +Recommended default: + +- no hard hostname allowlist in the first wave, only strict `https` validation plus redacted + diagnostics + +Rationale: + +- current docs and schema establish the existence of `authUrl`, but not a future-proof hostname + contract +- strict scheme validation gives strong safety without coupling the feature to undocumented host + choices + +### Open question 8 - whether explicit user refresh should immediately escalate to `refreshToken = true` + +Recommended default: + +- no, start with a normal read and escalate only on concrete auth-staleness evidence + +Rationale: + +- keeps manual refresh predictable without overusing token refresh as a blunt instrument +- preserves a clear distinction between ordinary state refresh and auth recovery + +## Risk Register + +This section lists the most important remaining implementation risks and how the plan contains them. + +### Risk 1 - false subscription billing semantics + +Failure shape: + +- UI says ChatGPT account is active +- launch silently uses API key + +Mitigation: + +- ChatGPT execution must strip `OPENAI_API_KEY` and `CODEX_API_KEY` +- force `forced_login_method="chatgpt"` +- add dedicated tests for this exact case + +### Risk 2 - split auth store between control plane and execution + +Failure shape: + +- app-server sees logged-in account +- `codex exec` uses different `HOME` / `CODEX_HOME` + +Mitigation: + +- centralize env resolution +- test exact parity of resolved auth store env for read/login/logout/exec + +### Risk 3 - false logout on transient app-server failure + +Failure shape: + +- timeout or initialize failure +- UI collapses to `not connected` + +Mitigation: + +- degraded state is first-class +- freshness window can preserve last-known-good account truth temporarily + +### Risk 4 - shell regains ownership and recreates coupling + +Failure shape: + +- banner/dialog/section each re-implement Codex logic differently + +Mitigation: + +- feature owns Codex semantics +- shell only hosts composition and generic layout + +### Risk 5 - config drift creates silent preference flips + +Failure shape: + +- presence of API key or account implicitly changes user preference + +Mitigation: + +- explicit separation of persisted preference and observed availability +- one-way normalization with no inferred preference writes + +### Risk 6 - rollout exposes login before launch policy is correct + +Failure shape: + +- user can log into ChatGPT in UI +- runtime still blocks without API key + +Mitigation: + +- rollout gate order enforces launch policy before or alongside subscription UX + +### Risk 7 - over-expansion of scope into browser mode or apiKey app-server login + +Failure shape: + +- feature accumulates multiple control planes in first wave + +Mitigation: + +- browser mode explicitly deferred +- app-server `apiKey` login explicitly out of first implementation + +## Follow-On Plan For `agent_teams_orchestrator` + +This feature is intentionally scoped to `claude_team` first, but we should document the expected +later parity path now so the first implementation does not paint us into a corner. + +### What should carry over later + +- normalized Codex auth vocabulary + - `preferredAuthMode` + - `effectiveAuthMode` + - `degraded` + - managed-account vs API-key truth +- same launch-readiness semantics +- same exec env sanitization rules +- same no-silent-fallback rule + +### What should not be copied blindly + +- Electron-specific browser launch adapters +- preload contracts +- renderer hook design + +### Suggested parity order + +1. share only pure policy and data-shape concepts first +2. extract any provider-agnostic launch-readiness helpers only if duplication is real +3. keep transport/UI/process integration repo-specific + +### Important guardrail + +Do not prematurely contort the `claude_team` implementation around orchestrator parity if it makes +the desktop feature worse or harder to reason about. + +## Phase Gates And Go / No-Go Rules + +This section is the release-quality version of the rollout. + +### Gate 1 - read-only truth is trustworthy + +Must be true before exposing any new login affordance: + +- autodetect works for already logged-in ChatGPT users +- API key availability still shows correctly +- degraded account reads do not erase last-known-good truth immediately +- no current non-Codex provider behavior regresses + +### Gate 2 - launch policy is trustworthy + +Must be true before defaulting UI toward subscription messaging: + +- ChatGPT-backed launch works with no API key present +- ChatGPT-backed launch strips API keys from env +- API-key launch still works +- auto mode resolves deterministically and observably + +### Gate 3 - interactive login is trustworthy + +Must be true before removing terminal-modal login for Codex: + +- browser login starts reliably +- duplicate clicks do not create duplicate sessions +- cancel works +- logout works +- stale cached snapshots do not reappear after login/logout transitions + +### Gate 4 - UI parity is trustworthy + +Must be true before calling the feature complete: + +- no normal Codex UI path still says API key is the only connection mechanism +- dashboard and settings surfaces agree on account truth +- the rendered Codex panel uses the same account truth as launch policy + +### Hard no-go conditions + +Do not ship the feature if any of these remain true: + +- ChatGPT mode can still execute with inherited API keys +- app-server and exec use different auth storage roots +- degraded app-server reads collapse to false logout +- `ProviderConnectionService` remains the final readiness authority for Codex +- Codex login in normal UI still routes through terminal modal commands + +## Implementation Phases And Commit Boundaries + +### Phase A - shared transport extraction + +Goal: + +- extract reusable app-server transport primitives from `recent-projects` + +Estimated size: + +- `250-450` lines + +Suggested commit: + +- `refactor(codex-account): extract shared app-server transport` + +Primary deliverables: + +- extracted generic JSON-RPC stdio primitives +- no deep import from `recent-projects` into the new feature +- `recent-projects` still green on the extracted transport + +Acceptance criteria: + +- no behavior change for `recent-projects` +- extracted transport owns initialize/initialized handshake +- transport defaults are centralized in one place + +### Phase B - feature skeleton and read-only snapshot + +Goal: + +- create the feature slice +- implement `account/read` +- implement snapshot DTO and IPC bridge + +Estimated size: + +- `450-750` lines + +Suggested commit: + +- `feat(codex-account): add managed account snapshot feature` + +Primary deliverables: + +- feature slice exists with contracts, main, preload, renderer entrypoints +- `account/read` wired through feature IPC +- API key availability merged into snapshot +- cached snapshot and degraded state logic working + +Acceptance criteria: + +- already logged-in ChatGPT user is detected +- API-key-only user is detected +- degraded app-server read does not present false logout + +### Phase C - config, migration, and renderer composition + +Goal: + +- add `preferredAuthMode` +- integrate feature panel into runtime settings and provider cards + +Estimated size: + +- `300-550` lines + +Suggested commit: + +- `feat(codex-account): add codex auth preference and shell composition` + +Primary deliverables: + +- persisted `preferredAuthMode` +- config migration on load and save +- shell surfaces render the feature-owned Codex panel + +Acceptance criteria: + +- stale legacy Codex config normalizes forward +- renderer can display auto/chatgpt/api_key preference correctly +- generic shell layout remains unchanged for non-Codex providers + +### Phase D - runtime launch policy integration + +Goal: + +- remove API-key-only hard gate +- add env sanitization and `forced_login_method` policy + +Estimated size: + +- `300-600` lines + +Suggested commit: + +- `feat(codex-account): wire codex launch readiness policy` + +Primary deliverables: + +- Codex launch readiness no longer owned by API-key-only logic +- ChatGPT env sanitization wired +- `forced_login_method` wired per effective auth mode + +Acceptance criteria: + +- ChatGPT launch works without API key +- API-key launch still works +- ambient API keys cannot hijack ChatGPT launch mode + +### Phase E - login, cancel, logout + +Goal: + +- full managed login lifecycle via app-server + +Estimated size: + +- `350-650` lines + +Suggested commit: + +- `feat(codex-account): add codex app-server login lifecycle` + +Primary deliverables: + +- browser login flow +- cancel flow +- logout flow +- live login session manager with timeout and duplicate-click safety + +Acceptance criteria: + +- one login session at a time +- login success refreshes snapshot +- logout clears managed account state in UI + +### Phase F - rate limits and final shell cleanup + +Goal: + +- add rate limits +- remove Codex terminal modal auth path + +Estimated size: + +- `150-300` lines + +Suggested commit: + +- `refactor(codex-account): finalize native codex account ui` + +Primary deliverables: + +- rate-limit panel +- final Codex copy cleanup +- terminal-modal Codex login/logout removed from normal UI paths + +Acceptance criteria: + +- dashboard and settings show consistent Codex account story +- plan type and rate limits are visible when available +- no normal Codex UI path still presents API key as the only connection method + +## Phase-By-Phase Task Checklist + +This section gives the recommended execution order inside each phase so implementation can proceed +with fewer ambiguous jumps. + +### Phase A checklist + +1. extract generic JSON-RPC stdio transport primitives out of `recent-projects` +2. move initialize/initialized handshake helpers into shared Codex app-server infrastructure +3. repoint `recent-projects` to the extracted transport +4. verify no behavior change in `recent-projects` + +### Phase B checklist + +1. scaffold `src/features/codex-account` public entrypoints +2. define DTOs, channels, and event contracts +3. implement `account/read` client +4. implement managed-account plus API-key merge logic +5. add cache and single-flight snapshot reads +6. register IPC and preload bridge +7. verify read-only snapshot in renderer/devtools path + +### Phase C checklist + +1. add `providerConnections.codex.preferredAuthMode` to config types +2. implement read-time and write-time normalization +3. update validation to the new shape +4. create feature-owned Codex panel and adapters +5. integrate panel into runtime settings/manage surfaces +6. remove API-key-only wording from Codex-specific renderer paths + +### Phase D checklist + +1. implement launch-readiness use case +2. implement auth-specific exec env policy +3. introduce runtime coordinator for Codex launch decisions +4. delegate Codex env assembly away from API-key-only logic +5. verify ChatGPT launch, API-key launch, and degraded-path behavior + +### Phase E checklist + +1. implement login session manager +2. implement browser login start flow +3. implement cancel flow +4. implement logout flow +5. wire login events into snapshot refresh and renderer subscriptions +6. verify duplicate-click safety and timeout behavior + +### Phase F checklist + +1. implement lazy rate-limit reads and cache +2. surface plan/rate-limit info in feature UI +3. remove normal Codex terminal-modal login/logout path +4. harmonize dashboard/settings/manage wording +5. capture final signoff evidence and residual known gaps + +## PR Slicing And Review Discipline + +Even with a strong plan, this feature can create bugs if we ship overly broad mixed PRs. + +### Recommended slicing rule + +Prefer one phase per PR when possible. + +If a phase becomes too wide, split it by seam, not by random files. + +Good split examples: + +- transport extraction +- read-only snapshot and IPC +- config migration and shell composition +- runtime launch policy +- login lifecycle +- rate limits and cleanup + +Bad split examples: + +- "main changes" vs "renderer changes" when both are needed to make one behavior coherent +- mixing migration, login, and runtime policy into one giant PR + +### Review checklist for each PR + +Every PR should answer: + +1. what is the new source of truth introduced or changed +2. what existing source of truth stops owning that behavior +3. what failure mode is newly covered +4. what migration risk exists +5. what tests prove the behavior + +### Green-state rule + +Each PR should leave the app in a coherent state: + +- no UI promise without runtime support +- no runtime support hidden behind stale UI wording +- no half-migrated config shape exposed to renderer code + +## Implementation Anti-Patterns To Avoid + +These are the failure modes most likely to reintroduce the problems this plan is trying to solve. + +### Anti-pattern 1 - generic shell service quietly regains Codex policy + +Bad shape: + +- `ProviderConnectionService` or `ClaudeMultimodelBridgeService` starts accumulating special-case + Codex account logic again + +Why it is bad: + +- recreates the coupling we are explicitly trying to remove + +### Anti-pattern 2 - renderer computes business truth from badges and booleans + +Bad shape: + +- UI derives Codex auth semantics from generic `authenticated`, `authMethod`, or `statusMessage` + flags alone + +Why it is bad: + +- produces inconsistent copy and launch assumptions across surfaces + +### Anti-pattern 3 - migration infers preference from availability + +Bad shape: + +- existing API key or managed account silently rewrites `preferredAuthMode` + +Why it is bad: + +- mutates user intent based on incidental environment state + +### Anti-pattern 4 - degraded becomes a synonym for connected + +Bad shape: + +- stale evidence is treated as permanently sufficient proof of current auth state + +Why it is bad: + +- creates false readiness and false billing semantics + +### Anti-pattern 5 - launch fallback becomes silent + +Bad shape: + +- failed ChatGPT launch silently retries with API key + +Why it is bad: + +- destroys trust in the runtime story and billing expectations + +## Definition Of Done + +This feature is done only when all of the following are true: + +1. A previously logged-in ChatGPT Codex account autodetects automatically. +2. The UI clearly distinguishes managed account and API key availability. +3. `auto` mode works and prefers ChatGPT when available. +4. Launch policy no longer falsely requires API key when managed account exists. +5. ChatGPT-mode execution sanitizes API-key env vars. +6. API-key mode still works. +7. Login, cancel, and logout work from the real UI. +8. Codex terminal-login path is no longer used in normal UI flows. +9. `recent-projects` remains green. +10. Existing non-Codex provider UX remains unchanged. + +## Explicit Rejections + +The following approaches are intentionally rejected. + +### Rejected - read `~/.codex/auth.json` + +Reason: + +- brittle +- storage backend may vary +- security-sensitive + +### Rejected - revive legacy Codex OAuth transport + +Reason: + +- wrong architecture +- incompatible with native-only cutover intent + +### Rejected - put all Codex account logic into `ProviderConnectionService` + +Reason: + +- violates SRP +- creates provider-specific policy blob +- fights the feature architecture standard + +### Rejected - hard-block launch whenever app-server is degraded + +Reason: + +- false negative risk +- execution seam and account control plane are different + +### Rejected - use app-server API-key login mode in the first wave + +Reason: + +- creates dual key stores +- unclear ownership + +## Final Recommendation + +Implement this as a full feature slice with: + +- extracted shared app-server transport primitives +- app-server-managed account truth +- app-owned API key truth +- feature-owned launch-readiness policy +- shell integration through composition, not more Codex branches in shell components + +The critical correctness points are: + +- unify auth storage context across app-server and exec +- sanitize API-key env vars in ChatGPT mode +- do not let app-server degradation become a false execution blocker + +If those three rules are followed, this plan should fit the orchestrator/UI architecture cleanly and restore the right Codex UX without reintroducing the old legacy path. diff --git a/docs/research/codex-app-server-account-signoff.md b/docs/research/codex-app-server-account-signoff.md new file mode 100644 index 00000000..5b5eeed7 --- /dev/null +++ b/docs/research/codex-app-server-account-signoff.md @@ -0,0 +1,303 @@ +# Codex App-Server Account Feature - Signoff Evidence + +Date: 2026-04-20 + +Worktree: +- `/Users/belief/dev/projects/claude/claude_team_codex_native_runtime_plan` + +Branch: +- `spike/codex-native-runtime-plan` + +Related plan: +- [codex-app-server-account-feature-plan.md](./codex-app-server-account-feature-plan.md) + +## Scope + +This signoff covers the app-server-backed Codex account feature work implemented in this repo: + +- shared Codex app-server transport extraction +- `codex-account` feature slice +- Codex `preferredAuthMode` config migration and validation +- renderer/runtime integration for managed ChatGPT account plus API key truth +- per-launch `forced_login_method` overrides for native Codex execution +- lazy rate-limits support +- login lifecycle wiring in the real UI path + +## Automated Verification + +### Targeted tests + +Command: + +```bash +pnpm vitest run \ + test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts \ + test/features/codex-account/main/CodexAccountEnvBuilder.test.ts \ + test/features/codex-account/main/createCodexAccountFeature.test.ts \ + test/features/codex-account/main/CodexLoginSessionManager.test.ts \ + test/features/codex-account/preload/createCodexAccountBridge.test.ts \ + test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts \ + test/main/services/runtime/providerAwareCliEnv.test.ts \ + test/main/services/runtime/ProviderConnectionService.test.ts \ + test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \ + test/main/services/schedule/ScheduledTaskExecutor.test.ts \ + test/main/services/team/TeamProvisioningServicePrepare.test.ts \ + test/main/services/team/TeamProvisioningServicePrompts.test.ts \ + test/main/services/infrastructure/ConfigManager.codexMigration.test.ts \ + test/renderer/api/httpClient.codexAccount.test.ts \ + test/renderer/api/httpClient.exactTaskLogs.test.ts \ + test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \ + test/renderer/components/runtime/providerConnectionUi.test.ts \ + test/renderer/components/cli/CliStatusVisibility.test.ts \ + test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts \ + test/main/ipc/configValidation.test.ts \ + test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts \ + test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts \ + test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts \ + test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts \ + test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts +``` + +Result: + +- `25` test files passed +- `204` tests passed + +### Typecheck + +Command: + +```bash +pnpm exec tsc -p tsconfig.json --noEmit +``` + +Result: + +- passed + +### Targeted lint + +Command: + +```bash +pnpm exec eslint \ + src/main/services/infrastructure/ConfigManager.ts \ + src/main/services/runtime/ProviderConnectionService.ts \ + src/main/services/runtime/providerAwareCliEnv.ts \ + src/main/services/schedule/ScheduledTaskExecutor.ts \ + src/features/codex-account/preload/createCodexAccountBridge.ts \ + src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts \ + src/renderer/api/httpClient.ts \ + src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx \ + test/main/services/infrastructure/ConfigManager.codexMigration.test.ts \ + test/features/codex-account/preload/createCodexAccountBridge.test.ts \ + test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts \ + test/main/services/runtime/ProviderConnectionService.test.ts \ + test/main/services/runtime/providerAwareCliEnv.test.ts \ + test/renderer/api/httpClient.codexAccount.test.ts \ + test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +``` + +Result: + +- passed + +## Live Read-Only Signoff + +### 1. Real `codex app-server account/read` + +Probe result: + +```json +{ + "account": { + "type": "chatgpt", + "email": "quantjumppro@gmail.com", + "planType": "pro" + }, + "requiresOpenaiAuth": true +} +``` + +What this proves: + +- installed Codex binary supports the stable app-server initialize flow used by the extracted transport +- ChatGPT account autodetect works on the real machine +- managed account truth is available without touching legacy transport + +### 2. Real `codex app-server account/rateLimits/read` + +Probe result summary: + +```json +{ + "rateLimits": { + "limitId": "codex", + "primary": { + "usedPercent": 77, + "windowDurationMins": 300 + }, + "secondary": { + "usedPercent": 45, + "windowDurationMins": 10080 + }, + "credits": { + "hasCredits": false, + "unlimited": false, + "balance": "0" + }, + "planType": "pro" + } +} +``` + +What this proves: + +- live rate-limit payload shape matches the feature assumptions +- plan/rate-limit surface can be driven from the real app-server contract + +### 3. Real feature-facade snapshot + +Command path: + +- `createCodexAccountFeature(...).refreshSnapshot({ includeRateLimits: true })` + +Observed summary: + +```json +{ + "preferredAuthMode": "chatgpt", + "effectiveAuthMode": "chatgpt", + "appServerState": "healthy", + "managedAccount": { + "type": "chatgpt", + "email": "quantjumppro@gmail.com", + "planType": "pro" + }, + "apiKey": { + "available": true, + "source": "environment", + "sourceLabel": "Detected from OPENAI_API_KEY" + }, + "launchAllowed": true, + "launchReadinessState": "ready_chatgpt", + "planType": "pro", + "rateLimitPrimaryUsedPercent": 77 +} +``` + +What this proves: + +- the real feature composition builds the expected snapshot +- app-server truth, API-key availability merge, readiness evaluation, and rate-limit shaping all work together + +### 4. Live preference-resolution checks through the feature facade + +Observed summary: + +```json +{ + "preferredAuthMode": "auto", + "effectiveAuthMode": "chatgpt", + "launchAllowed": true, + "launchReadinessState": "ready_both", + "managedAccountType": "chatgpt", + "apiKeyAvailable": true +} +``` + +```json +{ + "preferredAuthMode": "api_key", + "effectiveAuthMode": "api_key", + "launchAllowed": true, + "launchReadinessState": "ready_api_key", + "managedAccountType": "chatgpt", + "apiKeyAvailable": true +} +``` + +What this proves: + +- `auto` mode prefers ChatGPT when both auth sources exist +- `api_key` preference still resolves correctly even when a managed account is also present + +### 5. Live execution env sanitization check through `ProviderConnectionService` + +With a connected managed-account snapshot and `preferredAuthMode = "chatgpt"`, observed result: + +```json +{ + "OPENAI_API_KEY": null, + "CODEX_API_KEY": null +} +``` + +What this proves: + +- ChatGPT-mode execution sanitizes ambient API-key env vars when managed-account launch is selected + +### 6. Live provider-aware launch override check + +Command path: + +- `providerConnectionService.setCodexAccountFeature(createCodexAccountFeature(...))` +- `buildProviderAwareCliEnv({ binaryPath: "codex", providerId: "codex" })` + +Observed summary: + +```json +{ + "providerArgs": [ + "-c", + "forced_login_method=\"chatgpt\"" + ], + "connectionIssues": {} +} +``` + +What this proves: + +- the native Codex launch policy now emits a deterministic `forced_login_method` override +- the override is available through the shared provider-aware execution seam used by runtime launch paths + +## Definition Of Done Cross-Check + +1. Previously logged-in ChatGPT Codex account autodetects automatically. + Status: yes - proven by live `account/read`. + +2. UI clearly distinguishes managed account and API key availability. + Status: yes - implemented in the Codex panel and covered by renderer tests. + +3. `auto` mode works and prefers ChatGPT when available. + Status: yes - proven by live feature-facade check. + +4. Launch policy no longer falsely requires API key when managed account exists. + Status: yes - feature readiness plus live snapshot show `launchAllowed = true` in ChatGPT mode. + +5. ChatGPT-mode execution sanitizes API-key env vars. + Status: yes - covered by tests and live `ProviderConnectionService` probe. + +6. API-key mode still works. + Status: yes - covered by tests and live preference-resolution probe. + +7. Login, cancel, and logout work from the real UI. + Status: code path and tests are implemented; live destructive signoff was intentionally not executed in this document to avoid mutating the active local Codex account session. + +8. Codex terminal-login path is no longer used in normal UI flows. + Status: yes - normal Codex settings flow uses feature IPC actions, not terminal modal auth. + +9. `recent-projects` remains green. + Status: yes - targeted recent-projects safety suites passed. + +10. Existing non-Codex provider UX remains unchanged. + Status: targeted Anthropic/Gemini runtime and renderer tests passed. + +## Conclusion + +For this repo, the planned app-server account feature work is in signoffable shape: + +- architecture is aligned with the feature-slice plan +- renderer/runtime behavior is covered by targeted automated tests +- live app-server read and rate-limit contracts were verified on the installed Codex binary +- provider-aware native launch paths now receive deterministic Codex auth-mode overrides diff --git a/src/features/codex-account/contracts/api.ts b/src/features/codex-account/contracts/api.ts new file mode 100644 index 00000000..36b179c9 --- /dev/null +++ b/src/features/codex-account/contracts/api.ts @@ -0,0 +1,15 @@ +import type { CodexAccountSnapshotDto } from './dto'; + +export interface CodexAccountElectronApi { + getCodexAccountSnapshot: () => Promise; + refreshCodexAccountSnapshot: (options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + }) => Promise; + startCodexChatgptLogin: () => Promise; + cancelCodexChatgptLogin: () => Promise; + logoutCodexAccount: () => Promise; + onCodexAccountSnapshotChanged: ( + callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void + ) => () => void; +} diff --git a/src/features/codex-account/contracts/channels.ts b/src/features/codex-account/contracts/channels.ts new file mode 100644 index 00000000..0b896bde --- /dev/null +++ b/src/features/codex-account/contracts/channels.ts @@ -0,0 +1,6 @@ +export const CODEX_ACCOUNT_GET_SNAPSHOT = 'codexAccount:getSnapshot'; +export const CODEX_ACCOUNT_REFRESH_SNAPSHOT = 'codexAccount:refreshSnapshot'; +export const CODEX_ACCOUNT_START_CHATGPT_LOGIN = 'codexAccount:startChatgptLogin'; +export const CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN = 'codexAccount:cancelChatgptLogin'; +export const CODEX_ACCOUNT_LOGOUT = 'codexAccount:logout'; +export const CODEX_ACCOUNT_SNAPSHOT_CHANGED = 'codexAccount:snapshotChanged'; diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts new file mode 100644 index 00000000..27e45e94 --- /dev/null +++ b/src/features/codex-account/contracts/dto.ts @@ -0,0 +1,83 @@ +export type CodexAccountAuthMode = 'auto' | 'chatgpt' | 'api_key'; +export type CodexAccountEffectiveAuthMode = 'chatgpt' | 'api_key' | null; +export type CodexAccountPlanType = + | 'free' + | 'go' + | 'plus' + | 'pro' + | 'team' + | 'business' + | 'enterprise' + | 'edu' + | 'unknown'; +export type CodexAccountAppServerState = + | 'healthy' + | 'degraded' + | 'runtime-missing' + | 'incompatible'; +export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled'; +export type CodexLaunchReadinessState = + | 'ready_chatgpt' + | 'ready_api_key' + | 'ready_both' + | 'missing_auth' + | 'warning_degraded_but_launchable' + | 'runtime_missing' + | 'incompatible'; + +export interface CodexManagedAccountDto { + type: 'chatgpt' | 'api_key'; + email: string | null; + planType: CodexAccountPlanType | null; +} + +export interface CodexApiKeyAvailabilityDto { + available: boolean; + source: 'stored' | 'environment' | null; + sourceLabel: string | null; +} + +export interface CodexRateLimitWindowDto { + usedPercent: number; + windowDurationMins: number | null; + resetsAt: number | null; +} + +export interface CodexCreditsSnapshotDto { + hasCredits: boolean; + unlimited: boolean; + balance: string | null; +} + +export interface CodexRateLimitSnapshotDto { + limitId: string | null; + limitName: string | null; + primary: CodexRateLimitWindowDto | null; + secondary: CodexRateLimitWindowDto | null; + credits: CodexCreditsSnapshotDto | null; + planType: CodexAccountPlanType | null; +} + +export interface CodexLoginStateDto { + status: CodexAccountLoginStatus; + error: string | null; + startedAt: string | null; +} + +export interface CodexAccountSnapshotDto { + preferredAuthMode: CodexAccountAuthMode; + effectiveAuthMode: CodexAccountEffectiveAuthMode; + launchAllowed: boolean; + launchIssueMessage: string | null; + launchReadinessState: CodexLaunchReadinessState; + appServerState: CodexAccountAppServerState; + appServerStatusMessage: string | null; + managedAccount: CodexManagedAccountDto | null; + apiKey: CodexApiKeyAvailabilityDto; + requiresOpenaiAuth: boolean | null; + localAccountArtifactsPresent?: boolean; + localActiveChatgptAccountPresent?: boolean; + login: CodexLoginStateDto; + rateLimits: CodexRateLimitSnapshotDto | null; + updatedAt: string; +} diff --git a/src/features/codex-account/contracts/index.ts b/src/features/codex-account/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/codex-account/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; diff --git a/src/features/codex-account/core/domain/evaluateCodexLaunchReadiness.ts b/src/features/codex-account/core/domain/evaluateCodexLaunchReadiness.ts new file mode 100644 index 00000000..019a3593 --- /dev/null +++ b/src/features/codex-account/core/domain/evaluateCodexLaunchReadiness.ts @@ -0,0 +1,124 @@ +import type { + CodexAccountAppServerState, + CodexAccountAuthMode, + CodexAccountEffectiveAuthMode, + CodexApiKeyAvailabilityDto, + CodexLaunchReadinessState, + CodexManagedAccountDto, +} from '@features/codex-account/contracts'; + +export interface CodexLaunchReadinessResult { + state: CodexLaunchReadinessState; + effectiveAuthMode: CodexAccountEffectiveAuthMode; + launchAllowed: boolean; + issueMessage: string | null; +} + +export function evaluateCodexLaunchReadiness(input: { + preferredAuthMode: CodexAccountAuthMode; + managedAccount: CodexManagedAccountDto | null; + apiKey: CodexApiKeyAvailabilityDto; + appServerState: CodexAccountAppServerState; + appServerStatusMessage: string | null; + localActiveChatgptAccountPresent?: boolean; +}): CodexLaunchReadinessResult { + const managedAccountAvailable = input.managedAccount?.type === 'chatgpt'; + const apiKeyAvailable = input.apiKey.available; + + if (input.appServerState === 'runtime-missing') { + return { + state: 'runtime_missing', + effectiveAuthMode: null, + launchAllowed: false, + issueMessage: + input.appServerStatusMessage ?? 'Codex CLI is not available, so native Codex cannot start.', + }; + } + + if (input.preferredAuthMode === 'chatgpt') { + if (managedAccountAvailable) { + return { + state: + input.appServerState === 'degraded' ? 'warning_degraded_but_launchable' : 'ready_chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + issueMessage: + input.appServerState === 'degraded' + ? (input.appServerStatusMessage ?? + 'ChatGPT account detected, but account verification is currently degraded.') + : null, + }; + } + + return { + state: input.appServerState === 'incompatible' ? 'incompatible' : 'missing_auth', + effectiveAuthMode: null, + launchAllowed: false, + issueMessage: + input.appServerState === 'incompatible' + ? (input.appServerStatusMessage ?? + 'This Codex installation does not support ChatGPT account management.') + : input.localActiveChatgptAccountPresent + ? 'Reconnect ChatGPT to refresh the current Codex subscription session.' + : 'Connect a ChatGPT account to use your Codex subscription.', + }; + } + + if (input.preferredAuthMode === 'api_key') { + if (apiKeyAvailable) { + return { + state: 'ready_api_key', + effectiveAuthMode: 'api_key', + launchAllowed: true, + issueMessage: null, + }; + } + + return { + state: 'missing_auth', + effectiveAuthMode: null, + launchAllowed: false, + issueMessage: 'Add OPENAI_API_KEY or CODEX_API_KEY to use Codex API key mode.', + }; + } + + if (managedAccountAvailable) { + return { + state: + input.appServerState === 'degraded' + ? 'warning_degraded_but_launchable' + : apiKeyAvailable + ? 'ready_both' + : 'ready_chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + issueMessage: + input.appServerState === 'degraded' + ? (input.appServerStatusMessage ?? + 'ChatGPT account detected, but account verification is currently degraded.') + : null, + }; + } + + if (apiKeyAvailable) { + return { + state: 'ready_api_key', + effectiveAuthMode: 'api_key', + launchAllowed: true, + issueMessage: null, + }; + } + + return { + state: input.appServerState === 'incompatible' ? 'incompatible' : 'missing_auth', + effectiveAuthMode: null, + launchAllowed: false, + issueMessage: + input.appServerState === 'incompatible' + ? (input.appServerStatusMessage ?? + 'This Codex installation does not support ChatGPT account management.') + : input.localActiveChatgptAccountPresent + ? 'Reconnect ChatGPT to refresh the current Codex subscription session, or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.' + : 'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.', + }; +} diff --git a/src/features/codex-account/index.ts b/src/features/codex-account/index.ts new file mode 100644 index 00000000..371475b3 --- /dev/null +++ b/src/features/codex-account/index.ts @@ -0,0 +1,3 @@ +export type * from './contracts'; +export type { CodexLaunchReadinessResult } from './core/domain/evaluateCodexLaunchReadiness'; +export { evaluateCodexLaunchReadiness } from './core/domain/evaluateCodexLaunchReadiness'; diff --git a/src/features/codex-account/main/adapters/input/ipc/registerCodexAccountIpc.ts b/src/features/codex-account/main/adapters/input/ipc/registerCodexAccountIpc.ts new file mode 100644 index 00000000..1f2db06e --- /dev/null +++ b/src/features/codex-account/main/adapters/input/ipc/registerCodexAccountIpc.ts @@ -0,0 +1,33 @@ +import { + CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, + CODEX_ACCOUNT_GET_SNAPSHOT, + CODEX_ACCOUNT_LOGOUT, + CODEX_ACCOUNT_REFRESH_SNAPSHOT, + CODEX_ACCOUNT_START_CHATGPT_LOGIN, +} from '@features/codex-account/contracts'; + +import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature'; +import type { IpcMain } from 'electron'; + +export function registerCodexAccountIpc( + ipcMain: IpcMain, + feature: CodexAccountFeatureFacade +): void { + ipcMain.handle(CODEX_ACCOUNT_GET_SNAPSHOT, () => feature.getSnapshot()); + ipcMain.handle( + CODEX_ACCOUNT_REFRESH_SNAPSHOT, + (_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) => + feature.refreshSnapshot(options) + ); + ipcMain.handle(CODEX_ACCOUNT_START_CHATGPT_LOGIN, () => feature.startChatgptLogin()); + ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin()); + ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout()); +} + +export function removeCodexAccountIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(CODEX_ACCOUNT_GET_SNAPSHOT); + ipcMain.removeHandler(CODEX_ACCOUNT_REFRESH_SNAPSHOT); + ipcMain.removeHandler(CODEX_ACCOUNT_START_CHATGPT_LOGIN); + ipcMain.removeHandler(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN); + ipcMain.removeHandler(CODEX_ACCOUNT_LOGOUT); +} diff --git a/src/features/codex-account/main/adapters/output/presenters/CodexAccountSnapshotPresenter.ts b/src/features/codex-account/main/adapters/output/presenters/CodexAccountSnapshotPresenter.ts new file mode 100644 index 00000000..c9e9dfee --- /dev/null +++ b/src/features/codex-account/main/adapters/output/presenters/CodexAccountSnapshotPresenter.ts @@ -0,0 +1,19 @@ +import { + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + type CodexAccountSnapshotDto, +} from '@features/codex-account/contracts'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; + +import type { BrowserWindow } from 'electron'; + +export class CodexAccountSnapshotPresenter { + private mainWindow: BrowserWindow | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + publish(snapshot: CodexAccountSnapshotDto): void { + safeSendToRenderer(this.mainWindow, CODEX_ACCOUNT_SNAPSHOT_CHANGED, snapshot); + } +} diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts new file mode 100644 index 00000000..8e95422a --- /dev/null +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -0,0 +1,693 @@ +import { + type CodexAccountAuthMode, + type CodexAccountSnapshotDto, + type CodexApiKeyAvailabilityDto, + type CodexCreditsSnapshotDto, + type CodexLoginStateDto, + type CodexManagedAccountDto, + type CodexRateLimitSnapshotDto, + type CodexRateLimitWindowDto, +} from '@features/codex-account/contracts'; +import { + type CodexLaunchReadinessResult, + evaluateCodexLaunchReadiness, +} from '@features/codex-account/core/domain/evaluateCodexLaunchReadiness'; +import { ApiKeyService } from '@main/services/extensions'; +import { + type CodexAppServerGetAccountRateLimitsResponse, + type CodexAppServerGetAccountResponse, + type CodexAppServerRateLimitSnapshot, + CodexAppServerSessionFactory, + CodexBinaryResolver, + JsonRpcStdioClient, +} from '@main/services/infrastructure/codexAppServer'; +import { getCachedShellEnv } from '@main/utils/shellEnv'; + +import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter'; +import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient'; +import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder'; +import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager'; +import { detectCodexLocalAccountState } from '../infrastructure/detectCodexLocalAccountArtifacts'; + +import type { Logger } from '@shared/utils/logger'; +import type { BrowserWindow } from 'electron'; + +type LoggerPort = Pick; + +const SNAPSHOT_CACHE_TTL_MS = 5_000; +const RATE_LIMITS_CACHE_TTL_MS = 45_000; +const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000; + +interface CodexLastKnownAccount { + payload: CodexAppServerGetAccountResponse; + observedAt: number; +} + +interface CodexLastKnownRateLimits { + payload: CodexAppServerGetAccountRateLimitsResponse; + observedAt: number; +} + +interface CodexSnapshotRefreshOptions { + includeRateLimits: boolean; + forceRefreshToken: boolean; +} + +function hasChatgptManagedAccount( + payload: CodexAppServerGetAccountResponse | null | undefined +): boolean { + return payload?.account?.type === 'chatgpt'; +} + +function deepClone(value: T): T { + return structuredClone(value); +} + +function asCodexManagedAccount( + account: CodexAppServerGetAccountResponse['account'] +): CodexManagedAccountDto | null { + if (!account) { + return null; + } + + if (account.type === 'apiKey') { + return { + type: 'api_key', + email: null, + planType: null, + }; + } + + return { + type: 'chatgpt', + email: account.email, + planType: account.planType, + }; +} + +function asRateLimitWindow( + window: CodexAppServerRateLimitSnapshot['primary'] +): CodexRateLimitWindowDto | null { + if (!window) { + return null; + } + + return { + usedPercent: window.usedPercent, + windowDurationMins: window.windowDurationMins, + resetsAt: window.resetsAt, + }; +} + +function asCreditsSnapshot( + credits: CodexAppServerRateLimitSnapshot['credits'] +): CodexCreditsSnapshotDto | null { + if (!credits) { + return null; + } + + return { + hasCredits: credits.hasCredits, + unlimited: credits.unlimited, + balance: credits.balance, + }; +} + +function asRateLimits( + snapshot: CodexAppServerRateLimitSnapshot | null +): CodexRateLimitSnapshotDto | null { + if (!snapshot) { + return null; + } + + return { + limitId: snapshot.limitId, + limitName: snapshot.limitName, + primary: asRateLimitWindow(snapshot.primary), + secondary: asRateLimitWindow(snapshot.secondary), + credits: asCreditsSnapshot(snapshot.credits), + planType: snapshot.planType, + }; +} + +function getPreferredAuthMode(configManager: { + getConfig: () => { + providerConnections: { + codex: { + preferredAuthMode?: CodexAccountAuthMode; + }; + }; + }; +}): CodexAccountAuthMode { + return configManager.getConfig().providerConnections.codex.preferredAuthMode ?? 'auto'; +} + +function classifyAppServerFailure(error: unknown): { + appServerState: CodexAccountSnapshotDto['appServerState']; + appServerStatusMessage: string; +} { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if ( + lower.includes('unknown method') || + lower.includes('method not found') || + lower.includes('unknown command') || + lower.includes('no such command') + ) { + return { + appServerState: 'incompatible', + appServerStatusMessage: + 'The installed Codex binary does not support app-server account management yet.', + }; + } + + return { + appServerState: 'degraded', + appServerStatusMessage: message, + }; +} + +function normalizeRefreshOptions(options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; +}): CodexSnapshotRefreshOptions { + return { + includeRateLimits: options?.includeRateLimits === true, + forceRefreshToken: options?.forceRefreshToken === true, + }; +} + +function mergeRefreshOptions( + current: CodexSnapshotRefreshOptions | null, + next: CodexSnapshotRefreshOptions +): CodexSnapshotRefreshOptions { + if (!current) { + return next; + } + + return { + includeRateLimits: current.includeRateLimits || next.includeRateLimits, + forceRefreshToken: current.forceRefreshToken || next.forceRefreshToken, + }; +} + +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve: (() => void) | null = null; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + + if (!resolve) { + throw new Error('Failed to create deferred promise.'); + } + + return { + promise, + resolve, + }; +} + +export interface CodexAccountFeatureFacade { + getSnapshot(): Promise; + refreshSnapshot(options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + }): Promise; + startChatgptLogin(): Promise; + cancelLogin(): Promise; + logout(): Promise; + subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void; + setMainWindow(window: BrowserWindow | null): void; + getLaunchReadiness(): Promise; + dispose(): Promise; +} + +class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { + private readonly listeners = new Set<(snapshot: CodexAccountSnapshotDto) => void>(); + private readonly presenter = new CodexAccountSnapshotPresenter(); + private readonly envBuilder = new CodexAccountEnvBuilder(); + private readonly appServerClient: CodexAccountAppServerClient; + private readonly loginSessionManager: CodexLoginSessionManager; + + private snapshotCache: CodexAccountSnapshotDto | null = null; + private snapshotObservedAt = 0; + private refreshPromise: Promise | null = null; + private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null; + private lastKnownAccount: CodexLastKnownAccount | null = null; + private lastKnownRateLimits: CodexLastKnownRateLimits | null = null; + private mutationQueue: Promise = Promise.resolve(); + private mutationQueueRelease: (() => void) | null = null; + private activeMutationCount = 0; + + constructor( + private readonly logger: LoggerPort, + private readonly configManager: { + getConfig: () => { + providerConnections: { + codex: { + preferredAuthMode?: CodexAccountAuthMode; + }; + }; + }; + }, + private readonly apiKeyService = new ApiKeyService() + ) { + const sessionFactory = new CodexAppServerSessionFactory(new JsonRpcStdioClient(logger)); + this.appServerClient = new CodexAccountAppServerClient(sessionFactory); + this.loginSessionManager = new CodexLoginSessionManager(sessionFactory, logger); + + this.loginSessionManager.subscribe(() => { + void this.emitCurrentSnapshot(); + }); + this.loginSessionManager.onSettled(() => { + void this.refreshSnapshot({ + includeRateLimits: true, + forceRefreshToken: true, + }); + }); + } + + async getSnapshot(): Promise { + if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) { + return deepClone(this.snapshotCache); + } + + return this.refreshSnapshot(); + } + + async refreshSnapshot(options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + }): Promise { + this.pendingRefreshOptions = mergeRefreshOptions( + this.pendingRefreshOptions, + normalizeRefreshOptions(options) + ); + + if (!this.refreshPromise) { + this.refreshPromise = this.drainRefreshQueue().finally(() => { + this.refreshPromise = null; + }); + } + + return this.refreshPromise; + } + + async startChatgptLogin(): Promise { + let binaryMissing = false; + await this.runSerializedMutation(async () => { + const binaryPath = await CodexBinaryResolver.resolve(); + if (!binaryPath) { + binaryMissing = true; + return; + } + + const env = this.envBuilder.buildControlPlaneEnv({ binaryPath }); + await this.loginSessionManager.start({ binaryPath, env }); + }); + + if (binaryMissing) { + return this.loadSnapshot(); + } + + return this.emitCurrentSnapshot(); + } + + async cancelLogin(): Promise { + await this.runSerializedMutation(async () => { + await this.loginSessionManager.cancel(); + }); + + return this.emitCurrentSnapshot(); + } + + async logout(): Promise { + await this.runSerializedMutation(async () => { + await this.loginSessionManager.cancel().catch(() => undefined); + + const binaryPath = await CodexBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error('Codex CLI is not available, so logout cannot be completed.'); + } + + const env = this.envBuilder.buildControlPlaneEnv({ binaryPath }); + await this.appServerClient.logout({ binaryPath, env }); + this.lastKnownAccount = null; + this.lastKnownRateLimits = null; + await this.publishLoggedOutSnapshot(); + }); + + return this.refreshSnapshot({ includeRateLimits: true, forceRefreshToken: true }); + } + + subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void { + this.listeners.add(listener); + return (): void => { + this.listeners.delete(listener); + }; + } + + setMainWindow(window: BrowserWindow | null): void { + this.presenter.setMainWindow(window); + } + + async getLaunchReadiness(): Promise { + const snapshot = await this.getSnapshot(); + return evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); + } + + async dispose(): Promise { + await this.loginSessionManager.dispose(); + this.listeners.clear(); + this.snapshotCache = null; + this.refreshPromise = null; + this.pendingRefreshOptions = null; + this.lastKnownAccount = null; + this.lastKnownRateLimits = null; + this.activeMutationCount = 0; + if (this.mutationQueueRelease) { + this.mutationQueueRelease(); + this.mutationQueueRelease = null; + } + this.mutationQueue = Promise.resolve(); + } + + private async drainRefreshQueue(): Promise { + let lastSnapshot: CodexAccountSnapshotDto | null = null; + + while (this.pendingRefreshOptions) { + const nextOptions = this.pendingRefreshOptions; + this.pendingRefreshOptions = null; + await this.mutationQueue.catch(() => undefined); + + lastSnapshot = await this.loadSnapshot(nextOptions); + } + + if (!lastSnapshot) { + if (this.snapshotCache) { + return deepClone(this.snapshotCache); + } + return this.loadSnapshot(); + } + + return lastSnapshot; + } + + private async loadSnapshot(options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + }): Promise { + const preferredAuthMode = getPreferredAuthMode(this.configManager); + const apiKey = await this.loadApiKeyAvailability(); + const localAccountState = await detectCodexLocalAccountState(); + const localAccountArtifactsPresent = localAccountState.hasArtifacts; + const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount; + const binaryPath = await CodexBinaryResolver.resolve(); + const login = this.loginSessionManager.getState(); + const now = Date.now(); + + if (!binaryPath) { + const snapshot = this.setSnapshot({ + preferredAuthMode, + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.', + launchReadinessState: 'runtime_missing', + appServerState: 'runtime-missing', + appServerStatusMessage: + 'Codex CLI not found. Install Codex to use native account management.', + managedAccount: null, + apiKey, + requiresOpenaiAuth: null, + localAccountArtifactsPresent, + localActiveChatgptAccountPresent, + login, + rateLimits: null, + updatedAt: new Date(now).toISOString(), + }); + return snapshot; + } + + const env = this.envBuilder.buildControlPlaneEnv({ binaryPath }); + let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy'; + let appServerStatusMessage: string | null = null; + let accountPayload = this.lastKnownAccount?.payload ?? null; + let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null; + + try { + const accountResult = await this.appServerClient.readAccount({ + binaryPath, + env, + refreshToken: options?.forceRefreshToken ?? false, + }); + const canReuseLastKnownManagedAccount = + options?.forceRefreshToken !== true && + localActiveChatgptAccountPresent && + accountResult.account.account == null && + accountResult.account.requiresOpenaiAuth === true && + this.lastKnownAccount !== null && + now - this.lastKnownAccount.observedAt <= LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS && + hasChatgptManagedAccount(this.lastKnownAccount.payload); + + if (canReuseLastKnownManagedAccount) { + accountPayload = this.lastKnownAccount!.payload; + requiresOpenaiAuth = this.lastKnownAccount!.payload.requiresOpenaiAuth; + } else { + accountPayload = accountResult.account; + requiresOpenaiAuth = accountResult.account.requiresOpenaiAuth; + this.lastKnownAccount = { + payload: accountResult.account, + observedAt: now, + }; + } + } catch (error) { + const failure = classifyAppServerFailure(error); + appServerState = failure.appServerState; + appServerStatusMessage = failure.appServerStatusMessage; + + if ( + !this.lastKnownAccount || + now - this.lastKnownAccount.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS + ) { + accountPayload = null; + requiresOpenaiAuth = null; + } else { + accountPayload = this.lastKnownAccount.payload; + requiresOpenaiAuth = this.lastKnownAccount.payload.requiresOpenaiAuth; + } + } + + let rateLimits: CodexRateLimitSnapshotDto | null = null; + const shouldLoadRateLimits = + options?.includeRateLimits === true || + (this.lastKnownRateLimits !== null && + now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS); + + if (shouldLoadRateLimits) { + try { + if ( + this.lastKnownRateLimits && + now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS + ) { + rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits); + } else { + const rateLimitsPayload = await this.appServerClient.readRateLimits({ + binaryPath, + env, + }); + this.lastKnownRateLimits = { + payload: rateLimitsPayload, + observedAt: now, + }; + rateLimits = asRateLimits(rateLimitsPayload.rateLimits); + } + } catch (error) { + this.logger.warn('codex account rate limits refresh failed', { + error: error instanceof Error ? error.message : String(error), + }); + rateLimits = this.lastKnownRateLimits + ? asRateLimits(this.lastKnownRateLimits.payload.rateLimits) + : null; + } + } + + const managedAccount = asCodexManagedAccount(accountPayload?.account ?? null); + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode, + managedAccount, + apiKey, + appServerState, + appServerStatusMessage, + localActiveChatgptAccountPresent, + }); + + const snapshot = this.setSnapshot({ + preferredAuthMode, + effectiveAuthMode: readiness.effectiveAuthMode, + launchAllowed: readiness.launchAllowed, + launchIssueMessage: readiness.issueMessage, + launchReadinessState: readiness.state, + appServerState, + appServerStatusMessage, + managedAccount, + apiKey, + requiresOpenaiAuth, + localAccountArtifactsPresent, + localActiveChatgptAccountPresent, + login, + rateLimits, + updatedAt: new Date(now).toISOString(), + }); + + return snapshot; + } + + private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto { + this.snapshotCache = deepClone(nextSnapshot); + this.snapshotObservedAt = Date.now(); + const snapshot = deepClone(nextSnapshot); + this.presenter.publish(snapshot); + for (const listener of this.listeners) { + listener(snapshot); + } + return snapshot; + } + + private async emitCurrentSnapshot(): Promise { + if (!this.snapshotCache) { + return this.refreshSnapshot(); + } + + return this.setSnapshot({ + ...this.snapshotCache, + login: this.loginSessionManager.getState(), + updatedAt: new Date().toISOString(), + }); + } + + private async publishLoggedOutSnapshot(): Promise { + const preferredAuthMode = getPreferredAuthMode(this.configManager); + const apiKey = this.snapshotCache?.apiKey ?? (await this.loadApiKeyAvailability()); + const localAccountState = await detectCodexLocalAccountState(); + const localAccountArtifactsPresent = localAccountState.hasArtifacts; + const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount; + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode, + managedAccount: null, + apiKey, + appServerState: 'healthy', + appServerStatusMessage: null, + localActiveChatgptAccountPresent, + }); + const login = this.asIdleLoginState(this.loginSessionManager.getState()); + + return this.setSnapshot({ + preferredAuthMode, + effectiveAuthMode: readiness.effectiveAuthMode, + launchAllowed: readiness.launchAllowed, + launchIssueMessage: readiness.issueMessage, + launchReadinessState: readiness.state, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey, + requiresOpenaiAuth: false, + localAccountArtifactsPresent, + localActiveChatgptAccountPresent, + login, + rateLimits: null, + updatedAt: new Date().toISOString(), + }); + } + + private asIdleLoginState(loginState: CodexLoginStateDto): CodexLoginStateDto { + return { + status: 'idle', + error: loginState.status === 'failed' ? loginState.error : null, + startedAt: null, + }; + } + + private async runSerializedMutation(operation: () => Promise): Promise { + const previousMutation = this.mutationQueue.catch(() => undefined); + const deferred = createDeferred(); + this.mutationQueue = deferred.promise; + this.mutationQueueRelease = deferred.resolve; + + await previousMutation; + await this.refreshPromise?.catch(() => undefined); + + this.activeMutationCount += 1; + try { + return await operation(); + } finally { + this.activeMutationCount = Math.max(0, this.activeMutationCount - 1); + deferred.resolve(); + if (this.mutationQueueRelease === deferred.resolve) { + this.mutationQueueRelease = null; + } + } + } + + private async loadApiKeyAvailability(): Promise { + const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); + if (storedKey?.value.trim()) { + return { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }; + } + + const shellEnv = getCachedShellEnv() ?? {}; + const envSources = [shellEnv, process.env]; + for (const envSource of envSources) { + const codexKey = envSource.CODEX_API_KEY; + if (typeof codexKey === 'string' && codexKey.trim()) { + return { + available: true, + source: 'environment', + sourceLabel: 'Detected from CODEX_API_KEY', + }; + } + + const openAiKey = envSource.OPENAI_API_KEY; + if (typeof openAiKey === 'string' && openAiKey.trim()) { + return { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }; + } + } + + return { + available: false, + source: null, + sourceLabel: null, + }; + } +} + +export function createCodexAccountFeature(deps: { + logger: LoggerPort; + configManager: { + getConfig: () => { + providerConnections: { + codex: { + preferredAuthMode?: CodexAccountAuthMode; + }; + }; + }; + }; +}): CodexAccountFeatureFacade { + return new CodexAccountFeatureFacadeImpl(deps.logger, deps.configManager); +} diff --git a/src/features/codex-account/main/index.ts b/src/features/codex-account/main/index.ts new file mode 100644 index 00000000..6189c65f --- /dev/null +++ b/src/features/codex-account/main/index.ts @@ -0,0 +1,6 @@ +export { + registerCodexAccountIpc, + removeCodexAccountIpc, +} from './adapters/input/ipc/registerCodexAccountIpc'; +export type { CodexAccountFeatureFacade } from './composition/createCodexAccountFeature'; +export { createCodexAccountFeature } from './composition/createCodexAccountFeature'; diff --git a/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts b/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts new file mode 100644 index 00000000..14ba5e3f --- /dev/null +++ b/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts @@ -0,0 +1,100 @@ +import { + type CodexAppServerGetAccountParams, + type CodexAppServerGetAccountRateLimitsResponse, + type CodexAppServerGetAccountResponse, + type CodexAppServerLogoutAccountResponse, +} from '@main/services/infrastructure/codexAppServer'; + +import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer'; + +const ACCOUNT_READ_TIMEOUT_MS = 3_500; +const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500; +const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500; +const INITIALIZE_TIMEOUT_MS = 6_000; +const TOTAL_TIMEOUT_MS = 9_000; + +export class CodexAccountAppServerClient { + constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {} + + async readAccount(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + refreshToken?: boolean; + }): Promise<{ + account: CodexAppServerGetAccountResponse; + initialize: { codexHome: string; platformFamily: string; platformOs: string }; + }> { + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server account/read', + }, + async (session) => { + const account = await session.request( + 'account/read', + { + refreshToken: options.refreshToken ?? false, + } satisfies CodexAppServerGetAccountParams, + ACCOUNT_READ_TIMEOUT_MS + ); + + return { + account, + initialize: { + codexHome: session.initializeResponse.codexHome, + platformFamily: session.initializeResponse.platformFamily, + platformOs: session.initializeResponse.platformOs, + }, + }; + } + ); + } + + async readRateLimits(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + }): Promise { + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: ACCOUNT_RATE_LIMITS_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server account/rateLimits/read', + }, + async (session) => + session.request( + 'account/rateLimits/read', + undefined, + ACCOUNT_RATE_LIMITS_TIMEOUT_MS + ) + ); + } + + async logout(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + }): Promise { + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: ACCOUNT_LOGOUT_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server account/logout', + }, + async (session) => + session.request( + 'account/logout', + undefined, + ACCOUNT_LOGOUT_TIMEOUT_MS + ) + ); + } +} diff --git a/src/features/codex-account/main/infrastructure/CodexAccountEnvBuilder.ts b/src/features/codex-account/main/infrastructure/CodexAccountEnvBuilder.ts new file mode 100644 index 00000000..72fe4754 --- /dev/null +++ b/src/features/codex-account/main/infrastructure/CodexAccountEnvBuilder.ts @@ -0,0 +1,66 @@ +import { buildRuntimeBaseEnv } from '@main/services/runtime/buildRuntimeBaseEnv'; +import { getCachedShellEnv } from '@main/utils/shellEnv'; + +import type { CodexAccountEffectiveAuthMode } from '@features/codex-account/contracts'; + +const CODEX_API_KEY_ENV_VAR = 'CODEX_API_KEY'; +const OPENAI_API_KEY_ENV_VAR = 'OPENAI_API_KEY'; +const PROVIDER_ROUTING_ENV_KEYS = [ + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_GEMINI_BACKEND', + 'CLAUDE_CODE_CODEX_BACKEND', +] as const; + +export class CodexAccountEnvBuilder { + buildControlPlaneEnv(options: { + binaryPath?: string | null; + shellEnv?: NodeJS.ProcessEnv | null; + env?: NodeJS.ProcessEnv; + }): NodeJS.ProcessEnv { + const { env } = buildRuntimeBaseEnv({ + binaryPath: options.binaryPath, + shellEnv: options.shellEnv ?? getCachedShellEnv() ?? {}, + env: options.env, + }); + + for (const key of PROVIDER_ROUTING_ENV_KEYS) { + delete env[key]; + } + + delete env.OPENAI_API_KEY; + delete env.CODEX_API_KEY; + return env; + } + + applyExecutionAuthPolicy( + env: NodeJS.ProcessEnv, + options: { + effectiveAuthMode: CodexAccountEffectiveAuthMode; + apiKeyValue?: string | null; + } + ): NodeJS.ProcessEnv { + if (options.effectiveAuthMode === 'chatgpt') { + delete env[OPENAI_API_KEY_ENV_VAR]; + delete env[CODEX_API_KEY_ENV_VAR]; + return env; + } + + if (options.effectiveAuthMode === 'api_key' && options.apiKeyValue?.trim()) { + env[OPENAI_API_KEY_ENV_VAR] = options.apiKeyValue.trim(); + env[CODEX_API_KEY_ENV_VAR] = options.apiKeyValue.trim(); + return env; + } + + delete env[CODEX_API_KEY_ENV_VAR]; + if (typeof env[OPENAI_API_KEY_ENV_VAR] !== 'string' || !env[OPENAI_API_KEY_ENV_VAR]?.trim()) { + delete env[OPENAI_API_KEY_ENV_VAR]; + } + return env; + } +} diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts new file mode 100644 index 00000000..be71bef5 --- /dev/null +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -0,0 +1,300 @@ +import { + type CodexAppServerAccountLoginCompletedNotification, + type CodexAppServerCancelLoginAccountResponse, + type CodexAppServerLoginAccountResponse, + type CodexAppServerSession, +} from '@main/services/infrastructure/codexAppServer'; +import { shell } from 'electron'; + +import type { CodexLoginStateDto } from '@features/codex-account/contracts'; +import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer'; + +const LOGIN_REQUEST_TIMEOUT_MS = 5_000; +const INITIALIZE_TIMEOUT_MS = 6_000; +const LOGIN_PENDING_TIMEOUT_MS = 10 * 60 * 1_000; + +type CodexLoginStateListener = (state: CodexLoginStateDto) => void; +type CodexLoginSettledListener = () => void; +interface CodexLoginLogger { + warn: (message: string, meta?: Record) => void; +} + +export class CodexLoginSessionManager { + private readonly listeners = new Set(); + private readonly settledListeners = new Set(); + private state: CodexLoginStateDto = { + status: 'idle', + error: null, + startedAt: null, + }; + private pendingStartToken: symbol | null = null; + private activeSession: { + session: CodexAppServerSession; + loginId: string; + disposeNotificationListener: () => void; + timeoutId: ReturnType; + } | null = null; + + constructor( + private readonly sessionFactory: CodexAppServerSessionFactory, + private readonly logger: CodexLoginLogger + ) {} + + subscribe(listener: CodexLoginStateListener): () => void { + this.listeners.add(listener); + return (): void => { + this.listeners.delete(listener); + }; + } + + onSettled(listener: CodexLoginSettledListener): () => void { + this.settledListeners.add(listener); + return (): void => { + this.settledListeners.delete(listener); + }; + } + + getState(): CodexLoginStateDto { + return structuredClone(this.state); + } + + async start(options: { binaryPath: string; env: NodeJS.ProcessEnv }): Promise { + if (this.activeSession || this.pendingStartToken) { + return; + } + + const startToken = Symbol('codex-login-start'); + this.pendingStartToken = startToken; + let session: CodexAppServerSession | null = null; + + this.setState({ + status: 'starting', + error: null, + startedAt: new Date().toISOString(), + }); + + try { + session = await this.sessionFactory.openSession({ + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: LOGIN_REQUEST_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + }); + + if (this.pendingStartToken !== startToken) { + await session.close().catch(() => undefined); + return; + } + + const response = await session.request( + 'account/login/start', + { type: 'chatgpt' }, + LOGIN_REQUEST_TIMEOUT_MS + ); + + if (this.pendingStartToken !== startToken) { + await session.close().catch(() => undefined); + return; + } + + if (response.type !== 'chatgpt') { + throw new Error('Codex app-server returned an unexpected login response type'); + } + + const authUrl = new URL(response.authUrl); + if (authUrl.protocol !== 'https:') { + throw new Error('Codex app-server returned a non-https auth URL'); + } + + const disposeNotificationListener = session.onNotification((method, params) => { + if (method !== 'account/login/completed') { + return; + } + + const notification = params as CodexAppServerAccountLoginCompletedNotification; + if (notification.loginId && notification.loginId !== response.loginId) { + return; + } + + void this.handleCompletion(notification); + }); + + const timeoutId = setTimeout(() => { + void this.failActiveLogin('Timed out while waiting for ChatGPT account login to finish.'); + }, LOGIN_PENDING_TIMEOUT_MS); + + this.activeSession = { + session, + loginId: response.loginId, + disposeNotificationListener, + timeoutId, + }; + this.pendingStartToken = null; + + this.setState({ + status: 'pending', + error: null, + startedAt: this.state.startedAt, + }); + + await shell.openExternal(authUrl.toString()); + } catch (error) { + const wasAbandonedDuringStart = + this.pendingStartToken !== startToken && + !this.activeSession && + (this.state.status === 'cancelled' || this.state.status === 'idle'); + + if (this.pendingStartToken === startToken) { + this.pendingStartToken = null; + } + await session?.close().catch(() => undefined); + if (session && this.activeSession?.session === session) { + this.activeSession = null; + } + if (wasAbandonedDuringStart) { + return; + } + this.setState({ + status: 'failed', + error: error instanceof Error ? error.message : String(error), + startedAt: this.state.startedAt, + }); + throw error; + } + } + + async cancel(): Promise { + if (this.pendingStartToken && !this.activeSession) { + this.pendingStartToken = null; + this.setState({ + status: 'cancelled', + error: null, + startedAt: null, + }); + this.emitSettled(); + return; + } + + if (!this.activeSession) { + this.setState({ + status: 'cancelled', + error: null, + startedAt: null, + }); + return; + } + + const activeSession = this.activeSession; + this.activeSession = null; + clearTimeout(activeSession.timeoutId); + activeSession.disposeNotificationListener(); + + try { + await activeSession.session.request( + 'account/login/cancel', + { loginId: activeSession.loginId }, + LOGIN_REQUEST_TIMEOUT_MS + ); + } catch (error) { + this.logger.warn('codex login cancel failed', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + await activeSession.session.close().catch(() => undefined); + } + + this.setState({ + status: 'cancelled', + error: null, + startedAt: null, + }); + this.emitSettled(); + } + + async dispose(): Promise { + if (this.pendingStartToken) { + this.pendingStartToken = null; + } + + if (!this.activeSession) { + this.setState({ + status: 'idle', + error: null, + startedAt: null, + }); + return; + } + + const activeSession = this.activeSession; + this.activeSession = null; + clearTimeout(activeSession.timeoutId); + activeSession.disposeNotificationListener(); + await activeSession.session.close().catch(() => undefined); + this.setState({ + status: 'idle', + error: null, + startedAt: null, + }); + } + + private async handleCompletion( + notification: CodexAppServerAccountLoginCompletedNotification + ): Promise { + if (!this.activeSession) { + return; + } + + const activeSession = this.activeSession; + this.activeSession = null; + clearTimeout(activeSession.timeoutId); + activeSession.disposeNotificationListener(); + await activeSession.session.close().catch(() => undefined); + + if (notification.success) { + this.setState({ + status: 'idle', + error: null, + startedAt: null, + }); + } else { + this.setState({ + status: 'failed', + error: notification.error ?? 'ChatGPT login failed.', + startedAt: this.state.startedAt, + }); + } + + this.emitSettled(); + } + + private async failActiveLogin(errorMessage: string): Promise { + if (!this.activeSession) { + return; + } + + const activeSession = this.activeSession; + this.activeSession = null; + clearTimeout(activeSession.timeoutId); + activeSession.disposeNotificationListener(); + await activeSession.session.close().catch(() => undefined); + this.setState({ + status: 'failed', + error: errorMessage, + startedAt: this.state.startedAt, + }); + this.emitSettled(); + } + + private emitSettled(): void { + for (const listener of this.settledListeners) { + listener(); + } + } + + private setState(nextState: CodexLoginStateDto): void { + this.state = structuredClone(nextState); + for (const listener of this.listeners) { + listener(this.getState()); + } + } +} diff --git a/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts b/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts new file mode 100644 index 00000000..54d1854f --- /dev/null +++ b/src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.ts @@ -0,0 +1,109 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts'); + +interface CodexAccountsRegistry { + active_account_key?: string | null; + activeAccountKey?: string | null; +} + +interface CodexAuthFile { + auth_mode?: string | null; + authMode?: string | null; +} + +export interface CodexLocalAccountState { + hasArtifacts: boolean; + hasActiveChatgptAccount: boolean; +} + +function encodeAccountKeyForAuthFilename(accountKey: string): string { + return Buffer.from(accountKey, 'utf8') + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, ''); +} + +async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function detectCodexLocalAccountState( + accountsDir = CODEX_ACCOUNTS_DIR +): Promise { + try { + const entries = await fs.readdir(accountsDir, { withFileTypes: true }); + const hasArtifacts = entries.some( + (entry) => + entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json')) + ); + + if (!hasArtifacts) { + return { + hasArtifacts: false, + hasActiveChatgptAccount: false, + }; + } + + const registry = await readJsonFile( + path.join(accountsDir, 'registry.json') + ); + const activeAccountKey = + registry?.active_account_key?.trim() || registry?.activeAccountKey?.trim() || null; + + if (!activeAccountKey) { + return { + hasArtifacts: true, + hasActiveChatgptAccount: false, + }; + } + + const authFilePath = path.join( + accountsDir, + `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json` + ); + if (!(await fileExists(authFilePath))) { + return { + hasArtifacts: true, + hasActiveChatgptAccount: false, + }; + } + + const authFile = await readJsonFile(authFilePath); + const authMode = authFile?.auth_mode ?? authFile?.authMode ?? null; + + return { + hasArtifacts: true, + hasActiveChatgptAccount: authMode === 'chatgpt', + }; + } catch { + return { + hasArtifacts: false, + hasActiveChatgptAccount: false, + }; + } +} + +export async function detectCodexLocalAccountArtifacts( + accountsDir = CODEX_ACCOUNTS_DIR +): Promise { + const state = await detectCodexLocalAccountState(accountsDir); + return state.hasArtifacts; +} diff --git a/src/features/codex-account/preload/createCodexAccountBridge.ts b/src/features/codex-account/preload/createCodexAccountBridge.ts new file mode 100644 index 00000000..8ed97798 --- /dev/null +++ b/src/features/codex-account/preload/createCodexAccountBridge.ts @@ -0,0 +1,40 @@ +import { + CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, + CODEX_ACCOUNT_GET_SNAPSHOT, + CODEX_ACCOUNT_LOGOUT, + CODEX_ACCOUNT_REFRESH_SNAPSHOT, + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + CODEX_ACCOUNT_START_CHATGPT_LOGIN, + type CodexAccountElectronApi, +} from '@features/codex-account/contracts'; + +import type { IpcRenderer } from 'electron'; + +interface CreateCodexAccountBridgeDeps { + ipcRenderer: IpcRenderer; +} + +export function createCodexAccountBridge({ + ipcRenderer, +}: CreateCodexAccountBridgeDeps): CodexAccountElectronApi { + return { + getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT), + refreshCodexAccountSnapshot: (options) => + ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options), + startCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN), + cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN), + logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT), + onCodexAccountSnapshotChanged: (callback) => { + ipcRenderer.on( + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }; +} diff --git a/src/features/codex-account/preload/index.ts b/src/features/codex-account/preload/index.ts new file mode 100644 index 00000000..d62efa18 --- /dev/null +++ b/src/features/codex-account/preload/index.ts @@ -0,0 +1 @@ +export { createCodexAccountBridge } from './createCodexAccountBridge'; diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts new file mode 100644 index 00000000..7a32a42f --- /dev/null +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + +const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000; +const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000; +const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000; +const CODEX_HIDDEN_REFRESH_MS = 60_000; + +function isDocumentVisible(): boolean { + if (typeof document === 'undefined') { + return true; + } + + return document.visibilityState !== 'hidden'; +} + +function getRefreshIntervalMs(options: { + loginStatus: CodexAccountSnapshotDto['login']['status'] | undefined; + includeRateLimits: boolean; + visible: boolean; +}): number { + if (options.loginStatus === 'starting' || options.loginStatus === 'pending') { + return CODEX_PENDING_LOGIN_REFRESH_MS; + } + + if (!options.visible) { + return CODEX_HIDDEN_REFRESH_MS; + } + + return options.includeRateLimits + ? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS + : CODEX_VISIBLE_STANDARD_REFRESH_MS; +} + +export function useCodexAccountSnapshot(options: { + enabled: boolean; + includeRateLimits?: boolean; +}): { + snapshot: CodexAccountSnapshotDto | null; + loading: boolean; + error: string | null; + refresh: (options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + silent?: boolean; + }) => Promise; + startChatgptLogin: () => Promise; + cancelChatgptLogin: () => Promise; + logout: () => Promise; +} { + const electronMode = isElectronMode(); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [visible, setVisible] = useState(() => isDocumentVisible()); + const lastUpdatedAtRef = useRef(null); + + const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => { + lastUpdatedAtRef.current = Date.now(); + setSnapshot(nextSnapshot); + setError(null); + }, []); + + const refresh = useCallback( + async (refreshOptions?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + silent?: boolean; + }) => { + if (!electronMode || !options.enabled) { + return; + } + + const silent = refreshOptions?.silent === true; + if (!silent) { + setLoading(true); + setError(null); + } + try { + const nextSnapshot = await api.refreshCodexAccountSnapshot({ + includeRateLimits: refreshOptions?.includeRateLimits ?? options.includeRateLimits, + forceRefreshToken: refreshOptions?.forceRefreshToken, + }); + applySnapshot(nextSnapshot); + } catch (nextError) { + if (!silent) { + setError( + nextError instanceof Error ? nextError.message : 'Failed to refresh Codex account' + ); + } + } finally { + if (!silent) { + setLoading(false); + } + } + }, + [applySnapshot, electronMode, options.enabled, options.includeRateLimits] + ); + + useEffect(() => { + if (!electronMode || !options.enabled) { + return; + } + + setLoading(true); + setError(null); + + const initialSnapshotRequest = options.includeRateLimits + ? api.refreshCodexAccountSnapshot({ + includeRateLimits: true, + }) + : api.getCodexAccountSnapshot(); + + void initialSnapshotRequest + .then((nextSnapshot) => { + applySnapshot(nextSnapshot); + }) + .catch((nextError) => { + setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account'); + }) + .finally(() => { + setLoading(false); + }); + + const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => { + applySnapshot(nextSnapshot); + }); + + return unsubscribe; + }, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]); + + useEffect(() => { + if (!electronMode || !options.enabled || typeof document === 'undefined') { + return; + } + + const handleVisibilityChange = (): void => { + const nextVisible = isDocumentVisible(); + setVisible(nextVisible); + + if (!nextVisible) { + return; + } + + const staleAfterMs = options.includeRateLimits + ? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS + : CODEX_VISIBLE_STANDARD_REFRESH_MS; + + if ( + lastUpdatedAtRef.current === null || + Date.now() - lastUpdatedAtRef.current >= staleAfterMs + ) { + void refresh({ + includeRateLimits: options.includeRateLimits, + silent: true, + }); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [electronMode, options.enabled, options.includeRateLimits, refresh]); + + useEffect(() => { + if (!electronMode || !options.enabled) { + return; + } + + const refreshIntervalMs = getRefreshIntervalMs({ + loginStatus: snapshot?.login.status, + includeRateLimits: options.includeRateLimits === true, + visible, + }); + const intervalId = window.setInterval(() => { + void refresh({ + includeRateLimits: options.includeRateLimits, + silent: true, + }); + }, refreshIntervalMs); + + return () => { + window.clearInterval(intervalId); + }; + }, [ + electronMode, + options.enabled, + options.includeRateLimits, + refresh, + snapshot?.login.status, + visible, + ]); + + const runAction = useCallback( + async (runner: () => Promise): Promise => { + if (!electronMode || !options.enabled) { + return false; + } + + setLoading(true); + setError(null); + try { + const nextSnapshot = await runner(); + applySnapshot(nextSnapshot); + return true; + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Codex account action failed'); + return false; + } finally { + setLoading(false); + } + }, + [applySnapshot, electronMode, options.enabled] + ); + + return useMemo( + () => ({ + snapshot, + loading, + error, + refresh, + startChatgptLogin: () => runAction(() => api.startCodexChatgptLogin()), + cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()), + logout: () => runAction(() => api.logoutCodexAccount()), + }), + [error, loading, refresh, runAction, snapshot] + ); +} diff --git a/src/features/codex-account/renderer/index.ts b/src/features/codex-account/renderer/index.ts new file mode 100644 index 00000000..70d2e307 --- /dev/null +++ b/src/features/codex-account/renderer/index.ts @@ -0,0 +1,14 @@ +export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot'; +export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot'; +export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot'; +export { + formatCodexCreditsValue, + formatCodexRemainingPercent, + formatCodexResetWindowLabel, + formatCodexUsageExplanation, + formatCodexUsagePercent, + formatCodexUsageWindowLabel, + formatCodexWindowDuration, + formatCodexWindowDurationLong, + normalizeCodexResetTimestamp, +} from './rateLimitDisplay'; diff --git a/src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.ts b/src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.ts new file mode 100644 index 00000000..07d8b4b1 --- /dev/null +++ b/src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.ts @@ -0,0 +1,26 @@ +import { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot'; + +import type { CodexAccountSnapshotDto } from '../contracts'; +import type { CliInstallationStatus } from '@shared/types'; + +export function mergeCodexCliStatusWithSnapshot( + cliStatus: CliInstallationStatus | null, + snapshot: CodexAccountSnapshotDto | null +): CliInstallationStatus | null { + if (!cliStatus || !snapshot) { + return cliStatus; + } + + if (!cliStatus.providers.some((provider) => provider.providerId === 'codex')) { + return cliStatus; + } + + return { + ...cliStatus, + providers: cliStatus.providers.map((provider) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, snapshot) + : provider + ), + }; +} diff --git a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts new file mode 100644 index 00000000..8980cf69 --- /dev/null +++ b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts @@ -0,0 +1,216 @@ +import type { CodexAccountSnapshotDto } from '../contracts'; +import type { CliProviderStatus } from '@shared/types'; + +const CODEX_NATIVE_BACKEND_ID = 'codex-native'; +const CODEX_NATIVE_LABEL = 'Codex native'; +const CODEX_NATIVE_DESCRIPTION = 'Use codex exec JSON mode.'; +const DEFAULT_CODEX_AUTH_MODES = ['auto', 'chatgpt', 'api_key'] as const; + +function isCodexBootstrapPlaceholder(provider: CliProviderStatus): boolean { + return ( + provider.providerId === 'codex' && + provider.supported === false && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null + ); +} + +function getCodexNativeBackendTruth( + snapshot: CodexAccountSnapshotDto +): Pick< + NonNullable[number], + 'available' | 'selectable' | 'state' | 'statusMessage' | 'detailMessage' +> { + switch (snapshot.launchReadinessState) { + case 'ready_chatgpt': + case 'ready_api_key': + case 'ready_both': + return { + available: true, + selectable: true, + state: snapshot.appServerState === 'degraded' ? 'degraded' : 'ready', + statusMessage: + snapshot.appServerState === 'degraded' + ? (snapshot.launchIssueMessage ?? + snapshot.appServerStatusMessage ?? + 'Ready with degraded account verification.') + : 'Ready', + detailMessage: snapshot.appServerStatusMessage, + }; + case 'warning_degraded_but_launchable': + return { + available: true, + selectable: true, + state: 'degraded', + statusMessage: + snapshot.launchIssueMessage ?? + snapshot.appServerStatusMessage ?? + 'Ready with degraded account verification.', + detailMessage: snapshot.appServerStatusMessage, + }; + case 'runtime_missing': + return { + available: false, + selectable: false, + state: 'runtime-missing', + statusMessage: + snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? 'Runtime missing', + detailMessage: snapshot.appServerStatusMessage, + }; + case 'incompatible': + return { + available: false, + selectable: false, + state: 'disabled', + statusMessage: + snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? 'Runtime incompatible', + detailMessage: snapshot.appServerStatusMessage, + }; + case 'missing_auth': + default: + return { + available: false, + selectable: true, + state: 'authentication-required', + statusMessage: + snapshot.launchIssueMessage ?? + 'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.', + detailMessage: snapshot.appServerStatusMessage, + }; + } +} + +function getProviderStatusMessage( + snapshot: CodexAccountSnapshotDto, + fallback: string | null | undefined +): string | null { + if (snapshot.launchAllowed) { + if (snapshot.effectiveAuthMode === 'chatgpt') { + return snapshot.appServerState === 'degraded' + ? (snapshot.launchIssueMessage ?? + 'ChatGPT account detected - account verification is currently degraded.') + : 'ChatGPT account ready'; + } + + if (snapshot.effectiveAuthMode === 'api_key') { + return 'API key ready'; + } + } + + return snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? fallback ?? null; +} + +function mergeCodexNativeBackendOption( + provider: CliProviderStatus, + snapshot: CodexAccountSnapshotDto +): NonNullable { + const truth = getCodexNativeBackendTruth(snapshot); + const existingOptions = provider.availableBackends ?? []; + const hasCodexNativeOption = existingOptions.some( + (option) => option.id === CODEX_NATIVE_BACKEND_ID + ); + const baseOptions = hasCodexNativeOption + ? existingOptions + : [ + ...existingOptions, + { + id: CODEX_NATIVE_BACKEND_ID, + label: CODEX_NATIVE_LABEL, + description: CODEX_NATIVE_DESCRIPTION, + selectable: true, + recommended: true, + available: true, + state: 'ready' as const, + audience: 'general' as const, + statusMessage: null, + detailMessage: null, + }, + ]; + + return baseOptions.map((option) => { + if (option.id !== CODEX_NATIVE_BACKEND_ID) { + return option; + } + + return { + ...option, + label: option.label || CODEX_NATIVE_LABEL, + description: option.description || CODEX_NATIVE_DESCRIPTION, + recommended: option.recommended !== false, + audience: option.audience ?? 'general', + ...truth, + }; + }); +} + +export function mergeCodexProviderStatusWithSnapshot( + provider: CliProviderStatus, + snapshot: CodexAccountSnapshotDto | null +): CliProviderStatus { + if (provider.providerId !== 'codex' || !snapshot) { + return provider; + } + + const availableBackends = mergeCodexNativeBackendOption(provider, snapshot); + const baseConnection = provider.connection ?? { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: [...DEFAULT_CODEX_AUTH_MODES], + configuredAuthMode: snapshot.preferredAuthMode, + apiKeyConfigured: snapshot.apiKey.available, + apiKeySource: snapshot.apiKey.source, + apiKeySourceLabel: snapshot.apiKey.sourceLabel, + codex: null, + }; + + return { + ...provider, + supported: provider.supported || isCodexBootstrapPlaceholder(provider), + authenticated: snapshot.launchAllowed, + authMethod: + snapshot.effectiveAuthMode === 'chatgpt' + ? 'chatgpt' + : snapshot.effectiveAuthMode === 'api_key' + ? 'api_key' + : null, + verificationState: snapshot.launchAllowed + ? 'verified' + : snapshot.appServerState === 'runtime-missing' || snapshot.appServerState === 'incompatible' + ? 'error' + : 'unknown', + statusMessage: getProviderStatusMessage(snapshot, provider.statusMessage), + selectedBackendId: CODEX_NATIVE_BACKEND_ID, + resolvedBackendId: CODEX_NATIVE_BACKEND_ID, + availableBackends, + backend: { + kind: CODEX_NATIVE_BACKEND_ID, + label: CODEX_NATIVE_LABEL, + endpointLabel: 'codex exec --json', + projectId: provider.backend?.projectId ?? null, + authMethodDetail: snapshot.effectiveAuthMode ?? null, + }, + connection: { + ...baseConnection, + configuredAuthMode: snapshot.preferredAuthMode, + apiKeyConfigured: snapshot.apiKey.available, + apiKeySource: snapshot.apiKey.source, + apiKeySourceLabel: snapshot.apiKey.sourceLabel, + codex: { + preferredAuthMode: snapshot.preferredAuthMode, + effectiveAuthMode: snapshot.effectiveAuthMode, + launchAllowed: snapshot.launchAllowed, + launchIssueMessage: snapshot.launchIssueMessage, + launchReadinessState: snapshot.launchReadinessState, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + managedAccount: snapshot.managedAccount, + requiresOpenaiAuth: snapshot.requiresOpenaiAuth, + localAccountArtifactsPresent: snapshot.localAccountArtifactsPresent, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + login: snapshot.login, + rateLimits: snapshot.rateLimits, + }, + }, + }; +} diff --git a/src/features/codex-account/renderer/rateLimitDisplay.ts b/src/features/codex-account/renderer/rateLimitDisplay.ts new file mode 100644 index 00000000..66a0d5fb --- /dev/null +++ b/src/features/codex-account/renderer/rateLimitDisplay.ts @@ -0,0 +1,129 @@ +import type { CodexRateLimitSnapshotDto } from '../contracts'; + +export function normalizeCodexResetTimestamp(resetAt: number | null | undefined): number | null { + if (typeof resetAt !== 'number' || !Number.isFinite(resetAt) || resetAt <= 0) { + return null; + } + + return resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt; +} + +export function formatCodexWindowDuration( + windowDurationMins: number | null | undefined +): string | null { + if ( + typeof windowDurationMins !== 'number' || + !Number.isFinite(windowDurationMins) || + windowDurationMins <= 0 + ) { + return null; + } + + if (windowDurationMins % 10_080 === 0) { + return `${windowDurationMins / 10_080}w`; + } + + if (windowDurationMins % 1_440 === 0) { + return `${windowDurationMins / 1_440}d`; + } + + if (windowDurationMins % 60 === 0) { + return `${windowDurationMins / 60}h`; + } + + return `${windowDurationMins}m`; +} + +export function formatCodexWindowDurationLong( + windowDurationMins: number | null | undefined +): string | null { + if ( + typeof windowDurationMins !== 'number' || + !Number.isFinite(windowDurationMins) || + windowDurationMins <= 0 + ) { + return null; + } + + if (windowDurationMins % 10_080 === 0) { + const weeks = windowDurationMins / 10_080; + return weeks === 1 ? '7-day' : `${weeks}-week`; + } + + if (windowDurationMins % 1_440 === 0) { + const days = windowDurationMins / 1_440; + return days === 1 ? '1-day' : `${days}-day`; + } + + if (windowDurationMins % 60 === 0) { + const hours = windowDurationMins / 60; + return hours === 1 ? '1-hour' : `${hours}-hour`; + } + + return `${windowDurationMins}-minute`; +} + +export function formatCodexUsageWindowLabel( + title: 'Primary used' | 'Secondary used' | 'Weekly used', + windowDurationMins: number | null | undefined +): string { + const duration = formatCodexWindowDuration(windowDurationMins); + return duration ? `${title} (${duration})` : title; +} + +export function formatCodexResetWindowLabel( + title: 'Primary reset' | 'Secondary reset' | 'Weekly reset', + windowDurationMins: number | null | undefined +): string { + const duration = formatCodexWindowDuration(windowDurationMins); + return duration ? `${title} (${duration})` : title; +} + +export function formatCodexUsagePercent(usedPercent: number | null | undefined): string { + return typeof usedPercent === 'number' && Number.isFinite(usedPercent) + ? `${usedPercent}%` + : 'Unknown'; +} + +export function formatCodexRemainingPercent(usedPercent: number | null | undefined): string | null { + if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) { + return null; + } + + const remaining = Math.max(0, Math.min(100, 100 - usedPercent)); + return `${remaining}%`; +} + +export function formatCodexUsageExplanation( + usedPercent: number | null | undefined, + windowDurationMins: number | null | undefined +): string { + const windowLabel = formatCodexWindowDurationLong(windowDurationMins); + const remaining = formatCodexRemainingPercent(usedPercent); + + if (windowLabel && remaining) { + return `${formatCodexUsagePercent(usedPercent)} used - about ${remaining} left in the current ${windowLabel} window.`; + } + + if (windowLabel) { + return `Shows used quota in the current ${windowLabel} window, not remaining quota.`; + } + + return 'Shows used quota, not remaining quota.'; +} + +export function formatCodexCreditsValue(credits: CodexRateLimitSnapshotDto['credits']): string { + if (!credits) { + return 'Unknown'; + } + + if (credits.unlimited) { + return 'Unlimited'; + } + + if (!credits.hasCredits) { + return 'Not available'; + } + + return credits.balance ?? 'Unknown'; +} diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index f7109381..5710cf10 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -9,12 +9,14 @@ import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/Cl import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; -import { CodexBinaryResolver } from '../infrastructure/codex/CodexBinaryResolver'; -import { JsonRpcStdioClient } from '../infrastructure/codex/JsonRpcStdioClient'; import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; +import { + CodexBinaryResolver, + JsonRpcStdioClient, +} from '@main/services/infrastructure/codexAppServer'; import type { ServiceContext } from '@main/services'; export interface RecentProjectsFeatureFacade { diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index 86805355..b24b6f97 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -1,4 +1,7 @@ -import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; +import type { + JsonRpcSession, + JsonRpcStdioClient, +} from '@main/services/infrastructure/codexAppServer'; const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; diff --git a/src/main/index.ts b/src/main/index.ts index ff376759..a89d959e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Sentry must be the first import to capture early errors. import './sentry'; +import { + createCodexAccountFeature, + type CodexAccountFeatureFacade, + registerCodexAccountIpc, + removeCodexAccountIpc, +} from '@features/codex-account/main'; import { createRecentProjectsFeature, type RecentProjectsFeatureFacade, @@ -39,6 +45,7 @@ import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { CONTEXT_CHANGED, SCHEDULE_CHANGE, @@ -414,6 +421,7 @@ let contextRegistry: ServiceContextRegistry; let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; +let codexAccountFeature: CodexAccountFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; @@ -975,6 +983,11 @@ async function initializeServices(): Promise { getLocalContext: () => contextRegistry.get('local'), logger: createLogger('Feature:RecentProjects'), }); + codexAccountFeature = createCodexAccountFeature({ + logger: createLogger('Feature:CodexAccount'), + configManager, + }); + providerConnectionService.setCodexAccountFeature(codexAccountFeature); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -1029,6 +1042,7 @@ async function initializeServices(): Promise { crossTeamService, teamBackupService ?? undefined ); + registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); // Forward SSH state changes to renderer and HTTP SSE clients @@ -1171,6 +1185,9 @@ function shutdownServices(): void { } void skillsWatcherService?.stopAll(); + providerConnectionService.setCodexAccountFeature(null); + void codexAccountFeature?.dispose(); + codexAccountFeature = null; // Kill all PTY processes if (ptyTerminalService) { @@ -1179,6 +1196,7 @@ function shutdownServices(): void { // Remove IPC handlers removeIpcHandlers(); + removeCodexAccountIpc(ipcMain); removeRecentProjectsIpc(ipcMain); // Dispose backup service timers @@ -1458,6 +1476,7 @@ function createWindow(): void { if (teamProvisioningService) { teamProvisioningService.setMainWindow(null); } + codexAccountFeature?.setMainWindow(null); setEditorMainWindow(null); setReviewMainWindow(null); cleanupEditorState(); @@ -1492,6 +1511,7 @@ function createWindow(): void { if (teamProvisioningService) { teamProvisioningService.setMainWindow(mainWindow); } + codexAccountFeature?.setMainWindow(mainWindow); setEditorMainWindow(mainWindow); setReviewMainWindow(mainWindow); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index f651ec3e..e10b5653 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -529,6 +529,23 @@ function validateProviderConnectionsSection( continue; } + if (connectionKey === 'preferredAuthMode') { + if ( + connectionValue !== 'auto' && + connectionValue !== 'chatgpt' && + connectionValue !== 'api_key' + ) { + return { + valid: false, + error: + 'providerConnections.codex.preferredAuthMode must be one of: auto, chatgpt, api_key', + }; + } + + codexUpdate.preferredAuthMode = connectionValue; + continue; + } + return { valid: false, error: `providerConnections.codex.${connectionKey} is not a valid setting`, diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 9ab0015a..24254cc4 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -71,6 +71,9 @@ const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress'; /** Timeout for `claude --version` (ms) */ const VERSION_TIMEOUT_MS = 10_000; +const VERSION_RETRY_ATTEMPTS = 2; +const VERSION_RETRY_DELAY_MS = 350; +const HEALTHY_STATUS_FALLBACK_TTL_MS = 60_000; /** Timeout for `claude install` (ms) — can take a while on slow disks */ const INSTALL_TIMEOUT_MS = 120_000; @@ -134,6 +137,10 @@ function clipTailForDiag(s: string, maxLen: number): string { return stripControlForDiag(s).slice(-maxLen); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + const DIAG_PATH_HEAD = 400; const DIAG_HOME_PREVIEW = 120; const DIAG_AUTH_STDOUT_TAIL = 160; @@ -355,8 +362,36 @@ export class CliInstallerService { } ); private latestStatusSnapshot: CliInstallationStatus | null = null; + private lastHealthyStatusSnapshot: CliInstallationStatus | null = null; + private lastHealthyStatusObservedAt = 0; private readonly latestProviderSignatures = new Map(); + private rememberHealthyStatus(status: CliInstallationStatus): void { + if (!status.installed || !status.binaryPath || status.launchError) { + return; + } + + this.lastHealthyStatusSnapshot = cloneCliInstallationStatus(status); + this.lastHealthyStatusObservedAt = Date.now(); + } + + private getRecoverableHealthyStatus(binaryPath: string): CliInstallationStatus | null { + if ( + !this.lastHealthyStatusSnapshot || + !this.lastHealthyStatusSnapshot.installed || + !this.lastHealthyStatusSnapshot.binaryPath || + this.lastHealthyStatusSnapshot.binaryPath !== binaryPath + ) { + return null; + } + + if (Date.now() - this.lastHealthyStatusObservedAt > HEALTHY_STATUS_FALLBACK_TTL_MS) { + return null; + } + + return cloneCliInstallationStatus(this.lastHealthyStatusSnapshot); + } + private electronMetaForDiag(): Record { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -764,6 +799,7 @@ export class CliInstallerService { r.installedVersion = versionProbe.version; r.launchError = null; r.authStatusChecking = true; + this.rememberHealthyStatus(r); this.publishStatusSnapshot(r); // Auth and GCS version check are independent — run in parallel. @@ -772,8 +808,21 @@ export class CliInstallerService { this.checkAuthStatus(binaryPath, r, diag), r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(), ]); + this.rememberHealthyStatus(r); this.publishStatusSnapshot(r); } else { + const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath); + if (recoveredHealthyStatus) { + logger.warn( + `CLI version probe failed for ${binaryPath}, reusing last healthy status snapshot: ${versionProbe.error}` + ); + Object.assign(r, recoveredHealthyStatus, { + launchError: null, + }); + this.publishStatusSnapshot(r); + return; + } + diag.versionError = versionProbe.error; r.installed = false; r.installedVersion = null; @@ -806,37 +855,50 @@ export class CliInstallerService { private async probeCliVersion( binaryPath: string ): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> { - try { - const { stdout } = await execCli(binaryPath, ['--version'], { - timeout: VERSION_TIMEOUT_MS, - env: this.envForCli(binaryPath), - }); - const version = normalizeVersion(stdout); - if (!version) { - return { ok: false, error: 'CLI returned an empty version string.' }; - } + let lastError: string | null = null; - if (isSemverVersion(version)) { - logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`); - return { ok: true, version }; - } + for (let attempt = 1; attempt <= VERSION_RETRY_ATTEMPTS; attempt += 1) { + try { + const { stdout } = await execCli(binaryPath, ['--version'], { + timeout: VERSION_TIMEOUT_MS, + env: this.envForCli(binaryPath), + }); + const version = normalizeVersion(stdout); + if (!version) { + return { ok: false, error: 'CLI returned an empty version string.' }; + } - const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath); - if (inferredVersion) { - logger.info( - `Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"` + if (isSemverVersion(version)) { + logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`); + return { ok: true, version }; + } + + const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath); + if (inferredVersion) { + logger.info( + `Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"` + ); + return { ok: true, version: inferredVersion }; + } + + logger.warn( + `Installed CLI returned a non-semver version string: "${stdout.trim()}". ` + + 'Treating the binary as healthy, but omitting version details.' ); - return { ok: true, version: inferredVersion }; + return { ok: true, version: null }; + } catch (err) { + lastError = getErrorMessage(err); + if (attempt < VERSION_RETRY_ATTEMPTS) { + logger.warn( + `CLI version probe failed (attempt ${attempt}/${VERSION_RETRY_ATTEMPTS}), retrying after ${VERSION_RETRY_DELAY_MS}ms: ${lastError}` + ); + await sleep(VERSION_RETRY_DELAY_MS); + continue; + } } - - logger.warn( - `Installed CLI returned a non-semver version string: "${stdout.trim()}". ` + - 'Treating the binary as healthy, but omitting version details.' - ); - return { ok: true, version: null }; - } catch (err) { - return { ok: false, error: getErrorMessage(err) }; } + + return { ok: false, error: lastError ?? 'Failed to run runtime version probe.' }; } private async inferInstalledCliVersionFromPath(binaryPath: string): Promise { diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index b9231f0f..e4f324fe 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -11,14 +11,15 @@ import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { validateRegexPattern } from '@main/utils/regexValidation'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { createLogger } from '@shared/utils/logger'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import * as fs from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; +import type { CodexAccountAuthMode } from '@features/codex-account/contracts'; import type { TriggerColor } from '@shared/constants/triggerColors'; import type { SshConnectionProfile } from '@shared/types/api'; @@ -237,7 +238,9 @@ export interface ProviderConnectionsConfig { anthropic: { authMode: ProviderConnectionAuthMode; }; - codex: Record; + codex: { + preferredAuthMode: CodexAccountAuthMode; + }; } export interface DisplayConfig { @@ -331,7 +334,9 @@ const DEFAULT_CONFIG: AppConfig = { anthropic: { authMode: 'auto', }, - codex: {}, + codex: { + preferredAuthMode: 'auto', + }, }, runtime: { providerBackends: { @@ -392,6 +397,27 @@ function normalizeConfiguredClaudeRootPath(value: unknown): string | null { return resolved.slice(0, end); } +function normalizeCodexPreferredAuthMode( + currentValue: unknown, + legacyValue?: unknown +): CodexAccountAuthMode { + const candidate = currentValue ?? legacyValue; + + if (candidate === 'chatgpt' || candidate === 'api_key' || candidate === 'auto') { + return candidate; + } + + if (candidate === 'oauth') { + return 'chatgpt'; + } + + return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode; +} + +function shouldPersistNormalizedConfig(loaded: Partial, normalized: AppConfig): boolean { + return JSON.stringify(loaded) !== JSON.stringify(normalized); +} + // =========================================================================== // ConfigManager Class // =========================================================================== @@ -443,9 +469,14 @@ export class ConfigManager { try { const content = fs.readFileSync(this.configPath, 'utf8'); const parsed = JSON.parse(content) as Partial; + const merged = this.mergeWithDefaults(parsed); + + if (shouldPersistNormalizedConfig(parsed, merged)) { + this.persistConfig(merged); + } // Merge with defaults to ensure all fields exist - return this.mergeWithDefaults(parsed); + return merged; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { logger.info('No config file found, using defaults'); @@ -560,7 +591,12 @@ export class ConfigManager { ...DEFAULT_CONFIG.providerConnections.anthropic, ...(loaded.providerConnections?.anthropic ?? {}), }, - codex: {}, + codex: { + preferredAuthMode: normalizeCodexPreferredAuthMode( + loaded.providerConnections?.codex?.preferredAuthMode, + (loaded.providerConnections?.codex as { authMode?: unknown } | undefined)?.authMode + ), + }, }, runtime: { providerBackends: { @@ -655,6 +691,10 @@ export class ConfigManager { providerBackends: { ...this.config.runtime.providerBackends, ...runtimeUpdate.providerBackends, + codex: migrateProviderBackendId( + 'codex', + runtimeUpdate.providerBackends?.codex ?? this.config.runtime.providerBackends.codex + ) as RuntimeConfig['providerBackends']['codex'], }, } as unknown as Partial; } @@ -670,6 +710,10 @@ export class ConfigManager { codex: { ...this.config.providerConnections.codex, ...(connectionUpdate.codex ?? {}), + preferredAuthMode: normalizeCodexPreferredAuthMode( + connectionUpdate.codex?.preferredAuthMode, + (connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode + ), }, } as unknown as Partial; } diff --git a/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts b/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts new file mode 100644 index 00000000..dee886f8 --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts @@ -0,0 +1,135 @@ +import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; +import type { CodexAppServerInitializeResponse } from './protocol'; + +const DEFAULT_INITIALIZE_TIMEOUT_MS = 6_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; + +export const DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS = [ + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/unarchived', + 'thread/closed', + 'thread/name/updated', + 'turn/started', + 'turn/completed', + 'item/agentMessage/delta', + 'item/agentReasoning/delta', + 'item/execCommandOutputDelta', +]; + +export interface CodexAppServerSession extends JsonRpcSession { + readonly initializeResponse: CodexAppServerInitializeResponse; +} + +export class CodexAppServerSessionFactory { + constructor(private readonly rpcClient: JsonRpcStdioClient) {} + + async withSession( + options: { + binaryPath: string; + env?: NodeJS.ProcessEnv; + requestTimeoutMs?: number; + initializeTimeoutMs?: number; + totalTimeoutMs?: number; + label: string; + experimentalApi?: boolean; + optOutNotificationMethods?: string[]; + }, + handler: (session: CodexAppServerSession) => Promise + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const initializeTimeoutMs = Math.max( + options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, + requestTimeoutMs + ); + + return this.rpcClient.withSession( + { + binaryPath: options.binaryPath, + args: ['app-server'], + env: options.env, + requestTimeoutMs, + totalTimeoutMs: options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, + label: options.label, + }, + async (session) => { + const initializedSession = await this.initializeSession(session, { + initializeTimeoutMs, + experimentalApi: options.experimentalApi ?? false, + optOutNotificationMethods: + options.optOutNotificationMethods ?? + DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS, + }); + return handler(initializedSession); + } + ); + } + + async openSession(options: { + binaryPath: string; + env?: NodeJS.ProcessEnv; + requestTimeoutMs?: number; + initializeTimeoutMs?: number; + experimentalApi?: boolean; + optOutNotificationMethods?: string[]; + }): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const initializeTimeoutMs = Math.max( + options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, + requestTimeoutMs + ); + const session = await this.rpcClient.openSession({ + binaryPath: options.binaryPath, + args: ['app-server'], + env: options.env, + requestTimeoutMs, + }); + + try { + return await this.initializeSession(session, { + initializeTimeoutMs, + experimentalApi: options.experimentalApi ?? false, + optOutNotificationMethods: + options.optOutNotificationMethods ?? + DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS, + }); + } catch (error) { + await session.close().catch(() => undefined); + throw error; + } + } + + private async initializeSession( + session: JsonRpcSession, + options: { + initializeTimeoutMs: number; + experimentalApi: boolean; + optOutNotificationMethods: string[]; + } + ): Promise { + const initializeResponse = await session.request( + 'initialize', + { + clientInfo: { + name: 'claude-agent-teams-ui', + title: 'Agent Teams UI', + version: '0.1.0', + }, + capabilities: { + experimentalApi: options.experimentalApi, + optOutNotificationMethods: options.optOutNotificationMethods, + }, + }, + options.initializeTimeoutMs + ); + + await session.notify('initialized'); + + return { + ...session, + initializeResponse, + }; + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts similarity index 100% rename from src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts rename to src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts diff --git a/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts similarity index 61% rename from src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts rename to src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts index afd3dc6a..4b2bf21c 100644 --- a/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts +++ b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts @@ -3,10 +3,9 @@ import readline from 'node:readline'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; -import type { LoggerPort } from '../../../core/application/ports/LoggerPort'; - -const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; -const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +interface JsonRpcLogger { + warn: (message: string, meta?: Record) => void; +} interface JsonRpcErrorPayload { code?: number; @@ -19,9 +18,16 @@ interface JsonRpcResponse { error?: JsonRpcErrorPayload; } +interface JsonRpcNotificationMessage { + method?: string; + params?: unknown; +} + export interface JsonRpcSession { request(method: string, params?: unknown, timeoutMs?: number): Promise; notify(method: string, params?: unknown): Promise; + onNotification(listener: (method: string, params: unknown) => void): () => void; + close(): Promise; } function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { @@ -40,42 +46,48 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): }) as Promise; } +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; + export class JsonRpcStdioClient { - constructor(private readonly logger: LoggerPort) {} + constructor(private readonly logger: JsonRpcLogger) {} async withSession( options: { binaryPath: string; args: string[]; + env?: NodeJS.ProcessEnv; requestTimeoutMs?: number; totalTimeoutMs?: number; label: string; }, handler: (session: JsonRpcSession) => Promise ): Promise { - const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const session = await this.openSession(options); const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; - return withTimeout( - this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler), - totalTimeoutMs, - options.label - ); + try { + return await withTimeout(handler(session), totalTimeoutMs, options.label); + } finally { + await session.close(); + } } - async #runSession( - binaryPath: string, - args: string[], - requestTimeoutMs: number, - handler: (session: JsonRpcSession) => Promise - ): Promise { - const child = spawnCli(binaryPath, args, { + async openSession(options: { + binaryPath: string; + args: string[]; + env?: NodeJS.ProcessEnv; + requestTimeoutMs?: number; + }): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const child = spawnCli(options.binaryPath, options.args, { + env: options.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); const lineReader = readline.createInterface({ input: child.stdout! }); child.stderr?.on('data', () => { - // Keep stderr drained so process warnings do not block the pipe. + // Keep stderr drained so warnings never block the pipe. }); const pending = new Map< @@ -86,8 +98,10 @@ export class JsonRpcStdioClient { timeoutId: ReturnType; } >(); + const notificationListeners = new Set<(method: string, params: unknown) => void>(); let nextRequestId = 1; + let closed = false; const rejectAll = (error: Error): void => { for (const [id, entry] of pending) { @@ -97,10 +111,27 @@ export class JsonRpcStdioClient { } }; + const handleNotification = (message: JsonRpcNotificationMessage): void => { + if (typeof message.method !== 'string' || message.method.length === 0) { + return; + } + + for (const listener of notificationListeners) { + try { + listener(message.method, message.params); + } catch (error) { + this.logger.warn('json-rpc notification listener failed', { + error: error instanceof Error ? error.message : String(error), + method: message.method, + }); + } + } + }; + lineReader.on('line', (line) => { - let message: JsonRpcResponse; + let message: JsonRpcResponse & JsonRpcNotificationMessage; try { - message = JSON.parse(line) as JsonRpcResponse; + message = JSON.parse(line) as JsonRpcResponse & JsonRpcNotificationMessage; } catch (error) { this.logger.warn('json-rpc stdio emitted non-json line', { error: error instanceof Error ? error.message : String(error), @@ -108,24 +139,25 @@ export class JsonRpcStdioClient { return; } - if (typeof message.id !== 'number') { + if (typeof message.id === 'number') { + const entry = pending.get(message.id); + if (!entry) { + return; + } + + clearTimeout(entry.timeoutId); + pending.delete(message.id); + + if (message.error) { + entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); + return; + } + + entry.resolve(message.result); return; } - const entry = pending.get(message.id); - if (!entry) { - return; - } - - clearTimeout(entry.timeoutId); - pending.delete(message.id); - - if (message.error) { - entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); - return; - } - - entry.resolve(message.result); + handleNotification(message); }); child.once('error', (error) => { @@ -144,14 +176,42 @@ export class JsonRpcStdioClient { ); }); - const session: JsonRpcSession = { + const close = async (): Promise => { + if (closed) { + return; + } + closed = true; + + rejectAll(new Error('JSON-RPC session closed')); + notificationListeners.clear(); + lineReader.close(); + + if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { + await new Promise((resolve) => { + try { + child.stdin!.end(() => resolve()); + } catch { + resolve(); + } + }); + } + + killProcessTree(child); + try { + await once(child, 'close'); + } catch { + this.logger.warn('json-rpc close wait failed'); + } + }; + + return { request: ( method: string, params?: unknown, timeoutMs = requestTimeoutMs ): Promise => new Promise((resolve, reject) => { - if (!child.stdin) { + if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) { reject(new Error('JSON-RPC stdin is not available')); return; } @@ -176,7 +236,7 @@ export class JsonRpcStdioClient { }), notify: async (method: string, params?: unknown): Promise => { - if (!child.stdin) { + if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) { throw new Error('JSON-RPC stdin is not available'); } @@ -190,22 +250,15 @@ export class JsonRpcStdioClient { }); }); }, - }; - try { - return await handler(session); - } finally { - rejectAll(new Error('JSON-RPC session closed')); - lineReader.close(); - if (child.stdin && !child.stdin.destroyed) { - child.stdin.end(); - } - killProcessTree(child); - try { - await once(child, 'close'); - } catch { - this.logger.warn('json-rpc close wait failed'); - } - } + onNotification: (listener) => { + notificationListeners.add(listener); + return (): void => { + notificationListeners.delete(listener); + }; + }, + + close, + }; } } diff --git a/src/main/services/infrastructure/codexAppServer/index.ts b/src/main/services/infrastructure/codexAppServer/index.ts new file mode 100644 index 00000000..63b3b013 --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/index.ts @@ -0,0 +1,29 @@ +export type { CodexAppServerSession } from './CodexAppServerSessionFactory'; +export { + CodexAppServerSessionFactory, + DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS, +} from './CodexAppServerSessionFactory'; +export { CodexBinaryResolver } from './CodexBinaryResolver'; +export type { JsonRpcSession } from './JsonRpcStdioClient'; +export { JsonRpcStdioClient } from './JsonRpcStdioClient'; +export type { + CodexAppServerAccount, + CodexAppServerAccountLoginCompletedNotification, + CodexAppServerAccountRateLimitsUpdatedNotification, + CodexAppServerAccountUpdatedNotification, + CodexAppServerAuthMode, + CodexAppServerCancelLoginAccountParams, + CodexAppServerCancelLoginAccountResponse, + CodexAppServerCancelLoginAccountStatus, + CodexAppServerCreditsSnapshot, + CodexAppServerGetAccountParams, + CodexAppServerGetAccountRateLimitsResponse, + CodexAppServerGetAccountResponse, + CodexAppServerInitializeResponse, + CodexAppServerLoginAccountParams, + CodexAppServerLoginAccountResponse, + CodexAppServerLogoutAccountResponse, + CodexAppServerPlanType, + CodexAppServerRateLimitSnapshot, + CodexAppServerRateLimitWindow, +} from './protocol'; diff --git a/src/main/services/infrastructure/codexAppServer/protocol.ts b/src/main/services/infrastructure/codexAppServer/protocol.ts new file mode 100644 index 00000000..4fd8fbf1 --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/protocol.ts @@ -0,0 +1,113 @@ +export type CodexAppServerPlanType = + | 'free' + | 'go' + | 'plus' + | 'pro' + | 'team' + | 'business' + | 'enterprise' + | 'edu' + | 'unknown'; + +export type CodexAppServerAuthMode = 'apikey' | 'chatgpt' | 'chatgptAuthTokens'; + +export interface CodexAppServerInitializeResponse { + userAgent: string; + codexHome: string; + platformFamily: string; + platformOs: string; +} + +export type CodexAppServerAccount = + | { type: 'apiKey' } + | { + type: 'chatgpt'; + email: string; + planType: CodexAppServerPlanType; + }; + +export interface CodexAppServerGetAccountResponse { + account: CodexAppServerAccount | null; + requiresOpenaiAuth: boolean; +} + +export interface CodexAppServerGetAccountParams { + refreshToken: boolean; +} + +export type CodexAppServerLoginAccountParams = + | { + type: 'apiKey'; + apiKey: string; + } + | { + type: 'chatgpt'; + } + | { + type: 'chatgptAuthTokens'; + accessToken: string; + chatgptAccountId: string; + chatgptPlanType?: string | null; + }; + +export type CodexAppServerLoginAccountResponse = + | { type: 'apiKey' } + | { + type: 'chatgpt'; + loginId: string; + authUrl: string; + } + | { type: 'chatgptAuthTokens' }; + +export type CodexAppServerLogoutAccountResponse = Record; + +export interface CodexAppServerRateLimitWindow { + usedPercent: number; + windowDurationMins: number | null; + resetsAt: number | null; +} + +export interface CodexAppServerCreditsSnapshot { + hasCredits: boolean; + unlimited: boolean; + balance: string | null; +} + +export interface CodexAppServerRateLimitSnapshot { + limitId: string | null; + limitName: string | null; + primary: CodexAppServerRateLimitWindow | null; + secondary: CodexAppServerRateLimitWindow | null; + credits: CodexAppServerCreditsSnapshot | null; + planType: CodexAppServerPlanType | null; +} + +export interface CodexAppServerGetAccountRateLimitsResponse { + rateLimits: CodexAppServerRateLimitSnapshot; + rateLimitsByLimitId: Record | null; +} + +export interface CodexAppServerAccountLoginCompletedNotification { + loginId: string | null; + success: boolean; + error: string | null; +} + +export interface CodexAppServerAccountUpdatedNotification { + authMode: CodexAppServerAuthMode | null; + planType: CodexAppServerPlanType | null; +} + +export interface CodexAppServerAccountRateLimitsUpdatedNotification { + rateLimits: CodexAppServerRateLimitSnapshot; +} + +export interface CodexAppServerCancelLoginAccountParams { + loginId: string; +} + +export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound'; + +export interface CodexAppServerCancelLoginAccountResponse { + status: CodexAppServerCancelLoginAccountStatus; +} diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index f81a0ceb..5581bc29 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -1,8 +1,16 @@ +import path from 'node:path'; + +import { evaluateCodexLaunchReadiness } from '@features/codex-account'; import { getCachedShellEnv } from '@main/utils/shellEnv'; import { ApiKeyService } from '../extensions/apikeys/ApiKeyService'; import { ConfigManager } from '../infrastructure/ConfigManager'; +import type { + CodexAccountAuthMode, + CodexAccountSnapshotDto, +} from '@features/codex-account/contracts'; +import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; import type { CliProviderAuthMode, CliProviderConnectionInfo, @@ -27,7 +35,7 @@ const PROVIDER_CAPABILITIES: Record< codex: { supportsOAuth: false, supportsApiKey: true, - configurableAuthModes: [], + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], }, gemini: { supportsOAuth: false, @@ -45,8 +53,30 @@ const PROVIDER_API_KEY_ENV_VARS: Partial> = { const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; +function isCodexExecBinary(binaryPath?: string | null): boolean { + const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase(); + return ( + binaryName === 'codex' || + binaryName === 'codex.exe' || + binaryName === 'codex-cli' || + binaryName === 'codex-cli.exe' + ); +} + +function buildCodexForcedLoginLaunchArgs( + binaryPath: string | null | undefined, + loginMethod: 'chatgpt' | 'api' +): string[] { + if (isCodexExecBinary(binaryPath)) { + return ['-c', `forced_login_method="${loginMethod}"`]; + } + + return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })]; +} + export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; + private codexAccountFeature: Pick | null = null; constructor( private readonly apiKeyService = new ApiKeyService(), @@ -58,13 +88,17 @@ export class ProviderConnectionService { return ProviderConnectionService.instance; } + setCodexAccountFeature(feature: Pick | null): void { + this.codexAccountFeature = feature; + } + getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null { if (providerId === 'anthropic') { return this.configManager.getConfig().providerConnections.anthropic.authMode; } if (providerId === 'codex') { - return null; + return this.configManager.getConfig().providerConnections.codex.preferredAuthMode; } return null; @@ -107,23 +141,24 @@ export class ProviderConnectionService { return env; } - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); - const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); - const existingOpenAiKey = - typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() - ? env.OPENAI_API_KEY - : null; - const existingNativeKey = - typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && - env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() - ? env[CODEX_NATIVE_API_KEY_ENV_VAR] - : null; - const resolvedApiKey = - storedKey?.value.trim() || - existingOpenAiKey || - (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null); + const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); - if (resolvedApiKey) { + if (readiness.effectiveAuthMode === 'chatgpt') { + delete env.OPENAI_API_KEY; + delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; + return env; + } + + const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride); + if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) { env.OPENAI_API_KEY = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; return env; @@ -166,33 +201,25 @@ export class ProviderConnectionService { return env; } - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); - const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); - const existingOpenAiKey = - typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() - ? env.OPENAI_API_KEY - : null; - const existingNativeKey = - typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && - env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() - ? env[CODEX_NATIVE_API_KEY_ENV_VAR] - : null; - const resolvedApiKey = - storedKey?.value.trim() || - existingOpenAiKey || - (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null); + const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); - if (storedKey?.value.trim()) { - env.OPENAI_API_KEY = storedKey.value; - } else if ( - !existingOpenAiKey && - existingNativeKey && - codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID - ) { - env.OPENAI_API_KEY = existingNativeKey; + if (readiness.effectiveAuthMode === 'chatgpt') { + delete env.OPENAI_API_KEY; + delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; + return env; } - if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID && resolvedApiKey) { + const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride); + if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) { + env.OPENAI_API_KEY = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; } @@ -210,7 +237,7 @@ export class ProviderConnectionService { async getConfiguredConnectionIssue( env: NodeJS.ProcessEnv, providerId: CliProviderId, - runtimeBackendOverride?: string | null + _runtimeBackendOverride?: string | null ): Promise { if (providerId === 'anthropic') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') { @@ -231,16 +258,42 @@ export class ProviderConnectionService { return null; } - const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); - if ( - (typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) || - (typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && - env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()) - ) { + const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); + + if (readiness.launchAllowed) { return null; } - return 'Codex native requires OPENAI_API_KEY or CODEX_API_KEY. Add a stored or environment API key before launching Codex.'; + if (readiness.state === 'missing_auth') { + if (snapshot.preferredAuthMode === 'chatgpt') { + return snapshot.requiresOpenaiAuth + ? snapshot.localActiveChatgptAccountPresent + ? 'Codex ChatGPT account mode is selected, and Codex has a locally selected ChatGPT account, but the current session needs reconnect. Reconnect ChatGPT or switch Codex auth mode to API key.' + : snapshot.localAccountArtifactsPresent + ? 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected. Connect ChatGPT again or switch Codex auth mode to API key.' + : 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Connect ChatGPT again or switch Codex auth mode to API key.' + : 'Codex ChatGPT account mode is selected, but no managed ChatGPT account is available. Connect ChatGPT again or switch Codex auth mode to API key.'; + } + + if (snapshot.preferredAuthMode === 'api_key') { + return 'Codex API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available. Add one before launching Codex.'; + } + + return 'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account. Add one before launching Codex.'; + } + + return ( + readiness.issueMessage ?? + 'Codex native is not ready. Connect a ChatGPT account or add an API key before launching.' + ); } async getConfiguredConnectionIssues( @@ -264,6 +317,41 @@ export class ProviderConnectionService { return issues; } + async getConfiguredConnectionLaunchArgs( + env: NodeJS.ProcessEnv, + providerId: CliProviderId, + runtimeBackendOverride?: string | null, + binaryPath?: string | null + ): Promise { + if (providerId !== 'codex') { + return []; + } + + if (this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) !== CODEX_NATIVE_BACKEND_ID) { + return []; + } + + const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); + + if (readiness.effectiveAuthMode === 'chatgpt') { + return buildCodexForcedLoginLaunchArgs(binaryPath, 'chatgpt'); + } + + if (readiness.effectiveAuthMode === 'api_key') { + return buildCodexForcedLoginLaunchArgs(binaryPath, 'api'); + } + + return []; + } + async enrichProviderStatus(provider: CliProviderStatus): Promise { return { ...provider, @@ -278,27 +366,57 @@ export class ProviderConnectionService { async getConnectionInfo(providerId: CliProviderId): Promise { const capabilities = PROVIDER_CAPABILITIES[providerId]; const storedApiKey = await this.getStoredApiKey(providerId); - const codexRuntimeBackend = - providerId === 'codex' ? this.getConfiguredCodexRuntimeBackend() : null; - const externalCredential = this.getExternalCredential(providerId, codexRuntimeBackend); - const configurableAuthModes = - providerId === 'codex' ? ([] as CliProviderAuthMode[]) : capabilities.configurableAuthModes; + const externalCredential = this.getExternalCredential(providerId); + const codexSnapshot = providerId === 'codex' ? await this.getCodexAccountSnapshot() : null; + const configurableAuthModes = capabilities.configurableAuthModes; const configuredAuthMode = - providerId === 'codex' ? null : this.getConfiguredAuthMode(providerId); + providerId === 'codex' + ? (codexSnapshot?.preferredAuthMode ?? this.getConfiguredAuthMode(providerId)) + : this.getConfiguredAuthMode(providerId); + const apiKeyConfigured = + providerId === 'codex' + ? (codexSnapshot?.apiKey.available ?? false) + : Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim()); + const apiKeySource = + providerId === 'codex' + ? (codexSnapshot?.apiKey.source ?? null) + : storedApiKey?.value.trim() + ? 'stored' + : externalCredential?.value.trim() + ? 'environment' + : null; + const apiKeySourceLabel = + providerId === 'codex' + ? (codexSnapshot?.apiKey.sourceLabel ?? null) + : storedApiKey?.value.trim() + ? 'Stored in app' + : (externalCredential?.label ?? null); return { ...capabilities, configurableAuthModes, configuredAuthMode, - apiKeyConfigured: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim()), - apiKeySource: storedApiKey?.value.trim() - ? 'stored' - : externalCredential?.value.trim() - ? 'environment' + apiKeyConfigured, + apiKeySource, + apiKeySourceLabel, + codex: + providerId === 'codex' && codexSnapshot + ? { + preferredAuthMode: codexSnapshot.preferredAuthMode, + effectiveAuthMode: codexSnapshot.effectiveAuthMode, + appServerState: codexSnapshot.appServerState, + appServerStatusMessage: codexSnapshot.appServerStatusMessage, + managedAccount: codexSnapshot.managedAccount, + requiresOpenaiAuth: codexSnapshot.requiresOpenaiAuth, + localAccountArtifactsPresent: codexSnapshot.localAccountArtifactsPresent, + localActiveChatgptAccountPresent: codexSnapshot.localActiveChatgptAccountPresent, + login: codexSnapshot.login, + rateLimits: codexSnapshot.rateLimits, + launchAllowed: codexSnapshot.launchAllowed, + launchIssueMessage: codexSnapshot.launchIssueMessage, + launchReadinessState: codexSnapshot.launchReadinessState, + } : null, - apiKeySourceLabel: storedApiKey?.value.trim() - ? 'Stored in app' - : (externalCredential?.label ?? null), }; } @@ -320,10 +438,111 @@ export class ProviderConnectionService { return CODEX_NATIVE_BACKEND_ID; } - private getExternalCredential( - providerId: CliProviderId, - codexRuntimeBackend: 'codex-native' | null = null - ): ExternalCredential { + private async getCodexAccountSnapshot(): Promise { + if (this.codexAccountFeature) { + return this.codexAccountFeature.getSnapshot(); + } + + const preferredAuthMode = + (this.configManager.getConfig().providerConnections.codex.preferredAuthMode as + | CodexAccountAuthMode + | undefined) ?? 'auto'; + const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); + const externalCredential = this.getExternalCredential('codex'); + const apiKeyAvailable = Boolean(storedKey?.value.trim() || externalCredential?.value.trim()); + const apiKey = { + available: apiKeyAvailable, + source: storedKey?.value.trim() + ? 'stored' + : externalCredential?.value.trim() + ? 'environment' + : null, + sourceLabel: storedKey?.value.trim() ? 'Stored in app' : (externalCredential?.label ?? null), + } satisfies CodexAccountSnapshotDto['apiKey']; + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode, + managedAccount: null, + apiKey, + appServerState: 'degraded', + appServerStatusMessage: 'Codex account management has not been initialized yet.', + localActiveChatgptAccountPresent: false, + }); + + return { + preferredAuthMode, + effectiveAuthMode: readiness.effectiveAuthMode, + launchAllowed: readiness.launchAllowed, + launchIssueMessage: readiness.issueMessage, + launchReadinessState: readiness.state, + appServerState: 'degraded', + appServerStatusMessage: 'Codex account management has not been initialized yet.', + managedAccount: null, + apiKey, + requiresOpenaiAuth: null, + localAccountArtifactsPresent: false, + localActiveChatgptAccountPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + } + + private async resolveCodexApiKeyValue( + env: NodeJS.ProcessEnv, + runtimeBackendOverride?: string | null + ): Promise { + const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); + const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); + const existingOpenAiKey = + typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() + ? env.OPENAI_API_KEY + : null; + const existingNativeKey = + typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && + env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() + ? env[CODEX_NATIVE_API_KEY_ENV_VAR] + : null; + + return ( + storedKey?.value.trim() || + existingOpenAiKey || + (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null) + ); + } + + private mergeCodexApiKeyAvailability( + snapshot: CodexAccountSnapshotDto, + env: NodeJS.ProcessEnv + ): CodexAccountSnapshotDto { + const openAiApiKey = + typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() + ? env.OPENAI_API_KEY + : null; + const codexApiKey = + typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && + env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() + ? env[CODEX_NATIVE_API_KEY_ENV_VAR] + : null; + + if (!openAiApiKey && !codexApiKey) { + return snapshot; + } + + return { + ...snapshot, + apiKey: { + available: true, + source: 'environment', + sourceLabel: codexApiKey ? 'Detected from CODEX_API_KEY' : 'Detected from OPENAI_API_KEY', + }, + }; + } + + private getExternalCredential(providerId: CliProviderId): ExternalCredential { const shellEnv = getCachedShellEnv() ?? {}; const sources = [shellEnv, process.env]; diff --git a/src/main/services/runtime/buildRuntimeBaseEnv.ts b/src/main/services/runtime/buildRuntimeBaseEnv.ts new file mode 100644 index 00000000..568d0fa3 --- /dev/null +++ b/src/main/services/runtime/buildRuntimeBaseEnv.ts @@ -0,0 +1,86 @@ +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { getShellPreferredHome } from '@main/utils/shellEnv'; + +import { configManager } from '../infrastructure/ConfigManager'; + +import { + applyConfiguredRuntimeBackendsEnv, + applyProviderRuntimeEnv, + resolveTeamProviderId, +} from './providerRuntimeEnv'; + +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; + +export interface BuildRuntimeBaseEnvOptions { + binaryPath?: string | null; + providerId?: ProviderEnvTargetId; + providerBackendId?: string | null; + shellEnv?: NodeJS.ProcessEnv | null; + env?: NodeJS.ProcessEnv; +} + +function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): { + env: NodeJS.ProcessEnv; + resolvedProviderId: CliProviderId | null; +} { + const shellEnv = options.shellEnv ?? {}; + const env = { + ...buildEnrichedEnv(options.binaryPath), + ...shellEnv, + }; + + applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); + Object.assign(env, options.env ?? {}); + + const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE); + const fallbackHome = getFirstNonEmptyEnvValue( + env.HOME, + env.USERPROFILE, + getShellPreferredHome(), + shellEnv.HOME, + process.env.HOME, + process.env.USERPROFILE + ); + + if (explicitHome) { + env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome); + } else if (fallbackHome) { + env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome); + } + + if (!options.providerId) { + return { + env, + resolvedProviderId: null, + }; + } + + const resolvedProviderId = resolveTeamProviderId(options.providerId); + applyProviderRuntimeEnv(env, options.providerId); + + if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) { + env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim(); + } + + if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) { + env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim(); + } + + return { + env, + resolvedProviderId, + }; +} diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index bc5e4a93..83de3a28 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -1,14 +1,7 @@ -import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; - -import { configManager } from '../infrastructure/ConfigManager'; +import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import { - applyConfiguredRuntimeBackendsEnv, - applyProviderRuntimeEnv, - resolveTeamProviderId, -} from './providerRuntimeEnv'; import type { CliProviderId, TeamProviderId } from '@shared/types'; @@ -26,15 +19,7 @@ export interface ProviderAwareCliEnvOptions { export interface ProviderAwareCliEnvResult { env: NodeJS.ProcessEnv; connectionIssues: Partial>; -} - -function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined { - for (const value of values) { - if (typeof value === 'string' && value.trim().length > 0) { - return value; - } - } - return undefined; + providerArgs: string[]; } export async function buildProviderAwareCliEnv( @@ -42,41 +27,17 @@ export async function buildProviderAwareCliEnv( ): Promise { const connectionMode = options.connectionMode ?? 'strict'; const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {}; - const env = { - ...buildEnrichedEnv(options.binaryPath), - ...shellEnv, - }; - - applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); - - Object.assign(env, options.env ?? {}); - - const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE); - const fallbackHome = getFirstNonEmptyEnvValue( - env.HOME, - env.USERPROFILE, - getShellPreferredHome(), - shellEnv.HOME, - process.env.HOME, - process.env.USERPROFILE - ); - - if (explicitHome) { - env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome); - env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome); - } else if (fallbackHome) { - env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome); - env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome); - } + const { env, resolvedProviderId } = buildRuntimeBaseEnv({ + binaryPath: options.binaryPath, + providerId: options.providerId, + providerBackendId: options.providerBackendId, + shellEnv, + env: options.env, + }); if (options.providerId) { - const resolvedProviderId = resolveTeamProviderId(options.providerId); - applyProviderRuntimeEnv(env, options.providerId); - if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) { - env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim(); - } - if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) { - env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim(); + if (!resolvedProviderId) { + throw new Error('Resolved provider id is required when providerId is set'); } if (connectionMode === 'augment') { await providerConnectionService.augmentConfiguredConnectionEnv( @@ -87,6 +48,7 @@ export async function buildProviderAwareCliEnv( return { env, connectionIssues: {}, + providerArgs: [], }; } @@ -98,6 +60,12 @@ export async function buildProviderAwareCliEnv( return { env, + providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs( + env, + resolvedProviderId, + options.providerBackendId, + options.binaryPath + ), connectionIssues: await providerConnectionService.getConfiguredConnectionIssues( env, [resolvedProviderId], @@ -113,6 +81,7 @@ export async function buildProviderAwareCliEnv( return { env, connectionIssues: {}, + providerArgs: [], }; } @@ -120,5 +89,6 @@ export async function buildProviderAwareCliEnv( return { env, connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), + providerArgs: [], }; } diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index a98bde32..d2692183 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -105,7 +105,7 @@ export class ScheduledTaskExecutor { request.config.providerId === 'codex' || request.config.providerId === 'gemini' ? request.config.providerId : 'anthropic'; - const { env, connectionIssues } = await buildProviderAwareCliEnv({ + const { env, connectionIssues, providerArgs } = await buildProviderAwareCliEnv({ binaryPath, providerId, shellEnv, @@ -119,6 +119,8 @@ export class ScheduledTaskExecutor { throw new Error(connectionIssue); } + args.push(...providerArgs); + const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9b1e6518..5807ea51 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2424,12 +2424,11 @@ export class TeamDataService { : undefined, agentType: 'general-purpose' as const, joinedAt, - })), - { - providerBackendId: request.providerBackendId, - } + })) ); - await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); } async reconcileTeamArtifacts( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f2fab167..176f2d12 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -773,6 +773,7 @@ interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; authSource: ProvisioningAuthSource; geminiRuntimeAuth: GeminiRuntimeAuthState | null; + providerArgs?: string[]; warning?: string; } @@ -6201,7 +6202,12 @@ export class TeamProvisioningService { request.providerId, request.providerBackendId ); - const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + const { + env: shellEnv, + geminiRuntimeAuth, + providerArgs = [], + warning: envWarning, + } = provisioningEnv; if (envWarning) { throw new Error(envWarning); } @@ -6380,6 +6386,7 @@ export class TeamProvisioningService { ...(request.effort ? ['--effort', request.effort] : []), ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), + ...providerArgs, ]; const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -6427,12 +6434,11 @@ export class TeamProvisioningService { : undefined, agentType: 'general-purpose' as const, joinedAt: Date.now(), - })), - { - providerBackendId: request.providerBackendId, - } + })) ); - await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); if (request.skipPermissions === false) { await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); } @@ -6772,7 +6778,12 @@ export class TeamProvisioningService { request.providerId, request.providerBackendId ); - const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + const { + env: shellEnv, + geminiRuntimeAuth, + providerArgs = [], + warning: envWarning, + } = provisioningEnv; if (envWarning) { throw new Error(envWarning); } @@ -7009,6 +7020,7 @@ export class TeamProvisioningService { launchArgs.push('--worktree', request.worktree); } launchArgs.push(...parseCliArgs(request.extraCliArgs)); + launchArgs.push(...providerArgs); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, promptSize, @@ -12888,12 +12900,18 @@ export class TeamProvisioningService { env: providerEnv, authSource: 'configured_api_key_missing', geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, warning: providerConnectionIssue, }; } if (resolvedProviderId === 'codex') { - return { env: providerEnv, authSource: 'codex_runtime', geminiRuntimeAuth: null }; + return { + env: providerEnv, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, + }; } if (resolvedProviderId === 'gemini') { @@ -12901,6 +12919,7 @@ export class TeamProvisioningService { env: providerEnv, authSource: 'gemini_runtime', geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv), + providerArgs: providerEnvResult.providerArgs, }; } @@ -12909,7 +12928,12 @@ export class TeamProvisioningService { typeof providerEnv.ANTHROPIC_API_KEY === 'string' && providerEnv.ANTHROPIC_API_KEY.trim().length > 0 ) { - return { env: providerEnv, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; + return { + env: providerEnv, + authSource: 'anthropic_api_key', + geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, + }; } // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, @@ -12919,7 +12943,12 @@ export class TeamProvisioningService { providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0 ) { providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN; - return { env: providerEnv, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; + return { + env: providerEnv, + authSource: 'anthropic_auth_token', + geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, + }; } // 3. No explicit API key — let the CLI handle its own OAuth auth. @@ -12927,7 +12956,12 @@ export class TeamProvisioningService { // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // credentials file causes 401 errors because the stored token is // often stale (CLI refreshes in-memory but rarely writes back). - return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null }; + return { + env: providerEnv, + authSource: 'none', + geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, + }; } private async resolveControlApiBaseUrl(): Promise { @@ -13681,12 +13715,11 @@ export class TeamProvisioningService { : undefined, agentType: 'general-purpose' as const, joinedAt, - })), - { - providerBackendId: request.providerBackendId, - } + })) ); - await this.membersMetaStore.writeMembers(teamName, membersToWrite); + await this.membersMetaStore.writeMembers(teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); } catch (error) { logger.warn( `[${teamName}] Failed to persist members.meta.json: ${ diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts index 5936f527..e973dcba 100644 --- a/src/main/services/team/cliFlavor.ts +++ b/src/main/services/team/cliFlavor.ts @@ -26,7 +26,7 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { switch (flavor) { case 'agent_teams_orchestrator': return { - displayName: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', supportsSelfUpdate: false, showVersionDetails: false, showBinaryPath: false, diff --git a/src/preload/index.ts b/src/preload/index.ts index ac737d09..ccf85725 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { createCodexAccountBridge } from '@features/codex-account/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; @@ -121,6 +122,7 @@ import { TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, @@ -147,7 +149,6 @@ import { TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_MEMBER_SPAWN_STATUSES, - TEAM_GET_AGENT_RUNTIME, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, @@ -165,9 +166,9 @@ import { TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, - TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -270,7 +271,6 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -293,6 +293,7 @@ import type { TaskChangePresenceState, TaskChangeSetV2, TaskComment, + TeamAgentRuntimeSnapshot, TeamChangeEvent, TeamClaudeLogsQuery, TeamClaudeLogsResponse, @@ -458,6 +459,9 @@ ipcRenderer.on( // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object const electronAPI: ElectronAPI = { + ...createCodexAccountBridge({ + ipcRenderer, + }), ...createRecentProjectsBridge(), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 7037e29e..58b5af8b 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,6 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { AppConfig, @@ -219,6 +220,29 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); + getCodexAccountSnapshot = (): Promise => + Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); + + refreshCodexAccountSnapshot = (_options?: { + includeRateLimits?: boolean; + forceRefreshToken?: boolean; + }): Promise => + Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); + + startCodexChatgptLogin = (): Promise => + Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); + + cancelCodexChatgptLogin = (): Promise => + Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); + + logoutCodexAccount = (): Promise => + Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); + + onCodexAccountSnapshotChanged = + (_callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void): (() => void) => + () => + undefined; + getDashboardRecentProjects = (): Promise => this.get('/api/dashboard/recent-projects'); diff --git a/src/renderer/components/common/CliInstallWarningBanner.tsx b/src/renderer/components/common/CliInstallWarningBanner.tsx index a87ef7aa..3a9cb1f1 100644 --- a/src/renderer/components/common/CliInstallWarningBanner.tsx +++ b/src/renderer/components/common/CliInstallWarningBanner.tsx @@ -1,6 +1,6 @@ /** * CliInstallWarningBanner — Global warning strip shown below the tab bar - * when Claude Code CLI is not installed. + * when the configured runtime is unavailable. * * Hidden on Dashboard pages (which have their own detailed CliStatusBanner). * Only rendered in Electron mode. @@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow'; export const CliInstallWarningBanner = (): React.JSX.Element | null => { const cliStatus = useStore(useShallow((s) => s.cliStatus)); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const openDashboard = useStore((s) => s.openDashboard); // Returns a primitive boolean — minimizes re-renders @@ -24,7 +25,13 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => { }); // Hide when: not Electron, status not loaded yet, CLI installed, or dashboard is focused - if (!isElectronMode() || !cliStatus || cliStatus.installed || isDashboardFocused) { + if ( + !isElectronMode() || + cliStatusLoading || + !cliStatus || + cliStatus.installed || + isDashboardFocused + ) { return null; } @@ -40,8 +47,8 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => { {cliStatus.binaryPath && cliStatus.launchError - ? 'Claude Code was found but failed to start. Open the Dashboard to repair or reinstall it.' - : 'Claude Code is not installed. Install it from the Dashboard to enable all features.'} + ? `The configured ${cliStatus.displayName} runtime was found but failed to start. Open the Dashboard to repair or reinstall it.` + : `The configured ${cliStatus.displayName} runtime is not installed. Install it from the Dashboard to enable all features.`} - ) : shouldShowProviderConnectAction(provider) ? ( + ) : !showSkeleton && shouldShowProviderConnectAction(provider) ? ( - {!showSkeleton && provider.models.length > 0 && ( + {!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && (
{ const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; - const visibleCliProviders = useMemo( - () => filterMainScreenCliProviders(cliStatus?.providers ?? []), - [cliStatus?.providers] + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + isElectron && + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + includeRateLimits: true, + }); + const visibleCliProviders = useMemo( + () => + filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot) + : provider + ), + [loadingCliStatus?.providers, codexAccount.snapshot] + ); + const loadingCliProviderMap = useMemo( + () => + new Map( + filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => [ + provider.providerId, + provider, + ]) + ), + [loadingCliStatus?.providers] + ); + const codexSnapshotPending = + codexAccount.loading && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && + !codexAccount.snapshot; + const effectiveCliStatus = useMemo( + () => + loadingCliStatus + ? { + ...loadingCliStatus, + providers: visibleCliProviders, + } + : loadingCliStatus, + [loadingCliStatus, visibleCliProviders] + ); + const renderCliStatus = effectiveCliStatus; + const runtimeDisplayName = getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled); useEffect(() => { if (!isElectron) return; @@ -729,24 +1022,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const interval = setInterval( () => { - void fetchCliStatus(); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); }, 10 * 60 * 1000 ); return () => clearInterval(interval); - }, [isElectron, cliStatus, fetchCliStatus]); + }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]); const handleInstall = useCallback(() => { installCli(); }, [installCli]); const handleRefresh = useCallback(() => { - if (multimodelEnabled) { - void bootstrapCliStatus({ multimodelEnabled: true }); - return; - } - void fetchCliStatus(); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); const handleMultimodelToggle = useCallback( @@ -785,12 +1082,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => { void (async () => { try { await invalidateCliStatus(); - await fetchCliStatus(); + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); } finally { setIsVerifyingAuth(false); } })(); - }, [fetchCliStatus, invalidateCliStatus]); + }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); const handleProviderLogin = useCallback((providerId: CliProviderId) => { setProviderTerminal({ providerId, action: 'login' }); @@ -800,7 +1101,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { (providerId: CliProviderId) => { void (async () => { const provider = - cliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null; + effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null; const disconnectAction = provider ? getProviderDisconnectAction(provider) : null; if (!disconnectAction) { return; @@ -821,7 +1122,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { setProviderTerminal({ providerId, action: 'logout' }); })(); }, - [cliStatus?.providers] + [effectiveCliStatus?.providers] ); const handleProviderManage = useCallback((providerId: CliProviderId) => { @@ -870,29 +1171,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'error') return 'error'; if (installerState === 'completed') return 'success'; if (installerState !== 'idle') return 'info'; - if (!cliStatus) return 'loading'; - if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info'; - if (cliStatus.authStatusChecking) return 'info'; - if (!cliStatus.installed) return 'error'; - if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) { + if (!renderCliStatus) return 'loading'; + if (isCheckingMultimodelStatus(renderCliStatus, visibleCliProviders)) return 'info'; + if (renderCliStatus.authStatusChecking) return 'info'; + if (!renderCliStatus.installed) return 'error'; + if (isMultimodelRuntimeStatus(renderCliStatus) && visibleCliProviders.length === 0) { return 'warning'; } if ( - isMultimodelRuntimeStatus(cliStatus) && + isMultimodelRuntimeStatus(renderCliStatus) && visibleCliProviders.length > 0 && !hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders) ) { return 'warning'; } - if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; - if (cliStatus.updateAvailable) return 'info'; + if (renderCliStatus.installed && !renderCliStatus.authLoggedIn) return 'warning'; + if (renderCliStatus.updateAvailable) return 'info'; return 'success'; }; const variant = getVariant(); const styles = VARIANT_STYLES[variant]; const activeTerminalProvider = providerTerminal - ? (cliStatus?.providers.find( + ? (effectiveCliStatus?.providers.find( (provider) => provider.providerId === providerTerminal.providerId ) ?? null) : null; @@ -903,7 +1204,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { : getProviderTerminalLogoutCommand(activeTerminalProvider) : null; const installedAuxiliaryUi = - cliStatus !== null ? ( + renderCliStatus !== null ? ( <> { : (visibleCliProviders[0]?.providerId ?? 'anthropic') } providerStatusLoading={cliProviderStatusLoading} - disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} + disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} onSelectBackend={handleProviderBackendChange} onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} /> - {providerTerminal && cliStatus.binaryPath && ( + {providerTerminal && renderCliStatus.binaryPath && ( { @@ -948,7 +1249,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ) : null; // ── Loading / fetch error state ──────────────────────────────────────── - if (!cliStatus && installerState === 'idle') { + if (!renderCliStatus && installerState === 'idle') { // Fetch failed — show error with retry if (cliStatusError && !cliStatusLoading) { return ( @@ -988,7 +1289,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { style={{ borderColor: styles.border, backgroundColor: styles.bg }} > - Claude CLI status will be checked in the background. + {runtimeDisplayName} status will be checked in the background. - {cliStatus.supportsSelfUpdate ? ( + {renderCliStatus.supportsSelfUpdate ? ( ) : (

{cliLaunchIssue - ? `The configured ${cliStatus.displayName} runtime failed its startup health check.` - : `The configured ${cliStatus.displayName} runtime was not found.`} + ? `The configured ${runtimeDisplayName} failed its startup health check.` + : `The configured ${runtimeDisplayName} was not found.`}

)}
@@ -1215,17 +1523,19 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed but not logged in — yellow warning banner if ( - cliStatus.installed && - cliStatus.flavor !== 'agent_teams_orchestrator' && - (cliStatus.authStatusChecking || isVerifyingAuth) + renderCliStatus.installed && + renderCliStatus.flavor !== 'agent_teams_orchestrator' && + (renderCliStatus.authStatusChecking || isVerifyingAuth) ) { - if (cliStatus.authStatusChecking || isVerifyingAuth) { + if (renderCliStatus.authStatusChecking || isVerifyingAuth) { return ( <> { } if ( - cliStatus.installed && - cliStatus.flavor !== 'agent_teams_orchestrator' && - !cliStatus.authStatusChecking && - !cliStatus.authLoggedIn + renderCliStatus.installed && + renderCliStatus.flavor !== 'agent_teams_orchestrator' && + !renderCliStatus.authStatusChecking && + !renderCliStatus.authLoggedIn ) { - const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers); + const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders( + renderCliStatus.providers + ); const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0; const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null; const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter( @@ -1272,14 +1584,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => { : apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.` : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.' - : `${cliStatus.displayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`; + : `${runtimeDisplayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`; return ( <> {
  • Open your terminal and run:{' '} - {cliStatus.showBinaryPath && cliStatus.binaryPath - ? `"${cliStatus.binaryPath}" auth status` + {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath + ? `"${renderCliStatus.binaryPath}" auth status` : 'your configured CLI auth status command'} {' '} — check if it shows "Logged in" @@ -1412,25 +1726,25 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
  • If it says logged in but the app doesn't see it, try:{' '} - {cliStatus.showBinaryPath && cliStatus.binaryPath - ? `"${cliStatus.binaryPath}" auth logout` + {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath + ? `"${renderCliStatus.binaryPath}" auth logout` : 'the runtime logout command'} {' '} then{' '} - {cliStatus.showBinaryPath && cliStatus.binaryPath - ? `"${cliStatus.binaryPath}" auth login` + {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath + ? `"${renderCliStatus.binaryPath}" auth login` : 'the runtime login command'} {' '} again
  • Make sure the CLI in your terminal is the same runtime the app uses - {cliStatus.showBinaryPath && cliStatus.binaryPath && ( + {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && ( :{' '} - {cliStatus.binaryPath} + {renderCliStatus.binaryPath} )} @@ -1444,10 +1758,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => { )} {installedAuxiliaryUi} - {showLoginTerminal && cliStatus.binaryPath && ( + {showLoginTerminal && renderCliStatus.binaryPath && ( { setShowLoginTerminal(false); @@ -1493,9 +1807,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { return ( <> { + const isElectron = useMemo(() => isElectronMode(), []); const tabId = useTabIdOptional(); const { fetchPluginCatalog, + bootstrapCliStatus, fetchCliStatus, fetchApiKeys, fetchSkillsCatalog, @@ -113,6 +129,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { cliStatus, cliStatusLoading, cliProviderStatusLoading, + appConfig, openDashboard, sessions, projects, @@ -120,6 +137,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { } = useStore( useShallow((s) => ({ fetchPluginCatalog: s.fetchPluginCatalog, + bootstrapCliStatus: s.bootstrapCliStatus, fetchCliStatus: s.fetchCliStatus, fetchApiKeys: s.fetchApiKeys, fetchSkillsCatalog: s.fetchSkillsCatalog, @@ -132,13 +150,58 @@ export const ExtensionStoreView = (): React.JSX.Element => { cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading, cliProviderStatusLoading: s.cliProviderStatusLoading, + appConfig: s.appConfig, openDashboard: s.openDashboard, sessions: s.sessions, projects: s.projects, repositoryGroups: s.repositoryGroups, })) ); - const cliInstalled = cliStatus?.installed ?? true; + const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + isElectron && + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean( + loadingCliStatus?.providers.some( + (provider: CliProviderStatus) => provider.providerId === 'codex' + ) + ), + includeRateLimits: true, + }); + const codexSnapshotPending = + codexAccount.loading && + Boolean( + loadingCliStatus?.providers.some( + (provider: CliProviderStatus) => provider.providerId === 'codex' + ) + ) && + !codexAccount.snapshot; + const effectiveCliStatus = useMemo( + () => + loadingCliStatus + ? { + ...loadingCliStatus, + providers: loadingCliStatus.providers.map((provider: CliProviderStatus) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot) + : provider + ), + } + : loadingCliStatus, + [loadingCliStatus, codexAccount.snapshot] + ); + const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null; + const runtimeDisplayName = getRuntimeDisplayName(effectiveCliStatus, multimodelEnabled); + const cliInstalled = effectiveCliStatus?.installed ?? true; const hasOngoingSessions = sessions.some((sess) => sess.isOngoing); const extensionsTabProjectId = useStore((s) => tabId @@ -195,8 +258,12 @@ export const ExtensionStoreView = (): React.JSX.Element => { }, [fetchPluginCatalog, projectPath]); useEffect(() => { - void fetchCliStatus(); - }, [fetchCliStatus]); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); // Fetch MCP installed state on mount useEffect(() => { @@ -215,42 +282,55 @@ export const ExtensionStoreView = (): React.JSX.Element => { // Refresh all data (plugins + MCP browse + installed + skills) const handleRefresh = useCallback(() => { - void fetchCliStatus(); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); void fetchApiKeys(); void fetchPluginCatalog(projectPath ?? undefined, true); void mcpBrowse(); // re-fetch first page void mcpFetchInstalled(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined); }, [ + bootstrapCliStatus, fetchApiKeys, fetchCliStatus, fetchPluginCatalog, fetchSkillsCatalog, + multimodelEnabled, mcpBrowse, mcpFetchInstalled, projectPath, ]); const isRefreshing = - cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + effectiveCliStatusLoading || + apiKeysLoading || + pluginCatalogLoading || + mcpBrowseLoading || + skillsLoading; const mcpMutationDisableReason = useMemo( () => getExtensionActionDisableReason({ isInstalled: false, - cliStatus, - cliStatusLoading, + cliStatus: effectiveCliStatus, + cliStatusLoading: effectiveCliStatusLoading, section: 'mcp', }), - [cliStatus, cliStatusLoading] + [effectiveCliStatus, effectiveCliStatusLoading] ); const cliStatusBanner = useMemo(() => { - const providers = cliStatus?.providers ?? []; + const providers = effectiveCliStatus?.providers ?? []; const visibleProviders = getVisibleMultimodelProviders(providers); - const isMultimodel = isMultimodelRuntimeStatus(cliStatus); + const isMultimodel = isMultimodelRuntimeStatus(effectiveCliStatus); const shouldShowMultimodelProviderCards = - isMultimodel && visibleProviders.length > 0 && cliStatus !== null; + isMultimodel && visibleProviders.length > 0 && effectiveCliStatus !== null; - if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) { + if ( + (effectiveCliStatusLoading || effectiveCliStatus === null) && + !shouldShowMultimodelProviderCards + ) { return (
    @@ -267,8 +347,10 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } - if (!cliStatus.installed) { - const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError); + if (!effectiveCliStatus.installed) { + const cliLaunchIssue = Boolean( + effectiveCliStatus.binaryPath && effectiveCliStatus.launchError + ); return (
    @@ -283,9 +365,9 @@ export const ExtensionStoreView = (): React.JSX.Element => { ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.' : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}

    - {cliLaunchIssue && cliStatus.launchError && ( + {cliLaunchIssue && effectiveCliStatus.launchError && (

    - {cliStatus.launchError} + {effectiveCliStatus.launchError}

    )}
    @@ -296,16 +378,18 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } - if (!isMultimodel && !cliStatus.authLoggedIn) { + if (!isMultimodel && !effectiveCliStatus.authLoggedIn) { return (
    -

    Claude CLI needs sign-in

    +

    {runtimeDisplayName} needs sign-in

    - Claude CLI was found - {cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin - installs are disabled until you sign in from the Dashboard. + {runtimeDisplayName} was found + {effectiveCliStatus.installedVersion + ? ` (${effectiveCliStatus.installedVersion})` + : ''} + , but plugin installs are disabled until you sign in from the Dashboard.

    diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 3352fb3c..595b06b4 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -4,9 +4,15 @@ import { useEffect, useMemo, useState } from 'react'; +import { + mergeCodexProviderStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; +import { isElectronMode } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -24,17 +30,56 @@ export const ApiKeysPanel = ({ projectPath, projectLabel, }: ApiKeysPanelProps): React.JSX.Element => { - const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = - useStore( - useShallow((s) => ({ - apiKeys: s.apiKeys, - apiKeysLoading: s.apiKeysLoading, - apiKeysError: s.apiKeysError, - storageStatus: s.apiKeyStorageStatus, - fetchStorageStatus: s.fetchApiKeyStorageStatus, - cliStatus: s.cliStatus, - })) - ); + const isElectron = useMemo(() => isElectronMode(), []); + const { + apiKeys, + apiKeysLoading, + apiKeysError, + storageStatus, + fetchStorageStatus, + cliStatus, + cliStatusLoading, + appConfig, + } = useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + appConfig: s.appConfig, + })) + ); + const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + isElectron && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + }); + const effectiveCliStatus = useMemo( + () => + loadingCliStatus + ? { + ...loadingCliStatus, + providers: loadingCliStatus.providers.map((provider) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot) + : provider + ), + } + : loadingCliStatus, + [loadingCliStatus, codexAccount.snapshot] + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); @@ -60,7 +105,7 @@ export const ApiKeysPanel = ({ const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; const providerKeyCards = useMemo(() => { - if (!cliStatus?.providers?.length) { + if (!effectiveCliStatus?.providers?.length) { return []; } @@ -78,7 +123,9 @@ export const ApiKeysPanel = ({ }, ] as const ).flatMap((item) => { - const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId); + const provider = effectiveCliStatus.providers.find( + (entry) => entry.providerId === item.providerId + ); if (!provider) { return []; } @@ -93,7 +140,7 @@ export const ApiKeysPanel = ({ }, ]; }); - }, [cliStatus]); + }, [effectiveCliStatus]); return (
    diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 78930ad7..653b9ef2 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -17,6 +17,7 @@ import { getExtensionActionDisableReason } from '@shared/utils/extensionNormaliz import { Check, Loader2, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import type { CliInstallationStatus } from '@shared/types'; import type { ExtensionOperationState } from '@shared/types/extensions'; interface InstallButtonProps { @@ -28,6 +29,11 @@ interface InstallButtonProps { disabled?: boolean; size?: 'sm' | 'default'; errorMessage?: string; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } export const InstallButton = ({ @@ -39,13 +45,17 @@ export const InstallButton = ({ disabled, size = 'sm', errorMessage, + cliStatus: cliStatusOverride, + cliStatusLoading: cliStatusLoadingOverride, }: InstallButtonProps) => { - const { cliStatus, cliStatusLoading } = useStore( + const { cliStatus: storedCliStatus, cliStatusLoading: storedCliStatusLoading } = useStore( useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading, })) ); + const cliStatus = cliStatusOverride ?? storedCliStatus; + const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading; const disableReason = getExtensionActionDisableReason({ isInstalled, cliStatus, diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 727d2603..114f9cb8 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -33,6 +33,7 @@ import { } from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; +import type { CliInstallationStatus } from '@shared/types'; import type { McpCustomInstallRequest, McpHeaderDef, @@ -45,6 +46,11 @@ interface CustomMcpServerDialogProps { open: boolean; onClose: () => void; projectPath: string | null; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } type TransportMode = 'stdio' | 'http'; @@ -66,10 +72,14 @@ export const CustomMcpServerDialog = ({ open, onClose, projectPath, + cliStatus: cliStatusOverride, + cliStatusLoading: cliStatusLoadingOverride, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); - const cliStatus = useStore((s) => s.cliStatus); - const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const storedCliStatus = useStore((s) => s.cliStatus); + const storedCliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliStatus = cliStatusOverride ?? storedCliStatus; + const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading; const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const scopeOptions: { value: Scope; label: string }[] = [ { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 10844f74..3e6d0f6e 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -23,6 +23,7 @@ import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; +import type { CliInstallationStatus } from '@shared/types'; import type { InstalledMcpEntry, McpCatalogItem, @@ -37,6 +38,11 @@ interface McpServerCardProps { diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } export const McpServerCard = ({ @@ -47,8 +53,11 @@ export const McpServerCard = ({ diagnostic, diagnosticsLoading, onClick, + cliStatus: cliStatusOverride, + cliStatusLoading, }: McpServerCardProps): React.JSX.Element => { - const cliStatus = useStore((s) => s.cliStatus); + const storedCliStatus = useStore((s) => s.cliStatus); + const cliStatus = cliStatusOverride ?? storedCliStatus; const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const operationKey = getMcpOperationKey(server.id, sharedScope); const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); @@ -262,6 +271,8 @@ export const McpServerCard = ({ state={installProgress} isInstalled={isInstalled} section="mcp" + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} onInstall={() => installMcpServer({ registryId: server.id, diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 97845c01..0cd87512 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -42,6 +42,7 @@ import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; +import type { CliInstallationStatus } from '@shared/types'; import type { InstalledMcpEntry, McpCatalogItem, @@ -59,6 +60,11 @@ interface McpServerDetailDialogProps { projectPath: string | null; open: boolean; onClose: () => void; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } type Scope = 'local' | 'user' | 'project' | 'global'; @@ -73,8 +79,11 @@ export const McpServerDetailDialog = ({ projectPath, open, onClose, + cliStatus: cliStatusOverride, + cliStatusLoading, }: McpServerDetailDialogProps): React.JSX.Element => { - const cliStatus = useStore((s) => s.cliStatus); + const storedCliStatus = useStore((s) => s.cliStatus); + const cliStatus = cliStatusOverride ?? storedCliStatus; const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const [scope, setScope] = useState(defaultSharedScope); const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; @@ -587,6 +596,8 @@ export const McpServerDetailDialog = ({ state={installProgress} isInstalled={isInstalledForScope} section="mcp" + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} onInstall={handleInstall} onUninstall={handleUninstall} disabled={installDisabled} diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index ba03a024..39eb99ee 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -15,6 +15,7 @@ import { } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { formatRelativeTime } from '@renderer/utils/formatters'; +import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { getMcpDiagnosticKey, @@ -30,6 +31,7 @@ import { SearchInput } from '../common/SearchInput'; import { McpServerCard } from './McpServerCard'; import { McpServerDetailDialog } from './McpServerDetailDialog'; +import type { CliInstallationStatus } from '@shared/types'; import type { InstalledMcpEntry, McpCatalogItem, @@ -68,6 +70,17 @@ interface McpServersPanelProps { mcpSearchWarnings: string[]; selectedMcpServerId: string | null; setSelectedMcpServerId: (id: string | null) => void; + cliStatus?: Pick< + CliInstallationStatus, + | 'installed' + | 'authLoggedIn' + | 'binaryPath' + | 'launchError' + | 'flavor' + | 'displayName' + | 'providers' + > | null; + cliStatusLoading?: boolean; } export const McpServersPanel = ({ @@ -79,6 +92,8 @@ export const McpServersPanel = ({ mcpSearchWarnings, selectedMcpServerId, setSelectedMcpServerId, + cliStatus: cliStatusOverride, + cliStatusLoading: cliStatusLoadingOverride, }: McpServersPanelProps): React.JSX.Element => { const projectStateKey = getMcpProjectStateKey(projectPath); const { @@ -99,8 +114,6 @@ export const McpServersPanel = ({ mcpDiagnosticsLastCheckedAtByProjectPath, mcpDiagnosticsLastCheckedAtFallback, runMcpDiagnostics, - cliStatus, - cliStatusLoading, } = useStore( useShallow((s) => ({ browseCatalog: s.mcpBrowseCatalog, @@ -120,10 +133,12 @@ export const McpServersPanel = ({ mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, - cliStatus: s.cliStatus, - cliStatusLoading: s.cliStatusLoading, })) ); + const storedCliStatus = useStore((s) => s.cliStatus); + const storedCliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliStatus = cliStatusOverride ?? storedCliStatus; + const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading; const installedServers = installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; const mcpDiagnostics = @@ -147,12 +162,8 @@ export const McpServersPanel = ({ }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); const diagnosticsDisableReason = useMemo(() => { - if (cliStatusLoading) { - return 'Checking runtime status...'; - } - if (cliStatus === null || typeof cliStatus === 'undefined') { - return 'Checking runtime availability...'; + return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...'; } if (cliStatus?.installed === false) { @@ -241,8 +252,7 @@ export const McpServersPanel = ({ // Sort displayed servers const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]); - const runtimeLabel = - cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI'; + const runtimeLabel = getRuntimeDisplayName(cliStatus, true); // Find selected server (search in both lists to avoid losing selection during search toggle) const selectedServer = useMemo(() => { @@ -411,13 +421,12 @@ export const McpServersPanel = ({

    {cliStatus?.flavor === 'agent_teams_orchestrator' - ? 'Configured runtime not available' - : 'Claude CLI not installed'} + ? `${runtimeLabel} not available` + : `${runtimeLabel} not installed`}

    - {cliStatus?.flavor === 'agent_teams_orchestrator' - ? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.' - : 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'} + MCP health checks require {runtimeLabel}. Go to the Dashboard to install or repair + it.

    @@ -458,6 +467,8 @@ export const McpServersPanel = ({ diagnostic={getDiagnostic(server)} diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} /> ))}
    @@ -488,6 +499,8 @@ export const McpServersPanel = ({ projectPath={projectPath} open={selectedMcpServerId !== null} onClose={() => setSelectedMcpServerId(null)} + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} /> ); diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 0f7230e1..fe1344b2 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -17,15 +17,27 @@ import { Tag } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; +import type { CliInstallationStatus } from '@shared/types'; import type { EnrichedPlugin } from '@shared/types/extensions'; interface PluginCardProps { plugin: EnrichedPlugin; index: number; onClick: (pluginId: string) => void; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } -export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => { +export const PluginCard = ({ + plugin, + index, + onClick, + cliStatus, + cliStatusLoading, +}: PluginCardProps): React.JSX.Element => { const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); const operationKey = getPluginOperationKey(plugin.pluginId, 'user'); @@ -120,6 +132,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J state={installProgress} isInstalled={isUserInstalled} section="plugins" + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')} size="sm" diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 5b4a4274..a4ef3fa2 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -39,6 +39,7 @@ import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; import { SourceBadge } from '../common/SourceBadge'; +import type { CliInstallationStatus } from '@shared/types'; import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions'; interface PluginDetailDialogProps { @@ -46,6 +47,11 @@ interface PluginDetailDialogProps { open: boolean; onClose: () => void; projectPath: string | null; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ @@ -59,6 +65,8 @@ export const PluginDetailDialog = ({ open, onClose, projectPath, + cliStatus, + cliStatusLoading, }: PluginDetailDialogProps): React.JSX.Element => { const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( useShallow((s) => ({ @@ -198,6 +206,8 @@ export const PluginDetailDialog = ({ state={installProgress} isInstalled={isInstalledForScope} section="plugins" + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} onInstall={() => installPlugin({ pluginId: plugin.pluginId, diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 8adde28f..ac47abfc 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -28,6 +28,7 @@ import { CategoryChips } from './CategoryChips'; import { PluginCard } from './PluginCard'; import { PluginDetailDialog } from './PluginDetailDialog'; +import type { CliInstallationStatus } from '@shared/types'; import type { EnrichedPlugin, PluginCapability, @@ -48,6 +49,11 @@ interface PluginsPanelProps { clearFilters: () => void; hasActiveFilters: boolean; setPluginSort: (sort: { field: PluginSortField; order: 'asc' | 'desc' }) => void; + cliStatus?: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' + > | null; + cliStatusLoading?: boolean; } const SORT_OPTIONS: { value: string; label: string }[] = [ @@ -125,8 +131,15 @@ export const PluginsPanel = ({ clearFilters, hasActiveFilters, setPluginSort, + cliStatus: cliStatusOverride, + cliStatusLoading, }: PluginsPanelProps): React.JSX.Element => { - const { catalog, loading, error, cliStatus } = useStore( + const { + catalog, + loading, + error, + cliStatus: storedCliStatus, + } = useStore( useShallow((s) => ({ catalog: s.pluginCatalog, loading: s.pluginCatalogLoading, @@ -134,6 +147,7 @@ export const PluginsPanel = ({ cliStatus: s.cliStatus, })) ); + const cliStatus = cliStatusOverride ?? storedCliStatus; const filtered = useMemo( () => selectFilteredPlugins(catalog, pluginFilters, pluginSort), @@ -192,8 +206,9 @@ export const PluginsPanel = ({ return (
    - In the multimodel runtime, plugins currently apply only to Anthropic sessions. Broader - plugin support across providers is in development. + In the multimodel runtime, plugins are currently guaranteed only for Anthropic + sessions. We are actively building broader plugin support for all agents, including + both universal plugins and agent-specific plugins. {capability.reason ? ` ${capability.reason}` : ''}
    ); @@ -407,6 +422,8 @@ export const PluginsPanel = ({ plugin={plugin} index={index} onClick={setSelectedPluginId} + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} /> ))} @@ -418,6 +435,8 @@ export const PluginsPanel = ({ open={selectedPluginId !== null} onClose={() => setSelectedPluginId(null)} projectPath={projectPath} + cliStatus={cliStatus} + cliStatusLoading={cliStatusLoading} /> ); diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 9c95959d..0ee032da 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -1,11 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { + mergeCodexProviderStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility'; import { getCliProviderExtensionCapability, @@ -149,6 +154,8 @@ export const SkillsPanel = ({ const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false); const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null); const detailById = useStore(useShallow((s) => s.skillsDetailsById)); @@ -167,28 +174,54 @@ export const SkillsPanel = ({ const selectedSkillIdRef = useRef(selectedSkillId); const selectedSkillItemRef = useRef(null); selectedSkillIdRef.current = selectedSkillId; + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + }); + const effectiveCliStatus = useMemo( + () => + loadingCliStatus + ? { + ...loadingCliStatus, + providers: loadingCliStatus.providers.map((provider) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot) + : provider + ), + } + : loadingCliStatus, + [loadingCliStatus, codexAccount.snapshot] + ); const mergedSkills = useMemo( () => [...projectSkills, ...userSkills], [projectSkills, userSkills] ); const codexSkillOverlayAvailable = useMemo( - () => isCodexSkillOverlayAvailable(cliStatus), - [cliStatus] + () => isCodexSkillOverlayAvailable(effectiveCliStatus), + [effectiveCliStatus] ); const skillsAudienceLabel = useMemo(() => { - if (cliStatus?.flavor !== 'agent_teams_orchestrator') { + if (effectiveCliStatus?.flavor !== 'agent_teams_orchestrator') { return null; } - const providerNames = getVisibleMultimodelProviders(cliStatus.providers ?? []) + const providerNames = getVisibleMultimodelProviders(effectiveCliStatus.providers ?? []) .filter((provider) => isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills')) ) .map((provider) => provider.displayName); return formatRuntimeAudienceLabel(providerNames); - }, [cliStatus]); + }, [effectiveCliStatus]); const codexOnlySkillsCount = useMemo( () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, [mergedSkills] @@ -314,7 +347,7 @@ export const SkillsPanel = ({ return (
    - {cliStatus?.flavor === 'agent_teams_orchestrator' && ( + {effectiveCliStatus?.flavor === 'agent_teams_orchestrator' && (
    Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '} {skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index cc293d62..c7e210b5 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -1,5 +1,17 @@ import { useEffect, useMemo, useState } from 'react'; +import { + formatCodexCreditsValue, + formatCodexRemainingPercent, + formatCodexResetWindowLabel, + formatCodexUsageExplanation, + formatCodexUsagePercent, + formatCodexUsageWindowLabel, + formatCodexWindowDurationLong, + mergeCodexProviderStatusWithSnapshot, + normalizeCodexResetTimestamp, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { @@ -30,8 +42,8 @@ import { isConnectionManagedRuntimeProvider, } from './providerConnectionUi'; import { - getVisibleProviderRuntimeBackendOptions, getProviderRuntimeBackendSummary, + getVisibleProviderRuntimeBackendOptions, ProviderRuntimeBackendSelector, } from './ProviderRuntimeBackendSelector'; @@ -39,7 +51,8 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha import type { ApiKeyEntry } from '@shared/types/extensions'; type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini'; -type PendingConnectionAction = 'auto' | 'oauth' | 'api_key' | null; +type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | null; + interface ConnectionMethodCardOption { readonly authMode: CliProviderAuthMode; readonly title: string; @@ -81,7 +94,7 @@ const API_KEY_PROVIDER_CONFIG: Record< name: 'Codex API Key', title: 'API key', description: - 'Codex native requires API-key credentials. Save OPENAI_API_KEY here and the app will mirror it into the native CODEX_API_KEY environment when launching Codex.', + 'Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.', placeholder: 'sk-proj-...', }, gemini: { @@ -98,13 +111,6 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini'; } -function isCodexNativeLane(provider: CliProviderStatus): boolean { - return ( - provider.providerId === 'codex' && - (provider.selectedBackendId === 'codex-native' || provider.resolvedBackendId === 'codex-native') - ); -} - function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); return matches.find((entry) => entry.scope === 'user') ?? null; @@ -115,7 +121,7 @@ function getConnectionDescription(provider: CliProviderStatus): string { case 'anthropic': return 'Choose how app-launched Anthropic sessions authenticate.'; case 'codex': - return 'Codex launches always use the native runtime now. Manage API-key credentials here before launching teams or one-shot Codex runs.'; + return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.'; case 'gemini': return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.'; } @@ -145,7 +151,16 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider } if (providerId === 'codex') { - return 'Codex always launches through the native runtime and requires API-key credentials.'; + switch (authMode) { + case 'auto': + return 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.'; + case 'chatgpt': + return 'Force native Codex launches to use your connected ChatGPT account and subscription.'; + case 'api_key': + return 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.'; + default: + return ''; + } } return ''; @@ -180,8 +195,51 @@ function getConnectionAlert(provider: CliProviderStatus): string | null { return 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.'; } - if (provider.providerId === 'codex' && !provider.connection?.apiKeyConfigured) { - return 'No OPENAI_API_KEY or CODEX_API_KEY credential is available yet.'; + if (provider.providerId === 'codex') { + const codex = provider.connection?.codex; + if (codex?.login.status === 'starting') { + return 'Starting ChatGPT login...'; + } + + if (codex?.login.status === 'pending') { + return 'Waiting for ChatGPT account login to finish...'; + } + + if (codex?.login.status === 'failed' && codex.login.error) { + return codex.login.error; + } + + if (provider.connection?.configuredAuthMode === 'api_key') { + if (!provider.connection?.apiKeyConfigured) { + return 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.'; + } + return null; + } + + if (provider.connection?.configuredAuthMode === 'chatgpt' && !codex?.managedAccount) { + const missingChatgptMessage = codex?.localActiveChatgptAccountPresent + ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.' + : codex?.localAccountArtifactsPresent + ? 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.' + : 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.'; + return provider.connection.apiKeyConfigured + ? `${missingChatgptMessage} Switch to API key mode to use the detected API key.` + : missingChatgptMessage; + } + + if (!codex?.launchAllowed && codex?.launchIssueMessage) { + return codex.launchIssueMessage; + } + + if (codex?.appServerState === 'degraded' && codex.appServerStatusMessage) { + return codex.appServerStatusMessage; + } + + if (!provider.connection?.apiKeyConfigured && !codex?.managedAccount) { + return 'No ChatGPT account or API key is available yet.'; + } + + return null; } if ( @@ -194,6 +252,147 @@ function getConnectionAlert(provider: CliProviderStatus): string | null { return null; } +function getCodexAccountPanelHint( + provider: CliProviderStatus | null, + configuredAuthMode: CliProviderAuthMode | undefined +): string | null { + if (provider?.providerId !== 'codex') { + return null; + } + + const codex = provider.connection?.codex; + if (!codex || codex.login.status === 'starting' || codex.login.status === 'pending') { + return null; + } + + if (codex.managedAccount?.type === 'chatgpt') { + if (!codex.rateLimits) { + return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.'; + } + + return null; + } + + const usageSentence = codex.localActiveChatgptAccountPresent + ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.' + : codex.localAccountArtifactsPresent + ? 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.' + : 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.'; + if (configuredAuthMode === 'chatgpt' && provider.connection?.apiKeyConfigured) { + return `${usageSentence} The detected API key is only used after you switch Codex to API key mode.`; + } + + if (configuredAuthMode === 'auto' && provider.connection?.apiKeyConfigured) { + return `${usageSentence} Auto will keep using the detected API key until ChatGPT is connected.`; + } + + return usageSentence; +} + +function getCheckingStatusColor(): string { + return 'var(--color-text-secondary)'; +} + +function getProviderStatusColor(statusText: string | null, authenticated: boolean): string { + if (statusText === 'Checking...') { + return getCheckingStatusColor(); + } + + return authenticated ? '#4ade80' : 'var(--color-text-muted)'; +} + +function formatCodexResetDateTime(timestampSeconds: number | null | undefined): string { + const normalized = normalizeCodexResetTimestamp(timestampSeconds); + return normalized ? new Date(normalized).toLocaleString() : 'Unknown'; +} + +function CodexRateLimitWindowCard({ + title, + usedLabel, + usedValue, + remainingValue, + resetLabel, + resetValue, + accent, +}: Readonly<{ + title: string; + usedLabel: string; + usedValue: string; + remainingValue: string; + resetLabel: string; + resetValue: string; + accent: 'primary' | 'secondary'; +}>): React.JSX.Element { + const accentStyles = + accent === 'primary' + ? { + borderColor: 'rgba(74, 222, 128, 0.24)', + backgroundColor: 'rgba(74, 222, 128, 0.05)', + badgeColor: '#86efac', + badgeBackground: 'rgba(74, 222, 128, 0.14)', + } + : { + borderColor: 'rgba(125, 211, 252, 0.22)', + backgroundColor: 'rgba(125, 211, 252, 0.04)', + badgeColor: '#bae6fd', + badgeBackground: 'rgba(125, 211, 252, 0.14)', + }; + + return ( +
    +
    +
    + {title} +
    + + {remainingValue} + +
    + +
    +
    +
    + {usedLabel} +
    +
    + {usedValue} +
    +
    + {remainingValue} left +
    +
    + +
    +
    + {resetLabel} +
    +
    + {resetValue} +
    +
    +
    +
    + ); +} + function getConnectionMethodCardOptions( provider: CliProviderStatus ): ConnectionMethodCardOption[] | null { @@ -217,7 +416,24 @@ function getConnectionMethodCardOptions( }, ]; case 'codex': - return null; + return [ + { + authMode: 'auto', + title: 'Auto', + description: + 'Prefer your ChatGPT account and subscription. Use API key mode only if needed.', + }, + { + authMode: 'chatgpt', + title: 'ChatGPT account', + description: 'Use your connected ChatGPT account and Codex subscription.', + }, + { + authMode: 'api_key', + title: 'API key', + description: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.', + }, + ]; default: return null; } @@ -225,7 +441,7 @@ function getConnectionMethodCardOptions( function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null { if (provider.providerId === 'codex') { - return 'Codex uses saved or environment API-key credentials for the native runtime.'; + return 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.'; } if (provider.providerId === 'anthropic') { @@ -342,6 +558,10 @@ export const ProviderRuntimeSettingsDialog = ({ const deleteApiKey = useStore((s) => s.deleteApiKey); const updateConfig = useStore((s) => s.updateConfig); const appConfig = useStore((s) => s.appConfig); + const codexAccount = useCodexAccountSnapshot({ + enabled: open && selectedProviderId === 'codex', + includeRateLimits: true, + }); useEffect(() => { if (!open) { @@ -374,6 +594,12 @@ export const ProviderRuntimeSettingsDialog = ({ setRuntimeError(null); }, [selectedProviderId]); + useEffect(() => { + if (selectedProviderId === 'codex' && codexAccount.error) { + setConnectionError(codexAccount.error); + } + }, [codexAccount.error, selectedProviderId]); + const statusSelectedProvider = useMemo(() => { return ( providers.find((provider) => provider.providerId === selectedProviderId) ?? @@ -394,18 +620,29 @@ export const ProviderRuntimeSettingsDialog = ({ : null; const selectedProvider = useMemo(() => { - if (!statusSelectedProvider?.connection) { - return statusSelectedProvider; + const mergedStatusProvider = + statusSelectedProvider?.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(statusSelectedProvider, codexAccount.snapshot) + : statusSelectedProvider; + + if (!mergedStatusProvider?.connection) { + return mergedStatusProvider; } const nextConnection = { - ...statusSelectedProvider.connection, + ...mergedStatusProvider.connection, }; - if (statusSelectedProvider.providerId === 'anthropic') { + if (mergedStatusProvider.providerId === 'anthropic') { nextConnection.configuredAuthMode = appConfig?.providerConnections?.anthropic.authMode ?? - statusSelectedProvider.connection.configuredAuthMode; + mergedStatusProvider.connection.configuredAuthMode; + } + + if (mergedStatusProvider.providerId === 'codex') { + nextConnection.configuredAuthMode = + appConfig?.providerConnections?.codex.preferredAuthMode ?? + mergedStatusProvider.connection.configuredAuthMode; } if (statusApiKeyConfig) { @@ -421,11 +658,13 @@ export const ProviderRuntimeSettingsDialog = ({ } return { - ...statusSelectedProvider, + ...mergedStatusProvider, connection: nextConnection, }; }, [ appConfig?.providerConnections?.anthropic.authMode, + appConfig?.providerConnections?.codex.preferredAuthMode, + codexAccount.snapshot, selectedApiKey, statusApiKeyConfig, statusSelectedProvider, @@ -437,6 +676,10 @@ export const ProviderRuntimeSettingsDialog = ({ const runtimeSummary = selectedProvider ? getProviderRuntimeBackendSummary(selectedProvider) : null; + const codexConnection = + selectedProvider?.providerId === 'codex' ? (selectedProvider.connection?.codex ?? null) : null; + const codexLoginPending = + codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending'; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; const configuredAuthMode: CliProviderAuthMode | undefined = selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; @@ -471,19 +714,30 @@ export const ProviderRuntimeSettingsDialog = ({ (selectedProvider?.providerId !== 'codex' || !selectedProvider.connection?.supportsOAuth) ); const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null; - const connectionLoading = selectedProviderLoading || connectionSaving; + const connectionLoading = + selectedProviderLoading || + connectionSaving || + Boolean(selectedProvider?.providerId === 'codex' && codexAccount.loading && !codexConnection); const connectionBusy = disabled || connectionLoading; + const codexActionBusy = + disabled || selectedProviderLoading || connectionSaving || codexAccount.loading; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; const connectionMethodCardsHint = selectedProvider ? getConnectionMethodCardsHint(selectedProvider) : null; + const codexAccountPanelHint = getCodexAccountPanelHint( + selectedProvider ?? null, + configuredAuthMode + ); const hasSubscriptionSession = selectedProvider?.providerId === 'anthropic' ? selectedProvider.authMethod === 'oauth_token' || selectedProvider.authMethod === 'claude.ai' : false; const canRequestSubscriptionLogin = - Boolean(selectedProvider?.connection?.supportsOAuth && onRequestLogin) && + selectedProvider?.providerId === 'anthropic' && + Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) && configuredAuthMode !== 'api_key' && + selectedProvider.statusMessage !== 'Checking...' && (!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth'); let connectionStatusLabel: string | null = null; if (selectedProvider) { @@ -517,6 +771,19 @@ export const ProviderRuntimeSettingsDialog = ({ } } + if (selectedProvider.providerId === 'codex') { + switch (pendingConnectionAction) { + case 'chatgpt': + return 'Switching to ChatGPT account mode...'; + case 'api_key': + return 'Switching to API key mode...'; + case 'auto': + return 'Switching to Auto...'; + default: + return 'Applying connection changes...'; + } + } + return 'Applying connection changes...'; } @@ -601,7 +868,7 @@ export const ProviderRuntimeSettingsDialog = ({ }; const handleAuthModeChange = async (authMode: string): Promise => { - if (selectedProvider?.providerId !== 'anthropic') { + if (selectedProvider?.providerId !== 'anthropic' && selectedProvider?.providerId !== 'codex') { return; } @@ -615,11 +882,21 @@ export const ProviderRuntimeSettingsDialog = ({ setConnectionError(null); let updateSucceeded = false; try { - await updateConfig('providerConnections', { - anthropic: { - authMode: nextAuthMode, - }, - }); + if (selectedProvider.providerId === 'anthropic') { + await updateConfig('providerConnections', { + anthropic: { + authMode: nextAuthMode, + }, + }); + } else if (nextAuthMode !== 'oauth') { + await updateConfig('providerConnections', { + codex: { + preferredAuthMode: nextAuthMode, + }, + }); + await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true }); + } + updateSucceeded = true; } catch (error) { setConnectionError(error instanceof Error ? error.message : 'Failed to update connection'); @@ -637,6 +914,46 @@ export const ProviderRuntimeSettingsDialog = ({ } }; + const handleCodexAccountRefresh = async (): Promise => { + setConnectionError(null); + try { + await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true }); + await onRefreshProvider?.('codex'); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Failed to refresh Codex account' + ); + } + }; + + const handleCodexStartLogin = async (): Promise => { + setConnectionError(null); + const success = await codexAccount.startChatgptLogin(); + if (!success && codexAccount.error) { + setConnectionError(codexAccount.error); + } + }; + + const handleCodexCancelLogin = async (): Promise => { + setConnectionError(null); + const success = await codexAccount.cancelChatgptLogin(); + if (success) { + await onRefreshProvider?.('codex'); + } else if (codexAccount.error) { + setConnectionError(codexAccount.error); + } + }; + + const handleCodexLogout = async (): Promise => { + setConnectionError(null); + const success = await codexAccount.logout(); + if (success) { + await onRefreshProvider?.('codex'); + } else if (codexAccount.error) { + setConnectionError(codexAccount.error); + } + }; + const handleRuntimeBackendSelect = async ( providerId: CliProviderId, backendId: string @@ -712,7 +1029,15 @@ export const ProviderRuntimeSettingsDialog = ({ {selectedProvider.authenticated @@ -863,6 +1188,281 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
    + {selectedProvider.providerId === 'codex' ? ( +
    +
    +
    +
    + ChatGPT account +
    +
    + Manage the local Codex app-server account session that powers + subscription-backed native launches. +
    +
    +
    + + {codexLoginPending ? ( + + ) : codexConnection?.managedAccount?.type === 'chatgpt' ? ( + + ) : ( + + )} +
    +
    + +
    + + {codexConnection?.managedAccount?.type === 'chatgpt' + ? 'Connected' + : codexLoginPending + ? 'Login in progress' + : 'Not connected'} + + {codexConnection ? ( + + App-server: {codexConnection.appServerState} + + ) : null} + {codexConnection?.managedAccount?.planType ? ( + + Plan: {codexConnection.managedAccount.planType} + + ) : null} + {codexConnection?.managedAccount?.email ? ( + + {codexConnection.managedAccount.email} + + ) : null} +
    + + {codexAccountPanelHint ? ( +
    + {codexAccountPanelHint} +
    + ) : null} + + {codexConnection?.rateLimits ? ( +
    +
    + These percentages show used quota, not remaining quota.{' '} + {formatCodexUsageExplanation( + codexConnection.rateLimits.primary?.usedPercent, + codexConnection.rateLimits.primary?.windowDurationMins + )} + {codexConnection.rateLimits.secondary + ? ` Weekly limits are shown separately in the ${ + formatCodexWindowDurationLong( + codexConnection.rateLimits.secondary.windowDurationMins + ) ?? 'secondary' + } window.` + : ''} +
    + +
    +
    + + + {codexConnection.rateLimits.secondary ? ( + + ) : ( +
    +
    + Weekly window +
    +
    + Weekly used (1w) +
    +
    + Not reported +
    +
    + Codex did not return a secondary window for this account snapshot. +
    +
    + )} +
    + +
    +
    +
    +
    + Credits +
    +
    + {formatCodexCreditsValue(codexConnection.rateLimits.credits)} +
    +
    +
    + Credits are shown separately from window-based subscription usage and + may be unavailable for plan-backed ChatGPT sessions. +
    +
    +
    +
    +
    + ) : null} +
    + ) : null} + {showApiKeySection && apiKeyConfig ? (
    = { auto: 'Auto', oauth: 'Subscription / OAuth', + chatgpt: 'ChatGPT account', api_key: 'API key', }; @@ -86,11 +87,53 @@ function getSelectedRuntimeBackendOption( } export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean { - return false; + return provider.providerId === 'codex'; } function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string { - return provider.backend?.label ?? CODEX_NATIVE_LABEL; + return CODEX_NATIVE_LABEL; +} + +function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string | null { + if (provider.providerId !== 'codex' || !provider.connection?.apiKeyConfigured) { + return null; + } + + if (provider.connection.apiKeySource === 'stored') { + return 'Saved API key available in Manage'; + } + + return provider.connection.apiKeySourceLabel ?? 'API key is configured'; +} + +function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): string | null { + if (provider.providerId !== 'codex') { + return null; + } + + const codexConnection = provider.connection?.codex; + if (!codexConnection || codexConnection.managedAccount?.type === 'chatgpt') { + return null; + } + + if (provider.connection?.configuredAuthMode !== 'chatgpt') { + return null; + } + + if (codexConnection.requiresOpenaiAuth) { + if (codexConnection.localActiveChatgptAccountPresent) { + return 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'; + } + + return codexConnection.localAccountArtifactsPresent + ? 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.' + : 'Codex CLI reports no active ChatGPT login'; + } + + return ( + codexConnection.launchIssueMessage ?? + 'Connect a ChatGPT account to use your Codex subscription.' + ); } export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null { @@ -106,6 +149,51 @@ export function formatProviderStatusText(provider: CliProviderStatus): string { const selectedBackendOption = getSelectedRuntimeBackendOption(provider); if (provider.providerId === 'codex') { + if (provider.connection?.codex?.login.status === 'starting') { + return 'Starting ChatGPT login...'; + } + + if (provider.connection?.codex?.login.status === 'pending') { + return 'Waiting for ChatGPT account login...'; + } + + if ( + provider.connection?.codex?.login.status === 'failed' && + provider.connection.codex.login.error + ) { + return provider.connection.codex.login.error; + } + + if ( + provider.connection?.codex?.appServerState === 'degraded' && + provider.connection.codex.effectiveAuthMode === 'chatgpt' && + provider.connection.codex.launchAllowed + ) { + return ( + provider.connection.codex.launchIssueMessage ?? + 'ChatGPT account detected - account verification is currently degraded.' + ); + } + + if (provider.connection?.codex?.launchAllowed) { + if (provider.connection.codex.effectiveAuthMode === 'chatgpt') { + return 'ChatGPT account ready'; + } + + if (provider.connection.codex.effectiveAuthMode === 'api_key') { + return 'API key ready'; + } + } + + const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider); + if (missingManagedAccountStatus) { + return missingManagedAccountStatus; + } + + if (provider.connection?.codex?.launchIssueMessage) { + return provider.connection.codex.launchIssueMessage; + } + if (selectedBackendOption?.statusMessage) { return selectedBackendOption.statusMessage; } @@ -156,11 +244,17 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s return null; } - if (provider.authenticated) { - return null; + if (provider.providerId === 'anthropic') { + if (provider.authenticated) { + return null; + } + + if (provider.connection?.configuredAuthMode === 'auto') { + return null; + } } - if (provider.providerId === 'anthropic' && provider.connection?.configuredAuthMode === 'auto') { + if (provider.providerId === 'codex' && provider.connection?.configuredAuthMode === 'auto') { return null; } @@ -168,7 +262,13 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s provider.providerId, provider.connection?.configuredAuthMode ?? null ); - return authModeLabel ? `Preferred auth: ${authModeLabel}` : null; + if (!authModeLabel) { + return null; + } + + return provider.providerId === 'codex' + ? `Selected auth: ${authModeLabel}` + : `Preferred auth: ${authModeLabel}`; } export function getProviderCredentialSummary(provider: CliProviderStatus): string | null { @@ -197,9 +297,31 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin } if (provider.providerId === 'codex') { - return provider.connection.apiKeySource === 'stored' - ? 'Saved API key available in Manage' - : (provider.connection.apiKeySourceLabel ?? 'API key is configured'); + const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider); + if (!apiKeyAvailabilitySummary) { + return null; + } + + if ( + provider.connection.codex?.managedAccount?.type === 'chatgpt' || + provider.connection.codex?.effectiveAuthMode === 'chatgpt' + ) { + return provider.connection.apiKeySource === 'stored' + ? 'API key also available in Manage as fallback' + : `${apiKeyAvailabilitySummary} - available as fallback`; + } + + if (provider.connection.configuredAuthMode === 'chatgpt') { + return provider.connection.apiKeySource === 'stored' + ? 'Saved API key available in Manage if you switch to API key mode' + : `${apiKeyAvailabilitySummary} - available if you switch to API key mode`; + } + + if (provider.connection.configuredAuthMode === 'auto') { + return `${apiKeyAvailabilitySummary} - Auto will use this until ChatGPT is connected`; + } + + return apiKeyAvailabilitySummary; } return provider.connection.apiKeySourceLabel ?? null; @@ -249,7 +371,7 @@ export function getProviderConnectLabel(provider: CliProviderStatus): string { } if (provider.providerId === 'codex') { - return 'Configure API key'; + return 'Connect ChatGPT'; } if (provider.providerId === 'gemini') { diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 2cc15a1c..a4f27e8e 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -332,7 +332,9 @@ export function useSettingsHandlers({ anthropic: { authMode: 'auto', }, - codex: {}, + codex: { + preferredAuthMode: 'auto', + }, }, runtime: { providerBackends: { diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index d9132190..9122dda6 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -7,6 +7,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + mergeCodexProviderStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; import { isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; @@ -29,6 +33,8 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; +import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; import { AlertTriangle, CheckCircle, @@ -80,6 +86,34 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo ); } +function isCodexSnapshotPending( + provider: CliProviderStatus, + codexSnapshotPending: boolean +): boolean { + return provider.providerId === 'codex' && codexSnapshotPending; +} + +function shouldMaskCodexNegativeBootstrapState( + sourceProvider: CliProviderStatus | null, + mergedProvider: CliProviderStatus +): boolean { + return ( + sourceProvider?.providerId === 'codex' && + sourceProvider.statusMessage === 'Checking...' && + mergedProvider.providerId === 'codex' && + mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' && + mergedProvider.connection.codex.login.status === 'idle' + ); +} + +function getProviderStatusColor(statusText: string, authenticated: boolean): string { + if (statusText === 'Checking...') { + return 'var(--color-text-secondary)'; + } + + return authenticated ? '#4ade80' : 'var(--color-text-muted)'; +} + function getProviderLabel(providerId: CliProviderId): string { switch (providerId) { case 'anthropic': @@ -177,10 +211,43 @@ export const CliStatusSection = (): React.JSX.Element | null => { const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; - const effectiveCliStatus = + const loadingCliStatus = !cliStatus && cliStatusLoading && multimodelEnabled ? createLoadingMultimodelCliStatus() : cliStatus; + const codexAccount = useCodexAccountSnapshot({ + enabled: + isElectron && + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + includeRateLimits: true, + }); + const codexSnapshotPending = + codexAccount.loading && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && + !codexAccount.snapshot; + const effectiveCliStatus = useMemo( + () => + loadingCliStatus + ? { + ...loadingCliStatus, + providers: loadingCliStatus.providers.map((provider) => + provider.providerId === 'codex' + ? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot) + : provider + ), + } + : loadingCliStatus, + [codexAccount.snapshot, loadingCliStatus] + ); + const loadingCliProviderMap = useMemo( + () => + new Map( + (loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider]) + ), + [loadingCliStatus?.providers] + ); const canOpenExtensions = effectiveCliStatus?.installed === true; const showInstalledControls = effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed'); @@ -202,8 +269,12 @@ export const CliStatusSection = (): React.JSX.Element | null => { }, [installCli]); const handleRefresh = useCallback(() => { - void fetchCliStatus(); - }, [fetchCliStatus]); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); const handleProviderLogout = useCallback( async (providerId: CliProviderId) => { @@ -242,9 +313,13 @@ export const CliStatusSection = (): React.JSX.Element | null => { const recheckStatus = useCallback(() => { void (async () => { await invalidateCliStatus(); - await fetchCliStatus(); + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); })(); - }, [fetchCliStatus, invalidateCliStatus]); + }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); const handleMultimodelToggle = useCallback( async (enabled: boolean) => { @@ -306,14 +381,15 @@ export const CliStatusSection = (): React.JSX.Element | null => { if (!isElectron) return null; + const runtimeDisplayName = getRuntimeDisplayName(effectiveCliStatus, multimodelEnabled); const runtimeLabel = effectiveCliStatus?.flavor === 'agent_teams_orchestrator' ? null : effectiveCliStatus && effectiveCliStatus.showVersionDetails && effectiveCliStatus.installedVersion - ? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` - : (effectiveCliStatus?.displayName ?? 'Claude CLI'); + ? `${runtimeDisplayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` + : runtimeDisplayName; const activeTerminalProvider = providerTerminal ? (effectiveCliStatus?.providers.find( @@ -463,11 +539,22 @@ export const CliStatusSection = (): React.JSX.Element | null => { {(() => { const providerLoading = cliProviderStatusLoading[provider.providerId] === true; - const showSkeleton = isProviderCardLoading(provider, providerLoading); + const showSkeleton = + isProviderCardLoading(provider, providerLoading) || + isCodexSnapshotPending(provider, codexSnapshotPending); const runtimeSummary = isConnectionManagedRuntimeProvider(provider) ? getProviderCurrentRuntimeSummary(provider) : getProviderRuntimeBackendSummary(provider); - const statusText = formatProviderStatusText(provider); + const sourceProvider = + loadingCliProviderMap.get(provider.providerId) ?? null; + const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState( + sourceProvider, + provider + ); + const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState; + const statusText = effectiveShowSkeleton + ? 'Checking...' + : formatProviderStatusText(provider); const connectionModeSummary = getProviderConnectionModeSummary(provider); const credentialSummary = getProviderCredentialSummary(provider); const disconnectAction = getProviderDisconnectAction(provider); @@ -498,15 +585,16 @@ export const CliStatusSection = (): React.JSX.Element | null => { {statusText}
    - {showSkeleton ? ( + {effectiveShowSkeleton ? ( ) : hasDetailContent ? (
    { {disconnectAction.label} - ) : shouldShowProviderConnectAction(provider) ? ( + ) : !effectiveShowSkeleton && + shouldShowProviderConnectAction(provider) ? (
    - {!showSkeleton && provider.models.length > 0 && ( + {!effectiveShowSkeleton && provider.models.length > 0 && (
    {
    {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError - ? 'Claude CLI was found but failed to start' - : 'Claude CLI not installed'} + ? `${runtimeDisplayName} was found but failed to start` + : `${runtimeDisplayName} not installed`}
    {effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
    @@ -664,16 +753,16 @@ export const CliStatusSection = (): React.JSX.Element | null => { > {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError - ? 'Reinstall Claude CLI' - : 'Install Claude CLI'} + ? `Reinstall ${runtimeDisplayName}` + : `Install ${runtimeDisplayName}`}
    )} {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (

    {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError - ? `The configured ${effectiveCliStatus.displayName} runtime failed its startup health check.` - : `The configured ${effectiveCliStatus.displayName} runtime was not found.`} + ? `The configured ${runtimeDisplayName} failed its startup health check.` + : `The configured ${runtimeDisplayName} was not found.`}

    )}
    @@ -779,9 +868,9 @@ export const CliStatusSection = (): React.JSX.Element | null => { {providerTerminal && cliStatus?.binaryPath && ( { const elapsed = useElapsedTimer(startedAt, loading); - const [logsOpen, setLogsOpen] = useState( - () => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading) - ); + const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; @@ -192,29 +190,6 @@ export const ProvisioningProgressBlock = ({ } }, [isError, cliLogsTail]); - // Open CLI logs while loading, collapse when done (unless error). - const prevLoadingRef = useRef(loading); - const hadLogsRef = useRef(Boolean(cliLogsTail)); - useEffect(() => { - if (!isError) { - const hasLogs = Boolean(cliLogsTail); - - if (loading && hasLogs && !hadLogsRef.current) { - // Logs just appeared while loading → open - setLogsOpen(true); - } else if (loading && !prevLoadingRef.current && hasLogs) { - // Started loading with logs already present → open - setLogsOpen(true); - } else if (!loading && prevLoadingRef.current) { - // Finished loading → collapse - setLogsOpen(false); - } - - hadLogsRef.current = hasLogs; - } - prevLoadingRef.current = loading; - }, [loading, cliLogsTail, isError]); - return (
    s.appConfig?.general?.multimodelEnabled ?? true); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus); + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + }); + const effectiveCliStatus = useMemo( + () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), + [loadingCliStatus, codexAccount.snapshot] + ); // ── Persisted draft state (survives tab navigation) ────────────────── const { @@ -508,7 +532,9 @@ export const CreateTeamDialog = ({ }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( + const entries: (readonly [TeamProviderId, string | null])[] = ( + effectiveCliStatus?.providers ?? [] + ).map( (provider) => [ provider.providerId as TeamProviderId, @@ -516,7 +542,7 @@ export const CreateTeamDialog = ({ ] as const ); return new Map(entries); - }, [cliStatus?.providers]); + }, [effectiveCliStatus?.providers]); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const prepareChecksRef = useRef([]); const prepareModelResultsCacheRef = useRef( @@ -556,8 +582,12 @@ export const CreateTeamDialog = ({ if (!open || cliStatus || cliStatusLoading) { return; } - void fetchCliStatus(); - }, [open, cliStatus, cliStatusLoading, fetchCliStatus]); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); useEffect(() => { if (!open || !canCreate || !launchTeam) { @@ -961,9 +991,11 @@ export const CreateTeamDialog = ({ const runtimeProviderStatusById = useMemo( () => new Map( - (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + (effectiveCliStatus?.providers ?? []).map( + (provider) => [provider.providerId, provider] as const + ) ), - [cliStatus?.providers] + [effectiveCliStatus?.providers] ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 06f0eb65..6189bfe1 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,5 +1,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + mergeCodexCliStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; import { api } from '@renderer/api'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { @@ -36,6 +40,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { isTeamProvisioningActive, selectResolvedMembersForTeamName, @@ -46,6 +51,7 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity'; +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getTeamModelSelectionError, @@ -249,9 +255,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus); const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch'; const isRelaunch = props.mode === 'relaunch'; + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + const codexAccount = useCodexAccountSnapshot({ + enabled: + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + }); + const effectiveCliStatus = useMemo( + () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), + [loadingCliStatus, codexAccount.snapshot] + ); const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? (props.schedule ?? null) : null; const isEditing = isSchedule && !!schedule; @@ -385,7 +409,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( + const entries: (readonly [TeamProviderId, string | null])[] = ( + effectiveCliStatus?.providers ?? [] + ).map( (provider) => [ provider.providerId as TeamProviderId, @@ -393,7 +419,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ] as const ); return new Map(entries); - }, [cliStatus?.providers]); + }, [effectiveCliStatus?.providers]); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const prepareChecksRef = useRef([]); const prepareModelResultsCacheRef = useRef( @@ -414,9 +440,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const runtimeProviderStatusById = useMemo( () => new Map( - (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + (effectiveCliStatus?.providers ?? []).map( + (provider) => [provider.providerId, provider] as const + ) ), - [cliStatus?.providers] + [effectiveCliStatus?.providers] ); useEffect(() => { @@ -442,8 +470,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (!open || cliStatus || cliStatusLoading) { return; } - void fetchCliStatus(); - }, [open, cliStatus, cliStatusLoading, fetchCliStatus]); + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 55fae00d..524bf149 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,5 +1,9 @@ import React, { useEffect, useMemo } from 'react'; +import { + mergeCodexCliStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Label } from '@renderer/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -10,6 +14,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { useStore } from '@renderer/store'; import { GEMINI_UI_DISABLED_BADGE_LABEL, @@ -136,10 +141,26 @@ export const TeamModelSelector: React.FC = ({ const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); - const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator'; + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); const effectiveProviderId = disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; + const codexAccount = useCodexAccountSnapshot({ + enabled: multimodelEnabled && effectiveProviderId === 'codex', + }); + const effectiveCliStatus = useMemo( + () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), + [codexAccount.snapshot, loadingCliStatus] + ); + const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null; + const multimodelAvailable = + multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.'; @@ -190,12 +211,14 @@ export const TeamModelSelector: React.FC = ({ }; const runtimeProviderStatus = useMemo( () => - cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null, - [cliStatus?.providers, effectiveProviderId] + effectiveCliStatus?.providers.find( + (provider) => provider.providerId === effectiveProviderId + ) ?? null, + [effectiveCliStatus?.providers, effectiveProviderId] ); const shouldAwaitRuntimeModelList = effectiveProviderId !== 'anthropic' && - (cliStatus == null || cliStatusLoading) && + (effectiveCliStatus == null || effectiveCliStatusLoading) && runtimeProviderStatus == null; const normalizedValue = normalizeTeamModelForUi( effectiveProviderId, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 5ebcc535..0de210fa 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -6,6 +6,7 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, @@ -1424,13 +1425,21 @@ export function initializeNotificationListeners(): () => void { break; } case 'completed': + { + const multimodelEnabled = + useStore.getState().appConfig?.general?.multimodelEnabled ?? true; + void refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus: useStore.getState().bootstrapCliStatus, + fetchCliStatus: useStore.getState().fetchCliStatus, + }); + } useStore.setState({ cliInstallerState: 'completed', cliCompletedVersion: progress.version ?? null, cliInstallerDetail: null, }); // Re-fetch status after install and auto-revert to idle after 3s - void useStore.getState().fetchCliStatus(); cliCompletedRevertTimer = setTimeout(() => { cliCompletedRevertTimer = null; // Only revert if still in 'completed' state (not overwritten by a new install) diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 6f066468..3b571e9b 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -44,7 +44,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { return { flavor: 'agent_teams_orchestrator', - displayName: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', supportsSelfUpdate: false, showVersionDetails: false, showBinaryPath: false, @@ -61,6 +61,24 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { }; } +function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean { + if (!provider) { + return false; + } + + return !( + provider.supported === false && + provider.authenticated === false && + provider.authMethod === null && + provider.verificationState === 'unknown' && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null && + (provider.availableBackends?.length ?? 0) === 0 && + provider.connection == null + ); +} + // ============================================================================= // Slice Interface // ============================================================================= @@ -164,6 +182,18 @@ export const createCliInstallerSlice: StateCreator [ + providerId, + !isHydratedMultimodelProviderStatus( + metadata.providers.find((provider) => provider.providerId === providerId) + ), + ]) + ) as Partial>; + const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( + (providerId) => nextProviderLoading[providerId] === true + ); + set((state) => { if (epoch !== cliStatusEpoch || !state.cliStatus) { return {}; @@ -171,37 +201,37 @@ export const createCliInstallerSlice: StateCreator provider.statusMessage === 'Checking...' - ), - providers: metadata.installed ? state.cliStatus.providers : metadata.providers, + authStatusChecking: metadata.installed && pendingProviderIds.length > 0, }, + cliStatusLoading: false, + cliProviderStatusLoading: nextProviderLoading, }; }); if (!metadata.installed) { if (epoch === cliStatusEpoch) { set({ - cliStatusLoading: false, cliProviderStatusLoading: {}, }); } return; } + + if (pendingProviderIds.length === 0) { + return; + } + + await Promise.allSettled( + pendingProviderIds.map((providerId) => + get().fetchCliProviderStatus(providerId, { + silent: false, + epoch, + }) + ) + ); + return; } catch (error) { logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error); } diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 3543bcd6..9b182339 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,6 +4,7 @@ */ import { api } from '@renderer/api'; +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { getExtensionActionDisableReason, getMcpDiagnosticKey, @@ -345,6 +346,26 @@ function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEn const SUCCESS_DISPLAY_MS = 2_000; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; + +function refreshConfiguredCliStatus( + state: Pick +): Promise { + return refreshCliStatusForCurrentMode({ + multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true, + bootstrapCliStatus: state.bootstrapCliStatus, + fetchCliStatus: state.fetchCliStatus, + }); +} + +function getExtensionActionCliStatusState( + state: Pick +): Pick[0], 'cliStatus' | 'cliStatusLoading'> { + return { + cliStatus: state.cliStatus, + cliStatusLoading: state.cliStatus === null && state.cliStatusLoading, + }; +} + export const createExtensionsSlice: StateCreator = ( set, get @@ -886,22 +907,21 @@ export const createExtensionsSlice: StateCreator ({ apiKeys: prev.apiKeys.filter((k) => k.id !== id), })); - await get().fetchCliStatus(); + await refreshConfiguredCliStatus(get()); const refreshError = get().cliStatusError; set({ apiKeysError: refreshError diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 9151f6b2..e0d46594 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -11,6 +11,21 @@ import type { TeamProviderId, } from '@shared/types'; +function normalizeMemberBackendLabel( + providerId: TeamProviderId, + backendLabel: string | undefined +): string | undefined { + if (!backendLabel) { + return undefined; + } + + if (providerId === 'codex' && backendLabel === 'Codex native') { + return 'Codex'; + } + + return backendLabel; +} + function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { if (!spawnEntry) { return false; @@ -35,9 +50,9 @@ export function resolveMemberRuntimeSummary( const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredEffort = member.effort ?? launchParams?.effort; const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); - const backendLabel = formatTeamProviderBackendLabel( + const backendLabel = normalizeMemberBackendLabel( configuredProvider, - launchParams?.providerBackendId + formatTeamProviderBackendLabel(configuredProvider, launchParams?.providerBackendId) ); const memorySuffix = typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 diff --git a/src/renderer/utils/refreshCliStatus.ts b/src/renderer/utils/refreshCliStatus.ts new file mode 100644 index 00000000..52b0d357 --- /dev/null +++ b/src/renderer/utils/refreshCliStatus.ts @@ -0,0 +1,17 @@ +interface RefreshCliStatusOptions { + multimodelEnabled: boolean; + bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; + fetchCliStatus: () => Promise; +} + +export function refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, +}: RefreshCliStatusOptions): Promise { + if (multimodelEnabled) { + return bootstrapCliStatus({ multimodelEnabled: true }); + } + + return fetchCliStatus(); +} diff --git a/src/renderer/utils/runtimeDisplayName.ts b/src/renderer/utils/runtimeDisplayName.ts new file mode 100644 index 00000000..6cd0798f --- /dev/null +++ b/src/renderer/utils/runtimeDisplayName.ts @@ -0,0 +1,26 @@ +import type { CliFlavor, CliInstallationStatus } from '@shared/types'; + +const MULTIMODEL_RUNTIME_LABEL = 'Multimodel runtime'; + +export function getRuntimeDisplayName( + cliStatus: Pick | null | undefined, + multimodelEnabledFallback = false +): string { + if (cliStatus?.flavor === 'agent_teams_orchestrator') { + if (!cliStatus.displayName || cliStatus.displayName === 'agent_teams_orchestrator') { + return MULTIMODEL_RUNTIME_LABEL; + } + + return cliStatus.displayName; + } + + if (cliStatus?.displayName) { + return cliStatus.displayName; + } + + return multimodelEnabledFallback ? MULTIMODEL_RUNTIME_LABEL : 'Claude CLI'; +} + +export function getRuntimeCommandLabel(flavor: CliFlavor): string { + return flavor === 'agent_teams_orchestrator' ? MULTIMODEL_RUNTIME_LABEL : 'Claude CLI'; +} diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index ad89fb92..94f72c49 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -323,8 +323,14 @@ export function sortTeamProviderModels( export function isCodexChatGptSubscriptionProviderStatus( providerStatus?: RuntimeAwareProviderStatus | null ): boolean { - void providerStatus; - return false; + if (providerStatus?.providerId !== 'codex') { + return false; + } + + return ( + providerStatus.authMethod === 'chatgpt' || + providerStatus.backend?.authMethodDetail === 'chatgpt' + ); } function isRuntimeHiddenTeamModel( diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 3d45b7d8..17d32f53 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -318,7 +318,7 @@ export function buildTeamProvisioningPresentation({ panelMessage: failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message, panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, - defaultLiveOutputOpen: true, + defaultLiveOutputOpen: false, compactTitle: 'Launching team', compactDetail: failedSpawnCount > 0 diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 0a9ca719..30ff59bc 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -107,6 +107,7 @@ import type { SessionsPaginationOptions, SubagentDetail, } from '@main/types'; +import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; // ============================================================================= // Cost Calculation Types @@ -732,7 +733,7 @@ export interface ReviewAPI { /** * Complete Electron API exposed to the renderer process via preload script. */ -export interface ElectronAPI extends RecentProjectsElectronApi { +export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi { getAppVersion: () => Promise; getProjects: () => Promise; getSessions: (projectId: string) => Promise; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index b435030f..4f5a4fb5 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -4,6 +4,16 @@ * Used for detecting, downloading, verifying, and installing Claude Code CLI binary. */ +import type { + CodexAccountAppServerState, + CodexAccountAuthMode, + CodexAccountEffectiveAuthMode, + CodexLoginStateDto, + CodexLaunchReadinessState, + CodexManagedAccountDto, + CodexRateLimitSnapshotDto, +} from '@features/codex-account/contracts'; + // ============================================================================= // Platform Detection // ============================================================================= @@ -24,7 +34,7 @@ export type CliPlatform = export type CliFlavor = 'claude' | 'agent_teams_orchestrator'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; -export type CliProviderAuthMode = 'auto' | 'oauth' | 'api_key'; +export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key'; export interface CliProviderConnectionInfo { supportsOAuth: boolean; @@ -34,6 +44,21 @@ export interface CliProviderConnectionInfo { apiKeyConfigured: boolean; apiKeySource: 'stored' | 'environment' | null; apiKeySourceLabel?: string | null; + codex?: { + preferredAuthMode: CodexAccountAuthMode; + effectiveAuthMode: CodexAccountEffectiveAuthMode; + appServerState: CodexAccountAppServerState; + appServerStatusMessage: string | null; + managedAccount: CodexManagedAccountDto | null; + requiresOpenaiAuth: boolean | null; + localAccountArtifactsPresent?: boolean; + localActiveChatgptAccountPresent?: boolean; + login: CodexLoginStateDto; + rateLimits: CodexRateLimitSnapshotDto | null; + launchAllowed: boolean; + launchIssueMessage: string | null; + launchReadinessState: CodexLaunchReadinessState; + } | null; } export interface CliProviderBackendOption { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 0188e735..3adcda4f 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -328,7 +328,9 @@ export interface AppConfig { anthropic: { authMode: 'auto' | 'oauth' | 'api_key'; }; - codex: Record; + codex: { + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; + }; }; /** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */ runtime: { diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index a00f616a..8015599c 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -237,12 +237,8 @@ export function getExtensionActionDisableReason(options: { section?: 'plugins' | 'mcp'; }): string | null { const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options; - if (cliStatusLoading) { - return 'Checking runtime status...'; - } - if (cliStatus === null) { - return 'Checking runtime availability...'; + return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...'; } if (cliStatus.installed === false) { diff --git a/test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts b/test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts new file mode 100644 index 00000000..41d75e09 --- /dev/null +++ b/test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { evaluateCodexLaunchReadiness } from '@features/codex-account/core/domain/evaluateCodexLaunchReadiness'; + +describe('evaluateCodexLaunchReadiness', () => { + it('prefers a managed ChatGPT account in auto mode when both auth sources are available', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'auto', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'plus', + }, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + appServerState: 'healthy', + appServerStatusMessage: null, + }); + + expect(readiness).toEqual({ + state: 'ready_both', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + issueMessage: null, + }); + }); + + it('blocks launch when ChatGPT account mode is selected but no managed account is connected', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'chatgpt', + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + appServerState: 'healthy', + appServerStatusMessage: null, + }); + + expect(readiness.state).toBe('missing_auth'); + expect(readiness.effectiveAuthMode).toBeNull(); + expect(readiness.launchAllowed).toBe(false); + expect(readiness.issueMessage).toContain('Connect a ChatGPT account'); + }); + + it('asks for reconnect instead of a fresh login when a locally selected ChatGPT account already exists', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'chatgpt', + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + appServerState: 'healthy', + appServerStatusMessage: null, + localActiveChatgptAccountPresent: true, + }); + + expect(readiness.state).toBe('missing_auth'); + expect(readiness.effectiveAuthMode).toBeNull(); + expect(readiness.launchAllowed).toBe(false); + expect(readiness.issueMessage).toContain('Reconnect ChatGPT'); + }); + + it('allows API-key mode when an API key is available', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'api_key', + managedAccount: null, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + appServerState: 'healthy', + appServerStatusMessage: null, + }); + + expect(readiness).toEqual({ + state: 'ready_api_key', + effectiveAuthMode: 'api_key', + launchAllowed: true, + issueMessage: null, + }); + }); + + it('surfaces degraded-but-launchable state when the managed account is still usable', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'auto', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + appServerState: 'degraded', + appServerStatusMessage: 'Temporary app-server probe failure', + }); + + expect(readiness).toEqual({ + state: 'warning_degraded_but_launchable', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + issueMessage: 'Temporary app-server probe failure', + }); + }); + + it('fails fast when the Codex runtime is missing entirely', () => { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: 'auto', + managedAccount: null, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + appServerState: 'runtime-missing', + appServerStatusMessage: 'Codex CLI not found', + }); + + expect(readiness).toEqual({ + state: 'runtime_missing', + effectiveAuthMode: null, + launchAllowed: false, + issueMessage: 'Codex CLI not found', + }); + }); +}); diff --git a/test/features/codex-account/main/CodexAccountEnvBuilder.test.ts b/test/features/codex-account/main/CodexAccountEnvBuilder.test.ts new file mode 100644 index 00000000..de22161f --- /dev/null +++ b/test/features/codex-account/main/CodexAccountEnvBuilder.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder'; + +describe('CodexAccountEnvBuilder', () => { + it('strips provider-routing flags and API keys from the control-plane env', () => { + const builder = new CodexAccountEnvBuilder(); + + const env = builder.buildControlPlaneEnv({ + env: { + HOME: '/Users/tester', + USERPROFILE: '/Users/tester', + OPENAI_API_KEY: 'openai-key', + CODEX_API_KEY: 'codex-key', + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + }, + shellEnv: { + PATH: '/usr/local/bin', + }, + }); + + expect(env.HOME).toBe('/Users/tester'); + expect(env.USERPROFILE).toBe('/Users/tester'); + expect(env.PATH).toBe('/usr/local/bin'); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.CODEX_API_KEY).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); + expect(env.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined(); + }); + + it('removes API keys from execution env when ChatGPT mode is selected', () => { + const builder = new CodexAccountEnvBuilder(); + + const env = builder.applyExecutionAuthPolicy( + { + OPENAI_API_KEY: 'openai-key', + CODEX_API_KEY: 'codex-key', + }, + { + effectiveAuthMode: 'chatgpt', + } + ); + + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.CODEX_API_KEY).toBeUndefined(); + }); + + it('injects both OPENAI_API_KEY and CODEX_API_KEY in API-key mode', () => { + const builder = new CodexAccountEnvBuilder(); + + const env = builder.applyExecutionAuthPolicy( + {}, + { + effectiveAuthMode: 'api_key', + apiKeyValue: 'stored-key', + } + ); + + expect(env.OPENAI_API_KEY).toBe('stored-key'); + expect(env.CODEX_API_KEY).toBe('stored-key'); + }); +}); diff --git a/test/features/codex-account/main/CodexLoginSessionManager.test.ts b/test/features/codex-account/main/CodexLoginSessionManager.test.ts new file mode 100644 index 00000000..c71ba3dd --- /dev/null +++ b/test/features/codex-account/main/CodexLoginSessionManager.test.ts @@ -0,0 +1,237 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const openExternalMock = vi.fn<(url: string) => Promise>(); + +vi.mock('electron', () => ({ + shell: { + openExternal: (url: string) => openExternalMock(url), + }, +})); + +import { CodexLoginSessionManager } from '@features/codex-account/main/infrastructure/CodexLoginSessionManager'; + +import type { CodexAppServerSession } from '@main/services/infrastructure/codexAppServer'; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +function createSession(overrides?: { + request?: ReturnType; + close?: ReturnType; +}) { + const listeners = new Set<(method: string, params: unknown) => void>(); + const request = + overrides?.request ?? + vi.fn().mockResolvedValue({ + type: 'chatgpt', + loginId: 'login-1', + authUrl: 'https://chatgpt.com/auth', + }); + const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined); + + const session = { + initializeResponse: { + userAgent: 'codex-test', + codexHome: '/Users/tester/.codex', + platformFamily: 'darwin', + platformOs: 'macos', + }, + request, + notify: vi.fn().mockResolvedValue(undefined), + onNotification: vi.fn((listener: (method: string, params: unknown) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + close, + } satisfies CodexAppServerSession; + + return { + session, + request, + close, + emitNotification(method: string, params: unknown) { + for (const listener of listeners) { + listener(method, params); + } + }, + }; +} + +describe('CodexLoginSessionManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + openExternalMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('ignores duplicate start requests while the first login session is still starting', async () => { + const deferredSession = createDeferred(); + const sessionFactory = { + openSession: vi.fn(() => deferredSession.promise), + }; + const manager = new CodexLoginSessionManager(sessionFactory as never, { + warn: vi.fn(), + }); + + const firstStart = manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + const secondStart = manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + + expect(sessionFactory.openSession).toHaveBeenCalledTimes(1); + + const fakeSession = createSession(); + deferredSession.resolve(fakeSession.session); + + await Promise.all([firstStart, secondStart]); + + expect(fakeSession.request).toHaveBeenCalledTimes(1); + expect(openExternalMock).toHaveBeenCalledTimes(1); + expect(manager.getState().status).toBe('pending'); + }); + + it('cancels a login cleanly while the app-server session is still starting', async () => { + const deferredSession = createDeferred(); + const sessionFactory = { + openSession: vi.fn(() => deferredSession.promise), + }; + const settledListener = vi.fn(); + const manager = new CodexLoginSessionManager(sessionFactory as never, { + warn: vi.fn(), + }); + manager.onSettled(settledListener); + + const startPromise = manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + + await manager.cancel(); + + const fakeSession = createSession(); + deferredSession.resolve(fakeSession.session); + await startPromise; + + expect(fakeSession.request).not.toHaveBeenCalled(); + expect(fakeSession.close).toHaveBeenCalledTimes(1); + expect(openExternalMock).not.toHaveBeenCalled(); + expect(settledListener).toHaveBeenCalledTimes(1); + expect(manager.getState()).toEqual({ + status: 'cancelled', + error: null, + startedAt: null, + }); + }); + + it('returns to idle after a successful login completion notification', async () => { + const fakeSession = createSession(); + const sessionFactory = { + openSession: vi.fn().mockResolvedValue(fakeSession.session), + }; + const settledListener = vi.fn(); + const manager = new CodexLoginSessionManager(sessionFactory as never, { + warn: vi.fn(), + }); + manager.onSettled(settledListener); + + await manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + + expect(manager.getState().status).toBe('pending'); + + fakeSession.emitNotification('account/login/completed', { + loginId: 'login-1', + success: true, + error: null, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(fakeSession.close).toHaveBeenCalledTimes(1); + expect(settledListener).toHaveBeenCalledTimes(1); + expect(manager.getState()).toEqual({ + status: 'idle', + error: null, + startedAt: null, + }); + }); + + it('marks the login as failed when the pending login times out', async () => { + vi.useFakeTimers(); + + const fakeSession = createSession(); + const sessionFactory = { + openSession: vi.fn().mockResolvedValue(fakeSession.session), + }; + const settledListener = vi.fn(); + const manager = new CodexLoginSessionManager(sessionFactory as never, { + warn: vi.fn(), + }); + manager.onSettled(settledListener); + + await manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1_000); + await Promise.resolve(); + await Promise.resolve(); + + expect(fakeSession.close).toHaveBeenCalledTimes(1); + expect(settledListener).toHaveBeenCalledTimes(1); + expect(manager.getState()).toMatchObject({ + status: 'failed', + error: 'Timed out while waiting for ChatGPT account login to finish.', + }); + }); + + it('surfaces failed login completion notifications as a failed state', async () => { + const fakeSession = createSession(); + const sessionFactory = { + openSession: vi.fn().mockResolvedValue(fakeSession.session), + }; + const settledListener = vi.fn(); + const manager = new CodexLoginSessionManager(sessionFactory as never, { + warn: vi.fn(), + }); + manager.onSettled(settledListener); + + await manager.start({ + binaryPath: '/usr/local/bin/codex', + env: {}, + }); + + fakeSession.emitNotification('account/login/completed', { + loginId: 'login-1', + success: false, + error: 'ChatGPT login was denied.', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(fakeSession.close).toHaveBeenCalledTimes(1); + expect(settledListener).toHaveBeenCalledTimes(1); + expect(manager.getState()).toMatchObject({ + status: 'failed', + error: 'ChatGPT login was denied.', + }); + }); +}); diff --git a/test/features/codex-account/main/createCodexAccountFeature.live.test.ts b/test/features/codex-account/main/createCodexAccountFeature.live.test.ts new file mode 100644 index 00000000..1841b5d1 --- /dev/null +++ b/test/features/codex-account/main/createCodexAccountFeature.live.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { createCodexAccountFeature } from '../../../../src/features/codex-account/main/composition/createCodexAccountFeature'; +import { detectCodexLocalAccountState } from '../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts'; + +const describeLive = process.env.LIVE_CODEX_ACCOUNT_SMOKE === '1' ? describe : describe.skip; + +describeLive('createCodexAccountFeature live smoke', () => { + it('classifies the current local Codex account state consistently with local account artifacts', async () => { + const localState = await detectCodexLocalAccountState(); + const feature = createCodexAccountFeature({ + logger: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + configManager: { + getConfig: () => ({ + providerConnections: { + codex: { + preferredAuthMode: 'chatgpt' as const, + }, + }, + }), + }, + }); + + try { + const snapshot = await feature.refreshSnapshot({ + includeRateLimits: true, + forceRefreshToken: true, + }); + + expect(snapshot.localAccountArtifactsPresent).toBe(localState.hasArtifacts); + expect(snapshot.localActiveChatgptAccountPresent).toBe( + localState.hasActiveChatgptAccount + ); + + if (localState.hasActiveChatgptAccount && snapshot.managedAccount?.type !== 'chatgpt') { + expect(snapshot.launchAllowed).toBe(false); + expect(snapshot.launchIssueMessage).toContain('Reconnect ChatGPT'); + } + + if (snapshot.managedAccount?.type === 'chatgpt') { + expect(snapshot.effectiveAuthMode).toBe('chatgpt'); + } + } finally { + await feature.dispose(); + } + }); +}); diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts new file mode 100644 index 00000000..0d0b847b --- /dev/null +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -0,0 +1,604 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createCodexAccountFeature } from '../../../../src/features/codex-account/main/composition/createCodexAccountFeature'; + +import type { + CodexAccountLoginStatus, + CodexAccountSnapshotDto, + CodexLoginStateDto, +} from '@features/codex-account/contracts'; + +const { + apiKeyLookupMock, + binaryResolveMock, + detectLocalAccountStateMock, + getCachedShellEnvMock, + loginCancelMock, + loginDisposeMock, + loginSettledListeners, + loginStartMock, + loginStateContainer, + loginStateListeners, + logoutMock, + readAccountMock, + readRateLimitsMock, +} = vi.hoisted(() => ({ + binaryResolveMock: vi.fn(), + apiKeyLookupMock: vi.fn(), + detectLocalAccountStateMock: vi.fn(), + getCachedShellEnvMock: vi.fn(), + readAccountMock: vi.fn(), + readRateLimitsMock: vi.fn(), + logoutMock: vi.fn(), + loginStartMock: vi.fn(), + loginCancelMock: vi.fn(), + loginDisposeMock: vi.fn(), + loginStateContainer: { + current: { + status: 'idle' as CodexAccountLoginStatus, + error: null as string | null, + startedAt: null as string | null, + }, + }, + loginStateListeners: new Set<() => void>(), + loginSettledListeners: new Set<() => void>(), +})); + +const originalOpenAiApiKey = process.env.OPENAI_API_KEY; +const originalCodexApiKey = process.env.CODEX_API_KEY; + +function emitLoginState(nextState: CodexLoginStateDto): void { + loginStateContainer.current = structuredClone(nextState); + for (const listener of loginStateListeners) { + listener(); + } +} + +vi.mock('../../../../src/main/services/extensions', () => ({ + ApiKeyService: class MockApiKeyService { + lookupPreferred = apiKeyLookupMock; + }, +})); + +vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedShellEnv: getCachedShellEnvMock, + }; +}); + +vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({ + CodexBinaryResolver: { + resolve: binaryResolveMock, + }, + CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {}, + JsonRpcStdioClient: class MockJsonRpcStdioClient {}, +})); + +vi.mock( + '../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts', + () => ({ + detectCodexLocalAccountState: detectLocalAccountStateMock, + detectCodexLocalAccountArtifacts: async () => + (await detectLocalAccountStateMock()).hasArtifacts, + }) +); + +vi.mock( + '../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient', + () => ({ + CodexAccountAppServerClient: class MockCodexAccountAppServerClient { + readAccount = readAccountMock; + readRateLimits = readRateLimitsMock; + logout = logoutMock; + }, + }) +); + +vi.mock( + '../../../../src/features/codex-account/main/infrastructure/CodexLoginSessionManager', + () => ({ + CodexLoginSessionManager: class MockCodexLoginSessionManager { + subscribe(listener: () => void): () => void { + loginStateListeners.add(listener); + return (): void => { + loginStateListeners.delete(listener); + }; + } + + onSettled(listener: () => void): () => void { + loginSettledListeners.add(listener); + return (): void => { + loginSettledListeners.delete(listener); + }; + } + + getState(): CodexLoginStateDto { + return structuredClone(loginStateContainer.current); + } + + async start(): Promise { + await loginStartMock(); + } + + async cancel(): Promise { + await loginCancelMock(); + } + + async dispose(): Promise { + await loginDisposeMock(); + } + }, + }) +); + +function createLoggerPort() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createConfigManager(preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' = 'auto') { + return { + getConfig: () => ({ + providerConnections: { + codex: { + preferredAuthMode, + }, + }, + }), + }; +} + +function createAccountResponse(overrides?: Partial<{ + requiresOpenaiAuth: boolean; + account: { type: 'chatgpt'; email: string; planType: 'pro' | 'plus' } | null; +}>) { + return { + account: + overrides && 'account' in overrides + ? overrides.account ?? null + : { + type: 'chatgpt' as const, + email: 'user@example.com', + planType: 'pro' as const, + }, + requiresOpenaiAuth: overrides?.requiresOpenaiAuth ?? true, + }; +} + +function createRateLimitsResponse() { + return { + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { + usedPercent: 77, + windowDurationMins: 300, + resetsAt: 1_776_678_034, + }, + secondary: null, + credits: { + hasCredits: false, + unlimited: false, + balance: '0', + }, + planType: 'pro' as const, + }, + rateLimitsByLimitId: null, + }; +} + +describe('createCodexAccountFeature', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.OPENAI_API_KEY; + delete process.env.CODEX_API_KEY; + binaryResolveMock.mockResolvedValue('/usr/local/bin/codex'); + apiKeyLookupMock.mockResolvedValue(null); + detectLocalAccountStateMock.mockResolvedValue({ + hasArtifacts: false, + hasActiveChatgptAccount: false, + }); + getCachedShellEnvMock.mockReturnValue({}); + readAccountMock.mockReset(); + readRateLimitsMock.mockReset(); + logoutMock.mockReset(); + loginStartMock.mockReset(); + loginCancelMock.mockReset(); + loginDisposeMock.mockReset(); + loginStateContainer.current = { + status: 'idle', + error: null, + startedAt: null, + }; + loginStateListeners.clear(); + loginSettledListeners.clear(); + }); + + afterAll(() => { + if (typeof originalOpenAiApiKey === 'string') { + process.env.OPENAI_API_KEY = originalOpenAiApiKey; + } else { + delete process.env.OPENAI_API_KEY; + } + + if (typeof originalCodexApiKey === 'string') { + process.env.CODEX_API_KEY = originalCodexApiKey; + } else { + delete process.env.CODEX_API_KEY; + } + }); + + it('builds a healthy snapshot from app-server account truth, API-key availability, and rate limits', async () => { + getCachedShellEnvMock.mockReturnValue({ + OPENAI_API_KEY: 'env-openai-key', + }); + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('auto'), + }); + + try { + const snapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + + expect(snapshot).toMatchObject>({ + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + launchAllowed: true, + launchReadinessState: 'ready_both', + }); + expect(snapshot.rateLimits?.planType).toBe('pro'); + expect(snapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(readAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ + binaryPath: '/usr/local/bin/codex', + refreshToken: false, + }) + ); + expect(readRateLimitsMock).toHaveBeenCalledTimes(1); + } finally { + await feature.dispose(); + } + }); + + it('keeps the last known managed account during a transient degraded read', async () => { + readAccountMock + .mockResolvedValueOnce({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }) + .mockRejectedValueOnce(new Error('temporary app-server timeout')); + + const logger = createLoggerPort(); + const feature = createCodexAccountFeature({ + logger, + configManager: createConfigManager('chatgpt'), + }); + + try { + const firstSnapshot = await feature.refreshSnapshot(); + const degradedSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true }); + + expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(degradedSnapshot.appServerState).toBe('degraded'); + expect(degradedSnapshot.appServerStatusMessage).toContain('temporary app-server timeout'); + expect(degradedSnapshot.managedAccount).toMatchObject({ + type: 'chatgpt', + email: 'user@example.com', + }); + expect(degradedSnapshot.launchAllowed).toBe(true); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('false logout'), + expect.anything() + ); + } finally { + await feature.dispose(); + } + }); + + it('keeps the last known ChatGPT managed account during a transient empty account read after HMR-style reconnect flicker', async () => { + detectLocalAccountStateMock.mockResolvedValue({ + hasArtifacts: true, + hasActiveChatgptAccount: true, + }); + readAccountMock + .mockResolvedValueOnce({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }) + .mockResolvedValueOnce({ + account: createAccountResponse({ account: null, requiresOpenaiAuth: true }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const firstSnapshot = await feature.refreshSnapshot(); + const secondSnapshot = await feature.refreshSnapshot(); + + expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(secondSnapshot.managedAccount).toMatchObject({ + type: 'chatgpt', + email: 'user@example.com', + }); + expect(secondSnapshot.launchAllowed).toBe(true); + expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt'); + expect(secondSnapshot.launchIssueMessage).toBeNull(); + } finally { + await feature.dispose(); + } + }); + + it('classifies a locally selected ChatGPT account without a usable managed session as reconnect-needed', async () => { + detectLocalAccountStateMock.mockResolvedValue({ + hasArtifacts: true, + hasActiveChatgptAccount: true, + }); + readAccountMock.mockResolvedValue({ + account: createAccountResponse({ account: null, requiresOpenaiAuth: true }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const snapshot = await feature.refreshSnapshot(); + + expect(snapshot.localAccountArtifactsPresent).toBe(true); + expect(snapshot.localActiveChatgptAccountPresent).toBe(true); + expect(snapshot.launchAllowed).toBe(false); + expect(snapshot.launchReadinessState).toBe('missing_auth'); + expect(snapshot.launchIssueMessage).toContain('Reconnect ChatGPT'); + } finally { + await feature.dispose(); + } + }); + + it('runs a stronger queued refresh after a passive read is already in flight', async () => { + let resolveFirstRead: ((value: unknown) => void) | null = null; + readAccountMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRead = resolve; + }) + ) + .mockResolvedValueOnce({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('auto'), + }); + + try { + const firstRefresh = feature.refreshSnapshot(); + const strongerRefresh = feature.refreshSnapshot({ + includeRateLimits: true, + forceRefreshToken: true, + }); + + await vi.waitFor(() => { + expect(resolveFirstRead).not.toBeNull(); + }); + + const completeFirstRead = resolveFirstRead as ((value: unknown) => void) | null; + if (!completeFirstRead) { + throw new Error('Expected the first account read to remain pending.'); + } + + completeFirstRead({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const [firstSnapshot, strongerSnapshot] = await Promise.all([firstRefresh, strongerRefresh]); + + expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(strongerSnapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(readAccountMock).toHaveBeenCalledTimes(2); + expect(readAccountMock.mock.calls[0]?.[0]).toMatchObject({ + refreshToken: false, + }); + expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({ + refreshToken: true, + }); + expect(readRateLimitsMock).toHaveBeenCalledTimes(1); + } finally { + await feature.dispose(); + } + }); + + it('logs out and refreshes to the new logged-out truth instead of keeping stale account state', async () => { + readAccountMock + .mockResolvedValueOnce({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }) + .mockResolvedValueOnce({ + account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + logoutMock.mockResolvedValue({}); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const initialSnapshot = await feature.refreshSnapshot(); + const afterLogout = await feature.logout(); + + expect(initialSnapshot.managedAccount?.type).toBe('chatgpt'); + expect(logoutMock).toHaveBeenCalledTimes(1); + expect(afterLogout.managedAccount).toBeNull(); + expect(afterLogout.requiresOpenaiAuth).toBe(false); + expect(afterLogout.launchAllowed).toBe(false); + expect(afterLogout.launchReadinessState).toBe('missing_auth'); + expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ + refreshToken: true, + }); + } finally { + await feature.dispose(); + } + }); + + it('publishes the pending login state immediately after login start without waiting for a full refresh', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + loginStartMock.mockImplementation(() => { + emitLoginState({ + status: 'pending', + error: null, + startedAt: '2026-04-20T12:00:00.000Z', + }); + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + await feature.refreshSnapshot(); + const pendingSnapshot = await feature.startChatgptLogin(); + + expect(pendingSnapshot.login).toMatchObject({ + status: 'pending', + startedAt: '2026-04-20T12:00:00.000Z', + }); + expect(loginStartMock).toHaveBeenCalledTimes(1); + } finally { + await feature.dispose(); + } + }); + + it('publishes a cancelled login snapshot immediately and then forces a settled refresh', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + emitLoginState({ + status: 'pending', + error: null, + startedAt: '2026-04-20T12:00:00.000Z', + }); + loginCancelMock.mockImplementation(() => { + emitLoginState({ + status: 'cancelled', + error: null, + startedAt: null, + }); + for (const listener of loginSettledListeners) { + listener(); + } + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + await feature.refreshSnapshot(); + const cancelledSnapshot = await feature.cancelLogin(); + + expect(loginCancelMock).toHaveBeenCalledTimes(1); + expect(cancelledSnapshot.login).toMatchObject({ + status: 'cancelled', + error: null, + startedAt: null, + }); + + await vi.waitFor(() => { + expect( + readAccountMock.mock.calls.some( + (call) => (call[0] as { refreshToken?: boolean } | undefined)?.refreshToken === true + ) + ).toBe(true); + }); + } finally { + await feature.dispose(); + } + }); +}); diff --git a/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts b/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts new file mode 100644 index 00000000..82dff65d --- /dev/null +++ b/test/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + detectCodexLocalAccountArtifacts, + detectCodexLocalAccountState, +} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts'; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'codex-artifacts-')); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +function encodeAccountKeyForAuthFilename(accountKey: string): string { + return Buffer.from(accountKey, 'utf8') + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, ''); +} + +describe('detectCodexLocalAccountArtifacts', () => { + it('returns true when the Codex accounts registry exists', async () => { + const accountsDir = await makeTempDir(); + await writeFile(path.join(accountsDir, 'registry.json'), '{}', 'utf8'); + + await expect(detectCodexLocalAccountArtifacts(accountsDir)).resolves.toBe(true); + }); + + it('returns true when auth artifacts exist without a registry file', async () => { + const accountsDir = await makeTempDir(); + await writeFile(path.join(accountsDir, 'chatgpt.auth.json'), '{}', 'utf8'); + + await expect(detectCodexLocalAccountArtifacts(accountsDir)).resolves.toBe(true); + }); + + it('returns false when the accounts directory is missing or empty', async () => { + const missingDir = path.join(await makeTempDir(), 'missing'); + const emptyDir = await makeTempDir(); + await mkdir(emptyDir, { recursive: true }); + + await expect(detectCodexLocalAccountArtifacts(missingDir)).resolves.toBe(false); + await expect(detectCodexLocalAccountArtifacts(emptyDir)).resolves.toBe(false); + }); + + it('detects a locally selected ChatGPT account from the registry and active auth file', async () => { + const accountsDir = await makeTempDir(); + const activeAccountKey = 'user-test::chatgpt-account'; + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: activeAccountKey }), + 'utf8' + ); + await writeFile( + path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), + JSON.stringify({ auth_mode: 'chatgpt' }), + 'utf8' + ); + + await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({ + hasArtifacts: true, + hasActiveChatgptAccount: true, + }); + }); + + it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => { + const accountsDir = await makeTempDir(); + await writeFile( + path.join(accountsDir, 'registry.json'), + JSON.stringify({ active_account_key: 'user-test::missing-auth' }), + 'utf8' + ); + + await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({ + hasArtifacts: true, + hasActiveChatgptAccount: false, + }); + }); +}); diff --git a/test/features/codex-account/preload/createCodexAccountBridge.test.ts b/test/features/codex-account/preload/createCodexAccountBridge.test.ts new file mode 100644 index 00000000..ee522cc3 --- /dev/null +++ b/test/features/codex-account/preload/createCodexAccountBridge.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, + CODEX_ACCOUNT_GET_SNAPSHOT, + CODEX_ACCOUNT_LOGOUT, + CODEX_ACCOUNT_REFRESH_SNAPSHOT, + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + CODEX_ACCOUNT_START_CHATGPT_LOGIN, +} from '../../../../src/features/codex-account/contracts'; +import { createCodexAccountBridge } from '../../../../src/features/codex-account/preload/createCodexAccountBridge'; + +describe('createCodexAccountBridge', () => { + it('forwards Codex account IPC requests through raw ipcRenderer.invoke and returns raw payloads', async () => { + const snapshot = { ok: true }; + const ipcRenderer = { + invoke: vi.fn().mockResolvedValue(snapshot), + on: vi.fn(), + removeListener: vi.fn(), + }; + const bridge = createCodexAccountBridge({ + ipcRenderer: ipcRenderer as never, + }); + + const refreshOptions = { + includeRateLimits: true, + forceRefreshToken: true, + }; + + await expect(bridge.getCodexAccountSnapshot()).resolves.toBe(snapshot); + await expect(bridge.refreshCodexAccountSnapshot(refreshOptions)).resolves.toBe(snapshot); + await expect(bridge.startCodexChatgptLogin()).resolves.toBe(snapshot); + await expect(bridge.cancelCodexChatgptLogin()).resolves.toBe(snapshot); + await expect(bridge.logoutCodexAccount()).resolves.toBe(snapshot); + + expect(ipcRenderer.invoke.mock.calls).toEqual([ + [CODEX_ACCOUNT_GET_SNAPSHOT], + [CODEX_ACCOUNT_REFRESH_SNAPSHOT, refreshOptions], + [CODEX_ACCOUNT_START_CHATGPT_LOGIN], + [CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN], + [CODEX_ACCOUNT_LOGOUT], + ]); + }); + + it('subscribes and unsubscribes from Codex snapshot change notifications', () => { + const ipcRenderer = { + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + }; + const bridge = createCodexAccountBridge({ + ipcRenderer: ipcRenderer as never, + }); + const callback = vi.fn(); + + const unsubscribe = bridge.onCodexAccountSnapshotChanged(callback); + + expect(ipcRenderer.on).toHaveBeenCalledWith(CODEX_ACCOUNT_SNAPSHOT_CHANGED, callback); + + unsubscribe(); + + expect(ipcRenderer.removeListener).toHaveBeenCalledWith( + CODEX_ACCOUNT_SNAPSHOT_CHANGED, + callback + ); + }); +}); diff --git a/test/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.test.ts b/test/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.test.ts new file mode 100644 index 00000000..a59f6aea --- /dev/null +++ b/test/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeCodexCliStatusWithSnapshot } from '../../../../src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot'; +import { createDefaultCliExtensionCapabilities } from '../../../../src/shared/utils/providerExtensionCapabilities'; + +import type { CodexAccountSnapshotDto } from '../../../../src/features/codex-account/contracts'; +import type { CliInstallationStatus } from '../../../../src/shared/types'; + +function createCliStatus(): CliInstallationStatus { + return { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: true, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/usr/local/bin/agent-teams', + launchError: null, + latestVersion: null, + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: null, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'Connected', + models: ['claude-opus-4-7'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + }, + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: 'Checking...', + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: 'codex-native', + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + }, + ], + }; +} + +function createChatgptSnapshot(): CodexAccountSnapshotDto { + return { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T12:00:00.000Z', + }; +} + +describe('mergeCodexCliStatusWithSnapshot', () => { + it('updates only the codex provider while preserving the rest of the runtime status', () => { + const merged = mergeCodexCliStatusWithSnapshot(createCliStatus(), createChatgptSnapshot()); + + expect(merged?.providers[0]?.providerId).toBe('anthropic'); + expect(merged?.providers[0]?.statusMessage).toBe('Connected'); + expect(merged?.providers[1]?.providerId).toBe('codex'); + expect(merged?.providers[1]?.authMethod).toBe('chatgpt'); + expect(merged?.providers[1]?.statusMessage).toBe('ChatGPT account ready'); + expect(merged?.providers[1]?.backend?.authMethodDetail).toBe('chatgpt'); + expect(merged?.providers[1]?.models).toEqual(['gpt-5.4', 'gpt-5.1-codex-max']); + }); +}); diff --git a/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts b/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts new file mode 100644 index 00000000..269fc3dc --- /dev/null +++ b/test/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeCodexProviderStatusWithSnapshot } from '../../../../src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot'; +import { createDefaultCliExtensionCapabilities } from '../../../../src/shared/utils/providerExtensionCapabilities'; + +import type { CodexAccountSnapshotDto } from '../../../../src/features/codex-account/contracts'; +import type { CliProviderStatus } from '../../../../src/shared/types'; + +function createBaseCodexProvider(): CliProviderStatus { + return { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + models: ['gpt-5.4'], + modelAvailability: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: 'codex-native', + resolvedBackendId: null, + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use codex exec JSON mode.', + selectable: true, + recommended: true, + available: false, + state: 'authentication-required', + audience: 'general', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + detailMessage: null, + }, + ], + externalRuntimeDiagnostics: [], + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + projectId: null, + authMethodDetail: null, + }, + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }, + }; +} + +function createReadyChatgptSnapshot(): CodexAccountSnapshotDto { + return { + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'plan-pro', + limitName: 'Pro', + primary: { + usedPercent: 5, + windowDurationMins: 300, + resetsAt: 1_762_547_200, + }, + secondary: { + usedPercent: 41, + windowDurationMins: 10_080, + resetsAt: 1_762_891_200, + }, + credits: { + hasCredits: false, + unlimited: false, + balance: null, + }, + planType: 'pro', + }, + updatedAt: '2026-04-20T12:00:00.000Z', + }; +} + +describe('mergeCodexProviderStatusWithSnapshot', () => { + it('upgrades stale codex provider auth/runtime state from the live snapshot', () => { + const merged = mergeCodexProviderStatusWithSnapshot( + createBaseCodexProvider(), + createReadyChatgptSnapshot() + ); + + expect(merged.authenticated).toBe(true); + expect(merged.authMethod).toBe('chatgpt'); + expect(merged.statusMessage).toBe('ChatGPT account ready'); + expect(merged.resolvedBackendId).toBe('codex-native'); + expect(merged.connection?.codex?.managedAccount?.email).toBe('belief@example.com'); + expect(merged.connection?.codex?.rateLimits?.primary?.usedPercent).toBe(5); + expect(merged.connection?.codex?.localAccountArtifactsPresent).toBe(true); + expect(merged.connection?.codex?.localActiveChatgptAccountPresent).toBe(true); + expect(merged.availableBackends?.find((option) => option.id === 'codex-native')).toMatchObject({ + available: true, + selectable: true, + state: 'ready', + statusMessage: 'Ready', + }); + }); + + it('hydrates codex connection truth even when the stale provider payload had no connection block', () => { + const merged = mergeCodexProviderStatusWithSnapshot( + { + ...createBaseCodexProvider(), + connection: null, + }, + createReadyChatgptSnapshot() + ); + + expect(merged.authenticated).toBe(true); + expect(merged.statusMessage).toBe('ChatGPT account ready'); + expect(merged.connection).toMatchObject({ + supportsOAuth: false, + supportsApiKey: true, + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + }); + expect(merged.connection?.codex?.managedAccount?.planType).toBe('pro'); + }); + + it('promotes stale bootstrap placeholders out of the unsupported state once live Codex snapshot truth arrives', () => { + const merged = mergeCodexProviderStatusWithSnapshot( + { + ...createBaseCodexProvider(), + supported: false, + statusMessage: 'Checking...', + models: [], + backend: null, + connection: null, + }, + { + ...createReadyChatgptSnapshot(), + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + managedAccount: null, + } + ); + + expect(merged.supported).toBe(true); + expect(merged.statusMessage).toBe('Connect a ChatGPT account to use your Codex subscription.'); + }); + + it('normalizes stale legacy backend truth back to codex-native even when the live snapshot is reconnect-needed', () => { + const merged = mergeCodexProviderStatusWithSnapshot( + { + ...createBaseCodexProvider(), + selectedBackendId: 'auto', + resolvedBackendId: 'api', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'legacy adapter', + projectId: null, + authMethodDetail: null, + }, + }, + { + ...createReadyChatgptSnapshot(), + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + managedAccount: null, + requiresOpenaiAuth: true, + } + ); + + expect(merged.selectedBackendId).toBe('codex-native'); + expect(merged.resolvedBackendId).toBe('codex-native'); + expect(merged.backend).toMatchObject({ + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + }); + }); +}); diff --git a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts new file mode 100644 index 00000000..53f27dcc --- /dev/null +++ b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts @@ -0,0 +1,244 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + +const apiMocks = vi.hoisted(() => ({ + getCodexAccountSnapshot: vi.fn(), + refreshCodexAccountSnapshot: vi.fn(), + startCodexChatgptLogin: vi.fn(), + cancelCodexChatgptLogin: vi.fn(), + logoutCodexAccount: vi.fn(), + onCodexAccountSnapshotChanged: vi.fn(() => () => undefined), +})); + +vi.mock('@renderer/api', () => ({ + api: apiMocks, + isElectronMode: () => true, +})); + +function createSnapshot(): CodexAccountSnapshotDto { + return { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { + usedPercent: 77, + windowDurationMins: 300, + resetsAt: 1_776_678_034, + }, + secondary: null, + credits: { + hasCredits: false, + unlimited: false, + balance: '0', + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +describe('useCodexAccountSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + vi.useRealTimers(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('loads the initial Codex snapshot through refresh when rate limits are requested', async () => { + const snapshot = createSnapshot(); + const refreshDeferred = createDeferred(); + apiMocks.refreshCodexAccountSnapshot.mockReturnValue(refreshDeferred.promise); + apiMocks.getCodexAccountSnapshot.mockResolvedValue(snapshot); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + }); + + return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty'); + } + + await act(async () => { + root.render(React.createElement(Harness)); + refreshDeferred.resolve(snapshot); + await refreshDeferred.promise; + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({ + includeRateLimits: true, + }); + expect(apiMocks.getCodexAccountSnapshot).not.toHaveBeenCalled(); + expect(host.textContent).toContain('belief@example.com'); + + act(() => { + root.unmount(); + }); + }); + + it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => { + vi.useFakeTimers(); + let visibilityState: DocumentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => visibilityState, + }); + + const snapshot = createSnapshot(); + apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + }); + + return React.createElement( + 'div', + { 'data-loading': state.loading ? 'true' : 'false' }, + state.snapshot?.managedAccount?.email ?? 'empty' + ); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false'); + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + + apiMocks.refreshCodexAccountSnapshot.mockClear(); + + await act(async () => { + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false'); + + act(() => { + root.unmount(); + }); + }); + + it('slows background refreshes while hidden and refreshes immediately when the tab becomes visible again after staleness', async () => { + vi.useFakeTimers(); + let visibilityState: DocumentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => visibilityState, + }); + + const snapshot = createSnapshot(); + apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + useCodexAccountSnapshot({ + enabled: true, + includeRateLimits: true, + }); + + return React.createElement('div', null, 'hook-mounted'); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + apiMocks.refreshCodexAccountSnapshot.mockClear(); + + await act(async () => { + visibilityState = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(50_000); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + + apiMocks.refreshCodexAccountSnapshot.mockClear(); + + await act(async () => { + vi.advanceTimersByTime(10_000); + visibilityState = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + }); + + expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts index 3dbc8b74..ab677e9f 100644 --- a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; -import type { JsonRpcSession, JsonRpcStdioClient } from '@features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient'; +import type { + JsonRpcSession, + JsonRpcStdioClient, +} from '@main/services/infrastructure/codexAppServer'; function createSession( request: JsonRpcSession['request'], @@ -11,6 +14,8 @@ function createSession( return { request, notify, + onNotification: vi.fn().mockReturnValue(() => undefined), + close: vi.fn().mockResolvedValue(undefined), }; } diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 49492cca..45d9ed95 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -139,6 +139,57 @@ describe('CliInstallerService', () => { expect(status.installedVersion).toBeNull(); }); + it('retries the version probe once before marking the runtime unhealthy', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + vi.mocked(execCli) + .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version')) + .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' }) + .mockResolvedValueOnce({ + stdout: '{"loggedIn":true,"authMethod":"oauth_token"}', + stderr: '', + }); + + const status = await service.getStatus(); + + expect(status.installed).toBe(true); + expect(status.installedVersion).toBe('2.3.4'); + expect(execCli).toHaveBeenNthCalledWith( + 1, + '/usr/local/bin/claude', + ['--version'], + expect.objectContaining({ timeout: expect.any(Number) }) + ); + expect(execCli).toHaveBeenNthCalledWith( + 2, + '/usr/local/bin/claude', + ['--version'], + expect.objectContaining({ timeout: expect.any(Number) }) + ); + }); + + it('reuses the last healthy runtime snapshot when a later version probe fails transiently', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' }) + .mockResolvedValueOnce({ + stdout: '{"loggedIn":true,"authMethod":"oauth_token"}', + stderr: '', + }) + .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version')) + .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version')); + + const firstStatus = await service.getStatus(); + const secondStatus = await service.getStatus(); + + expect(firstStatus.installed).toBe(true); + expect(firstStatus.installedVersion).toBe('2.3.4'); + expect(secondStatus.installed).toBe(true); + expect(secondStatus.installedVersion).toBe('2.3.4'); + expect(secondStatus.launchError).toBeNull(); + }); + it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => { allowConsoleLogs(); const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd'; diff --git a/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts new file mode 100644 index 00000000..cec72155 --- /dev/null +++ b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('ConfigManager Codex migration hardening', () => { + let tempRoot: string | null = null; + + afterEach(async () => { + if (tempRoot) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + vi.resetModules(); + const pathDecoder = await import('../../../../src/main/utils/pathDecoder'); + pathDecoder.setClaudeBasePathOverride(null); + }); + + it('persists the normalized Codex auth and runtime shape after loading a legacy config', async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-migration-')); + const configPath = path.join(tempRoot, 'claude-devtools-config.json'); + + // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path + fs.writeFileSync( + configPath, + JSON.stringify({ + providerConnections: { + codex: { + authMode: 'oauth', + apiKeyBetaEnabled: true, + }, + }, + runtime: { + providerBackends: { + codex: 'api', + }, + }, + }) + ); + + const { ConfigManager } = await import( + '../../../../src/main/services/infrastructure/ConfigManager' + ); + + const manager = new ConfigManager(configPath); + const config = manager.getConfig(); + + expect(config.providerConnections.codex.preferredAuthMode).toBe('chatgpt'); + expect(config.runtime.providerBackends.codex).toBe('codex-native'); + + await vi.waitFor(() => { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + providerConnections: { codex: Record }; + runtime: { providerBackends: { codex: string } }; + }; + + expect(persisted.providerConnections.codex).toEqual({ + preferredAuthMode: 'chatgpt', + }); + expect(persisted.runtime.providerBackends.codex).toBe('codex-native'); + }); + }); + + it('normalizes legacy Codex runtime backend updates inside ConfigManager updateConfig', async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-runtime-update-')); + const configPath = path.join(tempRoot, 'claude-devtools-config.json'); + + const { ConfigManager } = await import( + '../../../../src/main/services/infrastructure/ConfigManager' + ); + + const manager = new ConfigManager(configPath); + const updated = manager.updateConfig('runtime', { + providerBackends: { + codex: 'api' as never, + }, + } as never); + + expect(updated.runtime.providerBackends.codex).toBe('codex-native'); + + await vi.waitFor(() => { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + runtime: { providerBackends: { codex: string } }; + }; + + expect(persisted.runtime.providerBackends.codex).toBe('codex-native'); + }); + }); +}); diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index fca9b397..b38e5445 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -428,9 +428,9 @@ describe('ClaudeMultimodelBridgeService', () => { expect(codex?.capabilities.extensions.plugins).toMatchObject({ status: 'unsupported', }); - expect(isConnectionManagedRuntimeProvider(codex!)).toBe(false); + expect(isConnectionManagedRuntimeProvider(codex!)).toBe(true); expect(getProviderConnectionModeSummary(codex!)).toBeNull(); - expect(getProviderCurrentRuntimeSummary(codex!)).toBeNull(); + expect(getProviderCurrentRuntimeSummary(codex!)).toBe('Current runtime: Codex native'); }); it('preserves codex-native ready truth from runtime status payloads', async () => { diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index c67a1539..f663faee 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -17,7 +17,9 @@ describe('ProviderConnectionService', () => { anthropic: { authMode, }, - codex: {}, + codex: { + preferredAuthMode: 'auto' as const, + }, }, runtime: { providerBackends: { @@ -174,8 +176,8 @@ describe('ProviderConnectionService', () => { expect(info).toMatchObject({ supportsOAuth: false, supportsApiKey: true, - configurableAuthModes: [], - configuredAuthMode: null, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'auto', apiKeyConfigured: false, apiKeySource: null, apiKeySourceLabel: null, @@ -279,6 +281,209 @@ describe('ProviderConnectionService', () => { expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY'); }); + it('reports a pinned Codex ChatGPT mode as a missing active CLI login instead of flattening it to generic auth advice', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const issue = await service.getConfiguredConnectionIssue( + { + OPENAI_API_KEY: 'env-key', + }, + 'codex' + ); + + expect(issue).toBe( + 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Connect ChatGPT again or switch Codex auth mode to API key.' + ); + }); + + it('mentions local Codex account artifacts when pinned ChatGPT mode has no active managed session', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const issue = await service.getConfiguredConnectionIssue( + { + OPENAI_API_KEY: 'env-key', + }, + 'codex' + ); + + expect(issue).toBe( + 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected. Connect ChatGPT again or switch Codex auth mode to API key.' + ); + }); + + it('asks for reconnect when pinned ChatGPT mode still has a locally selected Codex account', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const issue = await service.getConfiguredConnectionIssue( + { + OPENAI_API_KEY: 'env-key', + }, + 'codex' + ); + + expect(issue).toBe( + 'Codex ChatGPT account mode is selected, and Codex has a locally selected ChatGPT account, but the current session needs reconnect. Reconnect ChatGPT or switch Codex auth mode to API key.' + ); + }); + + it('reports a pinned Codex API-key mode as missing only the API key credential', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'api_key', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Add OPENAI_API_KEY or CODEX_API_KEY to use Codex API key mode.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const issue = await service.getConfiguredConnectionIssue({}, 'codex'); + + expect(issue).toBe( + 'Codex API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available. Add one before launching Codex.' + ); + }); + it('augments PTY env for native Codex without dropping existing OpenAI credentials', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); @@ -302,4 +507,118 @@ describe('ProviderConnectionService', () => { expect(result.OPENAI_API_KEY).toBe('shell-key'); expect(result.CODEX_API_KEY).toBe('shell-key'); }); + + it('returns a chatgpt forced_login_method override for managed Codex launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: undefined, + CODEX_API_KEY: undefined, + }, + 'codex', + undefined, + '/mock/claude-multimodel' + ); + + expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']); + }); + + it('returns an api forced_login_method override for Codex API-key launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }, + 'codex', + undefined, + '/mock/claude-multimodel' + ); + + expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"api"}}']); + }); + + it('keeps codex exec style config overrides for direct Codex binary launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }, + 'codex', + undefined, + '/usr/local/bin/codex' + ); + + expect(args).toEqual(['-c', 'forced_login_method="api"']); + }); }); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 81567dbe..95ec8255 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -9,6 +9,7 @@ const augmentConfiguredConnectionEnvMock = vi.fn(); const applyConfiguredConnectionEnvMock = vi.fn(); const applyAllConfiguredConnectionEnvMock = vi.fn(); const getConfiguredConnectionIssuesMock = vi.fn(); +const getConfiguredConnectionLaunchArgsMock = vi.fn(); vi.mock('@main/utils/cliEnv', () => ({ buildEnrichedEnv: (...args: Parameters) => buildEnrichedEnvMock(...args), @@ -42,6 +43,9 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => applyConfiguredConnectionEnvMock(...args), applyAllConfiguredConnectionEnv: (...args: Parameters) => applyAllConfiguredConnectionEnvMock(...args), + getConfiguredConnectionLaunchArgs: ( + ...args: Parameters + ) => getConfiguredConnectionLaunchArgsMock(...args), getConfiguredConnectionIssues: (...args: Parameters) => getConfiguredConnectionIssuesMock(...args), }, @@ -70,6 +74,7 @@ describe('buildProviderAwareCliEnv', () => { applyAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => Promise.resolve(env) ); + getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]); getConfiguredConnectionIssuesMock.mockResolvedValue({}); }); @@ -104,6 +109,7 @@ describe('buildProviderAwareCliEnv', () => { expect(result.connectionIssues).toEqual({ anthropic: 'missing key', }); + expect(result.providerArgs).toEqual([]); }); it('builds shared env for generic CLI launches when no provider is specified', async () => { @@ -125,6 +131,7 @@ describe('buildProviderAwareCliEnv', () => { }) ); expect(result.connectionIssues).toEqual({}); + expect(result.providerArgs).toEqual([]); }); it('uses non-destructive credential augmentation for PTY-style envs', async () => { @@ -145,6 +152,7 @@ describe('buildProviderAwareCliEnv', () => { }) ); expect(result.connectionIssues).toEqual({}); + expect(result.providerArgs).toEqual([]); }); it('preserves caller-provided HOME and USERPROFILE overrides', async () => { @@ -169,6 +177,7 @@ describe('buildProviderAwareCliEnv', () => { ); expect(result.env.HOME).toBe('/Users/electron-home'); expect(result.env.USERPROFILE).toBe('/Users/electron-home'); + expect(result.providerArgs).toEqual([]); }); it('preserves explicit backend overrides passed by the caller', async () => { @@ -190,6 +199,7 @@ describe('buildProviderAwareCliEnv', () => { ); expect(result.env.CLAUDE_CODE_GEMINI_BACKEND).toBe('api'); expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native'); + expect(result.providerArgs).toEqual([]); }); it('preserves codex-native backend env across provider-aware child env building', async () => { @@ -212,5 +222,34 @@ describe('buildProviderAwareCliEnv', () => { undefined ); expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native'); + expect(result.providerArgs).toEqual([]); + }); + + it('returns provider launch args for strict codex launches', async () => { + getConfiguredConnectionLaunchArgsMock.mockResolvedValue([ + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + ]); + + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv({ + binaryPath: '/mock/claude-multimodel', + providerId: 'codex', + }); + + expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith( + expect.objectContaining({ + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + }), + 'codex', + undefined, + '/mock/claude-multimodel' + ); + expect(result.providerArgs).toEqual([ + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + ]); }); }); diff --git a/test/main/services/schedule/ScheduledTaskExecutor.test.ts b/test/main/services/schedule/ScheduledTaskExecutor.test.ts index 223d6261..c6068134 100644 --- a/test/main/services/schedule/ScheduledTaskExecutor.test.ts +++ b/test/main/services/schedule/ScheduledTaskExecutor.test.ts @@ -89,6 +89,7 @@ describe('ScheduledTaskExecutor', () => { buildProviderAwareCliEnvMock.mockResolvedValue({ env: { ...process.env, SHELL: '/bin/zsh' }, connectionIssues: {}, + providerArgs: [], }); const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor'); @@ -139,6 +140,37 @@ describe('ScheduledTaskExecutor', () => { expect(result.stderr).toBe('Error: something broke'); }); + it('appends provider launch overrides returned by provider-aware env resolution', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { ...process.env, SHELL: '/bin/zsh' }, + connectionIssues: {}, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + }); + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + const resultPromise = executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'Run the tests', + providerId: 'codex', + }, + }) + ); + + await flushAsync(); + + const spawnArgs = mockSpawnCli.mock.calls[0]?.[1] as string[]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']) + ); + + proc.emit('close', 0); + await resultPromise; + }); + it('rejects on process error event', async () => { const proc = createMockProcess(); mockSpawnCli.mockReturnValue(proc); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 9753bce9..d0e3e0e7 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -189,6 +189,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => authSource: 'anthropic_api_key', })); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); @@ -280,6 +281,39 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('forwards codex provider launch overrides into createTeam runtime args', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: {}, + authSource: 'codex_runtime', + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + const { runId } = await svc.createTeam( + { + teamName: 'codex-team', + cwd: process.cwd(), + members: [], + providerId: 'codex', + }, + () => {} + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toEqual( + expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']) + ); + + await svc.cancelProvisioning(runId); + }); + it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'alice', @@ -428,6 +462,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => authSource: 'anthropic_api_key', })); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); @@ -486,6 +521,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => authSource: 'anthropic_api_key', })); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); @@ -569,6 +605,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => })); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); @@ -603,4 +640,64 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + + it('forwards codex provider launch overrides into launchTeam runtime args', async () => { + const teamName = 'codex-launch-forced-login'; + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, + { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, + ], + }), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: {}, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], + source: 'config-fallback', + warning: undefined, + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + + const { runId } = await svc.launchTeam( + { + teamName, + cwd: process.cwd(), + providerId: 'codex', + clearContext: true, + } as any, + () => {} + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toEqual( + expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']) + ); + + await svc.cancelProvisioning(runId); + }); }); diff --git a/test/renderer/api/httpClient.codexAccount.test.ts b/test/renderer/api/httpClient.codexAccount.test.ts new file mode 100644 index 00000000..377bb68a --- /dev/null +++ b/test/renderer/api/httpClient.codexAccount.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HttpAPIClient } from '../../../src/renderer/api/httpClient'; + +class MockEventSource { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + addEventListener(): void { + // noop browser-mode stub + } + close(): void { + // noop browser-mode stub + } +} + +describe('HttpAPIClient Codex account browser fallback', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('rejects Codex account actions with a consistent browser-mode error and returns a safe noop subscription', async () => { + vi.stubGlobal('EventSource', MockEventSource); + const client = new HttpAPIClient('http://localhost:9999'); + const expectedMessage = 'Codex account bridge is unavailable in browser mode'; + + await expect(client.getCodexAccountSnapshot()).rejects.toThrow(expectedMessage); + await expect( + client.refreshCodexAccountSnapshot({ + includeRateLimits: true, + forceRefreshToken: true, + }) + ).rejects.toThrow(expectedMessage); + await expect(client.startCodexChatgptLogin()).rejects.toThrow(expectedMessage); + await expect(client.cancelCodexChatgptLogin()).rejects.toThrow(expectedMessage); + await expect(client.logoutCodexAccount()).rejects.toThrow(expectedMessage); + + expect(typeof client.onCodexAccountSnapshotChanged(() => undefined)).toBe('function'); + }); +}); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 00575af7..21fe1108 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + interface StoreState { cliStatus: Record | null; cliStatusLoading: boolean; @@ -45,6 +47,15 @@ let providerRuntimeSettingsDialogProps: { open?: boolean; initialProviderId?: string; } | null = null; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; vi.mock('@renderer/api', () => ({ api: { @@ -53,6 +64,14 @@ vi.mock('@renderer/api', () => ({ isElectronMode: () => true, })); +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + vi.mock('@renderer/components/common/ConfirmDialog', () => ({ confirm: vi.fn(() => Promise.resolve(true)), })); @@ -269,6 +288,13 @@ describe('CLI status visibility during completed install state', () => { beforeEach(() => { providerRuntimeSettingsDialogProps = null; + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + codexAccountHookState.refresh.mockClear(); + codexAccountHookState.startChatgptLogin.mockClear(); + codexAccountHookState.cancelChatgptLogin.mockClear(); + codexAccountHookState.logout.mockClear(); storeState.cliStatus = createInstalledCliStatus(); storeState.cliStatusLoading = false; storeState.cliProviderStatusLoading = {}; @@ -437,6 +463,50 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('does not render the Anthropic connect action while the provider card is still checking', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + authLoggedIn: false, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: null, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Checking...'); + expect(host.textContent).not.toContain('Connect Anthropic'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -565,6 +635,11 @@ describe('CLI status visibility during completed install state', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, installed: false, installedVersion: null, binaryPath: '/Users/tester/.claude/local/node_modules/.bin/claude', @@ -581,7 +656,12 @@ describe('CLI status visibility during completed install state', () => { }); expect(host.textContent).toContain('failed to start'); - expect(host.textContent).toContain('Reinstall Claude CLI'); + expect(host.textContent).toContain('Multimodel runtime was found but failed to start'); + expect(host.textContent).toContain('Re-check'); + expect(host.textContent).toContain( + 'The configured Multimodel runtime failed its startup health check.' + ); + expect(host.textContent).not.toContain('Reinstall Claude CLI'); await act(async () => { root.unmount(); @@ -626,6 +706,46 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('uses provider-first bootstrap when settings re-check runs in multimodel mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: true, + showVersionDetails: false, + installed: false, + authLoggedIn: false, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusSection)); + await Promise.resolve(); + }); + + const refreshButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Re-check') + ); + expect(refreshButton).not.toBeNull(); + + await act(async () => { + refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true }); + expect(storeState.fetchCliStatus).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('preserves settings runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -864,7 +984,7 @@ describe('CLI status visibility during completed install state', () => { }); expect(host.textContent).toContain('Ready'); - expect(host.textContent).toContain('Runtime: Codex native'); + expect(host.textContent).toContain('Current runtime: Codex native'); expect(host.textContent).not.toContain('Connected via API key'); await act(async () => { @@ -873,6 +993,723 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('shows remaining Codex subscription limits on the dashboard card when ChatGPT mode is active', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'plan-pro', + limitName: 'Pro', + primary: { + usedPercent: 5, + windowDurationMins: 300, + resetsAt: 1_762_547_200, + }, + secondary: { + usedPercent: 41, + windowDurationMins: 10_080, + resetsAt: 1_762_891_200, + }, + credits: { + hasCredits: false, + unlimited: false, + balance: null, + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: null, + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Providers: 1/1 connected'); + expect(host.textContent).toContain('ChatGPT account ready'); + expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.'); + expect(host.textContent).toContain('5h left'); + expect(host.textContent).toContain('95%'); + expect(host.textContent).toContain('1w left'); + expect(host.textContent).toContain('59%'); + expect(host.textContent).toContain('resets'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the live Codex account snapshot in the settings runtime section too', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: null, + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusSection)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('ChatGPT account ready'); + expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('applies the live Codex snapshot even while the dashboard is still on multimodel loading placeholder state', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'plan-pro', + limitName: 'Pro', + primary: { + usedPercent: 5, + windowDurationMins: 300, + resetsAt: 1_762_547_200, + }, + secondary: { + usedPercent: 41, + windowDurationMins: 10_080, + resetsAt: 1_762_891_200, + }, + credits: { + hasCredits: false, + unlimited: false, + balance: null, + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Providers: 1/2 connected'); + expect(host.textContent).toContain('5h left'); + expect(host.textContent).toContain('1w left'); + expect(host.textContent).toContain('resets'); + expect(host.textContent).not.toContain('status will be checked in the background'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps Codex on checking while the dashboard bootstrap is still on placeholder state and the live snapshot is only a negative auth result', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Checking...'); + expect(host.textContent).not.toContain( + 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.' + ); + expect(host.textContent).not.toContain( + 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('explains missing Codex limits when ChatGPT mode is selected but Codex is not logged in', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: false, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: null, + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex CLI reports no active ChatGPT login'); + expect(host.textContent).toContain('Selected auth: ChatGPT account'); + expect(host.textContent).toContain( + 'Detected from OPENAI_API_KEY - available if you switch to API key mode' + ); + expect(host.textContent).toContain( + 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login. API key fallback is available if you switch auth mode.' + ); + expect(host.textContent).not.toContain('5h left'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('explains reconnect when a local selected ChatGPT account exists but the current session is stale', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: false, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: null, + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.' + ); + expect(host.textContent).toContain( + 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect. API key fallback is available if you switch auth mode.' + ); + expect(host.textContent).not.toContain('5h left'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('explains when Auto is using an API key while ChatGPT usage limits are still unavailable', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'auto', + effectiveAuthMode: 'api_key', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + statusMessage: 'API key ready', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: 'api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: 'api_key', + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Detected from OPENAI_API_KEY - Auto will use this until ChatGPT is connected' + ); + expect(host.textContent).toContain( + 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login. Auto will keep using the API key until ChatGPT is connected.' + ); + expect(host.textContent).not.toContain('5h left'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not spin the provider refresh control during a global CLI refresh once the provider card is already rendered', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatusLoading = true; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: true, + authMethod: 'api_key', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + codex: { + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: 'api_key', + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + const refreshButton = host.querySelector('[title="Re-check Codex"]'); + expect(refreshButton).not.toBeNull(); + const refreshIcon = refreshButton?.querySelector('svg'); + expect(refreshIcon?.getAttribute('class')).not.toContain('animate-spin'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps settings codex-native rollout truth explicit for runtime-missing lanes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -909,7 +1746,7 @@ describe('CLI status visibility during completed install state', () => { }); expect(host.textContent).toContain('Codex CLI not found'); - expect(host.textContent).toContain('Runtime: Codex native - runtime missing'); + expect(host.textContent).toContain('Selected runtime: Codex native'); expect(host.textContent).not.toContain('Connected via API key'); await act(async () => { diff --git a/test/renderer/components/common/CliInstallWarningBanner.test.tsx b/test/renderer/components/common/CliInstallWarningBanner.test.tsx new file mode 100644 index 00000000..96e71181 --- /dev/null +++ b/test/renderer/components/common/CliInstallWarningBanner.test.tsx @@ -0,0 +1,104 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const openDashboard = vi.fn(); +const storeState = { + cliStatus: null as + | { + installed: boolean; + displayName: string; + binaryPath: string | null; + launchError: string | null; + } + | null, + cliStatusLoading: false, + paneLayout: { + focusedPaneId: 'pane-1', + panes: [ + { + id: 'pane-1', + activeTabId: 'tab-1', + tabs: [ + { + id: 'tab-1', + type: 'thread', + }, + ], + }, + ], + }, + openDashboard, +}; + +vi.mock('@renderer/api', () => ({ + isElectronMode: () => true, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +import { CliInstallWarningBanner } from '@renderer/components/common/CliInstallWarningBanner'; + +describe('CliInstallWarningBanner', () => { + afterEach(() => { + document.body.innerHTML = ''; + storeState.cliStatus = null; + storeState.cliStatusLoading = false; + openDashboard.mockReset(); + }); + + it('hides stale runtime errors while status is still loading', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + installed: false, + displayName: 'Multimodel runtime', + binaryPath: '/tmp/runtime', + launchError: 'spawn EACCES', + }; + storeState.cliStatusLoading = true; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliInstallWarningBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows the banner after loading completes and allows opening the dashboard', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + installed: false, + displayName: 'Multimodel runtime', + binaryPath: '/tmp/runtime', + launchError: 'spawn EACCES', + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliInstallWarningBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('failed to start'); + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(openDashboard).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/extensions/ExtensionStoreView.test.ts b/test/renderer/components/extensions/ExtensionStoreView.test.ts index 08ea0aa7..f765f01c 100644 --- a/test/renderer/components/extensions/ExtensionStoreView.test.ts +++ b/test/renderer/components/extensions/ExtensionStoreView.test.ts @@ -2,10 +2,12 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CliInstallationStatus } from '@shared/types'; interface StoreState { fetchPluginCatalog: ReturnType; + bootstrapCliStatus: ReturnType; fetchCliStatus: ReturnType; fetchApiKeys: ReturnType; fetchSkillsCatalog: ReturnType; @@ -18,13 +20,30 @@ interface StoreState { cliStatus: CliInstallationStatus | null; cliStatusLoading: boolean; cliProviderStatusLoading: Record; + appConfig: { + general: { + multimodelEnabled: boolean; + }; + }; openDashboard: ReturnType; - sessions: Array<{ isOngoing: boolean }>; + sessions: { isOngoing: boolean }[]; projects: unknown[]; repositoryGroups: unknown[]; } const storeState = {} as StoreState; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; +const pluginsPanelSpy = vi.fn(); +const mcpServersPanelSpy = vi.fn(); +const customMcpDialogSpy = vi.fn(); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), @@ -40,8 +59,17 @@ vi.mock('@renderer/api', () => ({ mcpRegistry: {}, skills: {}, }, + isElectronMode: () => true, })); +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + vi.mock('@renderer/contexts/useTabUIContext', () => ({ useTabIdOptional: () => undefined, })); @@ -134,11 +162,17 @@ vi.mock('@renderer/components/extensions/ExtensionsSubTabTrigger', () => ({ })); vi.mock('@renderer/components/extensions/plugins/PluginsPanel', () => ({ - PluginsPanel: () => React.createElement('div', null, 'plugins-panel'), + PluginsPanel: (props: unknown) => { + pluginsPanelSpy(props); + return React.createElement('div', null, 'plugins-panel'); + }, })); vi.mock('@renderer/components/extensions/mcp/McpServersPanel', () => ({ - McpServersPanel: () => React.createElement('div', null, 'mcp-panel'), + McpServersPanel: (props: unknown) => { + mcpServersPanelSpy(props); + return React.createElement('div', null, 'mcp-panel'); + }, })); vi.mock('@renderer/components/extensions/skills/SkillsPanel', () => ({ @@ -150,7 +184,10 @@ vi.mock('@renderer/components/extensions/apikeys/ApiKeysPanel', () => ({ })); vi.mock('@renderer/components/extensions/mcp/CustomMcpServerDialog', () => ({ - CustomMcpServerDialog: () => null, + CustomMcpServerDialog: (props: unknown) => { + customMcpDialogSpy(props); + return null; + }, })); vi.mock('lucide-react', () => { @@ -252,7 +289,18 @@ function createLoadingMultimodelStatus(): CliInstallationStatus { describe('ExtensionStoreView provider loading placeholders', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + pluginsPanelSpy.mockReset(); + mcpServersPanelSpy.mockReset(); + customMcpDialogSpy.mockReset(); + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + codexAccountHookState.refresh.mockReset().mockResolvedValue(undefined); + codexAccountHookState.startChatgptLogin.mockReset().mockResolvedValue(true); + codexAccountHookState.cancelChatgptLogin.mockReset().mockResolvedValue(true); + codexAccountHookState.logout.mockReset().mockResolvedValue(true); storeState.fetchPluginCatalog = vi.fn().mockResolvedValue(undefined); + storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined); storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined); @@ -268,6 +316,11 @@ describe('ExtensionStoreView provider loading placeholders', () => { anthropic: true, codex: true, }; + storeState.appConfig = { + general: { + multimodelEnabled: true, + }, + }; storeState.openDashboard = vi.fn(); storeState.sessions = []; storeState.projects = []; @@ -290,6 +343,9 @@ describe('ExtensionStoreView provider loading placeholders', () => { await Promise.resolve(); }); + expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true }); + expect(storeState.fetchCliStatus).not.toHaveBeenCalled(); + expect(host.textContent).toContain('Multimodel runtime capabilities'); expect(host.textContent).toContain('Anthropic'); expect(host.textContent).toContain('Codex'); @@ -303,6 +359,32 @@ describe('ExtensionStoreView provider loading placeholders', () => { }); }); + it('falls back to legacy refresh when multimodel is disabled', async () => { + storeState.appConfig = { + general: { + multimodelEnabled: false, + }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(storeState.fetchCliStatus).toHaveBeenCalledTimes(1); + expect(storeState.bootstrapCliStatus).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps provider placeholders visible when bootstrap data still says Checking...', async () => { storeState.cliStatusLoading = false; storeState.cliProviderStatusLoading = {}; @@ -326,4 +408,264 @@ describe('ExtensionStoreView provider loading placeholders', () => { await Promise.resolve(); }); }); + + it('uses the live Codex account snapshot to replace stale extension-card status', async () => { + storeState.cliStatusLoading = false; + storeState.cliProviderStatusLoading = {}; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = { + ...createLoadingMultimodelStatus(), + authLoggedIn: true, + authStatusChecking: false, + providers: [ + createLoadingMultimodelStatus().providers[1], + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('ChatGPT account ready'); + expect(host.textContent).not.toContain('Checking provider status...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the live Codex snapshot even while multimodel root status is still loading', async () => { + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + storeState.cliProviderStatusLoading = {}; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'plan-pro', + limitName: 'Pro', + primary: { + usedPercent: 5, + windowDurationMins: 300, + resetsAt: 1_762_547_200, + }, + secondary: { + usedPercent: 41, + windowDurationMins: 10_080, + resetsAt: 1_762_891_200, + }, + credits: { + hasCredits: false, + unlimited: false, + balance: null, + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('ChatGPT account ready'); + expect(host.textContent).not.toContain('Checking extensions runtime availability'); + expect(host.querySelector('button[disabled]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not leave the stale Codex placeholder stuck as unsupported once live snapshot truth arrives', async () => { + storeState.cliStatusLoading = false; + storeState.cliProviderStatusLoading = {}; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = { + ...createLoadingMultimodelStatus(), + authLoggedIn: true, + authStatusChecking: false, + providers: [createLoadingMultimodelStatus().providers[1]], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('Needs setup'); + expect(host.textContent).not.toContain('Unsupported'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('passes merged effective Codex status to nested extension panels and dialogs', async () => { + storeState.cliStatusLoading = true; + storeState.cliProviderStatusLoading = {}; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = { + ...createLoadingMultimodelStatus(), + authLoggedIn: true, + authStatusChecking: false, + providers: [createLoadingMultimodelStatus().providers[1]], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExtensionStoreView)); + await Promise.resolve(); + await Promise.resolve(); + }); + + const pluginsPanelProps = pluginsPanelSpy.mock.calls.at(-1)?.[0] as { + cliStatus?: CliInstallationStatus | null; + cliStatusLoading?: boolean; + }; + const mcpPanelProps = mcpServersPanelSpy.mock.calls.at(-1)?.[0] as { + cliStatus?: CliInstallationStatus | null; + cliStatusLoading?: boolean; + }; + const customDialogProps = customMcpDialogSpy.mock.calls.at(-1)?.[0] as { + cliStatus?: CliInstallationStatus | null; + cliStatusLoading?: boolean; + }; + + expect(pluginsPanelProps.cliStatusLoading).toBe(false); + expect(mcpPanelProps.cliStatusLoading).toBe(false); + expect(customDialogProps.cliStatusLoading).toBe(false); + expect(pluginsPanelProps.cliStatus?.providers[0]?.supported).toBe(true); + expect(pluginsPanelProps.cliStatus?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); + expect(mcpPanelProps.cliStatus?.providers[0]?.resolvedBackendId).toBe('codex-native'); + expect(customDialogProps.cliStatus?.providers[0]?.connection?.codex?.managedAccount?.email).toBe( + 'user@example.com' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts b/test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts new file mode 100644 index 00000000..04a30a9b --- /dev/null +++ b/test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts @@ -0,0 +1,318 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; +import type { CliInstallationStatus } from '@shared/types'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; + +interface StoreState { + apiKeys: Array<{ + id: string; + providerId: string; + displayName: string; + envVarName: string; + scope: 'user'; + createdAt: number; + updatedAt: number; + }>; + apiKeysLoading: boolean; + apiKeysError: string | null; + apiKeyStorageStatus: { + encryptionMethod: 'os-keychain' | 'local-aes'; + backend: string; + } | null; + fetchApiKeyStorageStatus: ReturnType; + cliStatus: CliInstallationStatus | null; + cliStatusLoading: boolean; + appConfig: { + general: { + multimodelEnabled: boolean; + }; + } | null; +} + +const storeState = {} as StoreState; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + +vi.mock('@renderer/api', () => ({ + isElectronMode: () => true, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: React.PropsWithChildren<{ onClick?: () => void }>) => + React.createElement( + 'button', + { + type: 'button', + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: () => null, +})); + +vi.mock('@renderer/components/extensions/apikeys/ApiKeyCard', () => ({ + ApiKeyCard: ({ apiKey }: { apiKey: { displayName: string } }) => + React.createElement('div', null, apiKey.displayName), +})); + +vi.mock('@renderer/components/extensions/apikeys/ApiKeyFormDialog', () => ({ + ApiKeyFormDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + Info: Icon, + Key: Icon, + Plus: Icon, + }; +}); + +import { ApiKeysPanel } from '@renderer/components/extensions/apikeys/ApiKeysPanel'; + +function createCliStatus(): CliInstallationStatus { + return { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + installed: true, + installedVersion: null, + binaryPath: '/usr/local/bin/agent-teams', + launchError: null, + latestVersion: null, + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: null, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + statusMessage: 'Connected', + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: ['auto', 'oauth', 'api_key'], + configuredAuthMode: 'auto', + apiKeyConfigured: false, + apiKeySource: null, + apiKeySourceLabel: null, + }, + }, + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Connect a ChatGPT account to use your Codex subscription.', + models: [], + modelAvailability: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: 'codex-native', + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + }, + ], + }; +} + +describe('ApiKeysPanel', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.apiKeys = []; + storeState.apiKeysLoading = false; + storeState.apiKeysError = null; + storeState.apiKeyStorageStatus = { + encryptionMethod: 'os-keychain', + backend: 'Keychain Access', + }; + storeState.fetchApiKeyStorageStatus = vi.fn().mockResolvedValue(undefined); + storeState.cliStatus = createCliStatus(); + storeState.cliStatusLoading = false; + storeState.appConfig = { + general: { + multimodelEnabled: true, + }, + }; + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses the live Codex account snapshot for the Codex runtime card', async () => { + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ApiKeysPanel, { + projectPath: null, + projectLabel: null, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex runtime'); + expect(host.textContent).toContain('Connected'); + expect(host.textContent).toContain('Current source: Detected from OPENAI_API_KEY.'); + expect(host.textContent).toContain('ChatGPT account ready'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the live Codex snapshot even while multimodel provider status is still loading', async () => { + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ApiKeysPanel, { + projectPath: null, + projectLabel: null, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex runtime'); + expect(host.textContent).toContain('Connected'); + expect(host.textContent).toContain('ChatGPT account ready'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 2b988637..0341dfd8 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; interface StoreState { - mcpBrowseCatalog: Array<{ + mcpBrowseCatalog: { id: string; name: string; description: string; @@ -14,15 +14,15 @@ interface StoreState { envVars: []; tools: []; requiresAuth: boolean; - }>; + }[]; mcpBrowseNextCursor?: string; mcpBrowseLoading: boolean; mcpBrowseError: string | null; mcpBrowse: ReturnType; - mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + mcpInstalledServers: { name: string; scope: 'local' | 'user' | 'project' }[]; mcpInstalledServersByProjectPath?: Record< string, - Array<{ name: string; scope: 'local' | 'user' | 'project' }> + { name: string; scope: 'local' | 'user' | 'project' }[] >; fetchMcpGitHubStars: ReturnType; mcpDiagnostics: Record; @@ -310,8 +310,8 @@ describe('McpServersPanel initial browse loading', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Configured runtime not available'); - expect(host.textContent).toContain('MCP health checks require the configured runtime'); + expect(host.textContent).toContain('Multimodel runtime not available'); + expect(host.textContent).toContain('MCP health checks require Multimodel runtime'); expect(host.textContent).not.toContain('Claude CLI not installed'); await act(async () => { @@ -356,7 +356,7 @@ describe('McpServersPanel initial browse loading', () => { button.textContent?.includes('Check Status') ); expect(checkStatusButton).toBeDefined(); - expect((checkStatusButton as HTMLButtonElement).disabled).toBe(true); + expect((checkStatusButton!).disabled).toBe(true); await act(async () => { root.unmount(); @@ -426,4 +426,86 @@ describe('McpServersPanel initial browse loading', () => { await Promise.resolve(); }); }); + + it('uses the effective runtime status override for diagnostics gating during background refresh', async () => { + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + projectPath: null, + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + cliStatus: { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/agent-teams', + launchError: null, + providers: [], + }, + cliStatusLoading: false, + }) + ); + await Promise.resolve(); + }); + + expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1); + expect(host.textContent).not.toContain('Checking runtime availability...'); + expect(host.textContent).not.toContain('The configured runtime is required.'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not block diagnostics when a usable runtime status already exists during background refresh', async () => { + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + installed: true, + binaryPath: '/usr/local/bin/agent-teams', + launchError: null, + }; + storeState.cliStatusLoading = true; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + projectPath: null, + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1); + expect(host.textContent).not.toContain('Checking runtime status...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/plugins/PluginsPanel.test.ts b/test/renderer/components/extensions/plugins/PluginsPanel.test.ts new file mode 100644 index 00000000..5cef1eb7 --- /dev/null +++ b/test/renderer/components/extensions/plugins/PluginsPanel.test.ts @@ -0,0 +1,272 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CliInstallationStatus } from '@shared/types'; + +type PluginsPanelCliStatus = Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' +>; + +interface StoreState { + pluginCatalog: { + pluginId: string; + marketplaceId: string; + qualifiedName: string; + name: string; + source: 'official'; + description: string; + category: string; + author: { name: string }; + version: string; + homepage: null; + tags: string[]; + hasLspServers: false; + hasMcpServers: false; + hasAgents: false; + hasCommands: false; + hasHooks: false; + isExternal: false; + installCount: number; + isInstalled: false; + installations: []; + }[]; + pluginCatalogLoading: boolean; + pluginCatalogError: string | null; + cliStatus: PluginsPanelCliStatus | null; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ children }: React.PropsWithChildren) => React.createElement('button', null, children), +})); + +vi.mock('@renderer/components/ui/checkbox', () => ({ + Checkbox: () => React.createElement('input', { type: 'checkbox' }), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement('button', null, children), + SelectValue: () => React.createElement('span', null, 'select-value'), + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', null, children), + SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) => + React.createElement('button', null, children), +})); + +vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ + SearchInput: ({ value }: { value: string }) => React.createElement('input', { value, readOnly: true }), +})); + +vi.mock('@renderer/components/extensions/plugins/CapabilityChips', () => ({ + CapabilityChips: () => React.createElement('div', null, 'capability-chips'), +})); + +vi.mock('@renderer/components/extensions/plugins/CategoryChips', () => ({ + CategoryChips: () => React.createElement('div', null, 'category-chips'), +})); + +vi.mock('@renderer/components/extensions/plugins/PluginCard', () => ({ + PluginCard: ({ plugin }: { plugin: { name: string } }) => React.createElement('div', null, plugin.name), +})); + +vi.mock('@renderer/components/extensions/plugins/PluginDetailDialog', () => ({ + PluginDetailDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ArrowUpDown: Icon, + Filter: Icon, + Puzzle: Icon, + Search: Icon, + }; +}); + +import { PluginsPanel } from '@renderer/components/extensions/plugins/PluginsPanel'; + +const staleCodexStatus: PluginsPanelCliStatus = { + flavor: 'agent_teams_orchestrator', + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/agent-teams', + launchError: null, + providers: [ + { + providerId: 'codex', + displayName: 'Codex', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: 'Checking...', + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: { + plugins: { + status: 'unsupported', + ownership: 'provider-scoped', + reason: 'Codex bootstrap placeholder', + }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + }, + ], +}; + +const mergedCodexStatus: PluginsPanelCliStatus = { + ...staleCodexStatus, + providers: [ + { + ...staleCodexStatus.providers[0], + supported: true, + statusMessage: 'ChatGPT account ready', + capabilities: { + ...staleCodexStatus.providers[0].capabilities, + extensions: { + ...staleCodexStatus.providers[0].capabilities.extensions, + plugins: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }, + ], +}; + +describe('PluginsPanel effective runtime status', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.pluginCatalog = []; + storeState.pluginCatalogLoading = false; + storeState.pluginCatalogError = null; + storeState.cliStatus = staleCodexStatus; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses the merged runtime status prop instead of stale store status for Codex plugin warnings', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(PluginsPanel, { + projectPath: null, + pluginFilters: { + search: '', + categories: [], + capabilities: [], + installedOnly: false, + }, + pluginSort: { field: 'popularity', order: 'desc' }, + selectedPluginId: null, + updatePluginSearch: vi.fn(), + toggleCategory: vi.fn(), + toggleCapability: vi.fn(), + toggleInstalledOnly: vi.fn(), + setSelectedPluginId: vi.fn(), + clearFilters: vi.fn(), + hasActiveFilters: false, + setPluginSort: vi.fn(), + cliStatus: mergedCodexStatus, + cliStatusLoading: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain( + 'In the multimodel runtime, plugins currently apply only to Anthropic sessions.' + ); + expect(host.textContent).not.toContain('Codex bootstrap placeholder'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('explains that broader plugin support for all agents is actively being built when Codex plugins are not supported yet', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(PluginsPanel, { + projectPath: null, + pluginFilters: { + search: '', + categories: [], + capabilities: [], + installedOnly: false, + }, + pluginSort: { field: 'popularity', order: 'desc' }, + selectedPluginId: null, + updatePluginSearch: vi.fn(), + toggleCategory: vi.fn(), + toggleCapability: vi.fn(), + toggleInstalledOnly: vi.fn(), + setSelectedPluginId: vi.fn(), + clearFilters: vi.fn(), + hasActiveFilters: false, + setPluginSort: vi.fn(), + cliStatus: staleCodexStatus, + cliStatusLoading: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'plugins are currently guaranteed only for Anthropic sessions' + ); + expect(host.textContent).toContain( + 'We are actively building broader plugin support for all agents' + ); + expect(host.textContent).toContain('universal plugins and agent-specific plugins'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index 87579c28..0c1c7bfc 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -15,12 +16,27 @@ interface StoreState { skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; cliStatus: CliInstallationStatus | null; + cliStatusLoading: boolean; + appConfig: { + general: { + multimodelEnabled: boolean; + }; + } | null; } const storeState = {} as StoreState; const startWatchingMock = vi.fn(); const stopWatchingMock = vi.fn(); const onChangedMock = vi.fn(); +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; let skillsChangedHandler: ((event: { scope: 'user' | 'project'; projectPath: string | null; @@ -32,6 +48,14 @@ vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector, })); @@ -238,6 +262,12 @@ describe('SkillsPanel', () => { storeState.skillsProjectCatalogByProjectPath = { '/tmp/project-a': [], }; + storeState.cliStatusLoading = false; + storeState.appConfig = { + general: { + multimodelEnabled: true, + }, + }; storeState.cliStatus = { flavor: 'claude', displayName: 'Claude CLI', @@ -254,6 +284,9 @@ describe('SkillsPanel', () => { authMethod: 'oauth', providers: [], }; + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; startWatchingMock.mockReset(); stopWatchingMock.mockReset(); onChangedMock.mockReset(); @@ -386,6 +419,156 @@ describe('SkillsPanel', () => { }); }); + it('uses the live Codex snapshot to expose Codex-only skill affordances after a stale provider bootstrap', async () => { + storeState.cliStatus = makeMultimodelStatus({ + providers: [ + ...makeMultimodelStatus().providers, + { + providerId: 'codex', + displayName: 'Codex', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities({ + plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null }, + }), + }, + connection: null, + backend: null, + }, + ], + }); + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: null, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic and Codex.' + ); + expect(host.textContent).toContain('Codex only'); + expect(host.textContent).toContain('Use `.codex` when a skill should stay Codex-only.'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the live Codex snapshot even while multimodel provider status is still loading', async () => { + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: null, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic and Codex.' + ); + expect(host.textContent).toContain('Codex only'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('resets the codex-only quick filter when codex entries disappear', async () => { storeState.cliStatus = makeMultimodelStatus({ providers: [ diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index c62b789d..96b6e129 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CliProviderStatus } from '@shared/types'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; interface StoreState { appConfig: { @@ -10,7 +11,9 @@ interface StoreState { anthropic: { authMode: 'auto' | 'oauth' | 'api_key'; }; - codex: Record; + codex: { + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; + }; }; }; apiKeys: { @@ -33,6 +36,15 @@ interface StoreState { } const storeState = {} as StoreState; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; vi.mock('@renderer/store', () => { const useStore = (selector: (state: StoreState) => unknown) => selector(storeState); @@ -42,6 +54,14 @@ vi.mock('@renderer/store', () => { return { useStore }; }); +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + vi.mock('@renderer/components/ui/button', () => ({ Button: ({ children, @@ -151,13 +171,14 @@ import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/Prov import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; function createCodexProvider( - overrides?: Partial & { + overrides?: Omit>, 'codex'> & { authenticated?: boolean; authMethod?: string | null; selectedBackendId?: string | null; resolvedBackendId?: string | null; availableBackends?: CliProviderStatus['availableBackends']; canLoginFromUi?: boolean; + codex?: Partial['codex']>>; } ): CliProviderStatus { return { @@ -200,11 +221,32 @@ function createCodexProvider( connection: { supportsOAuth: false, supportsApiKey: true, - configurableAuthModes: [], - configuredAuthMode: null, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: overrides?.configuredAuthMode ?? 'auto', apiKeyConfigured: overrides?.apiKeyConfigured ?? false, apiKeySource: overrides?.apiKeySource ?? null, apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null, + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: overrides?.apiKeyConfigured ? 'api_key' : null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: null, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured), + launchIssueMessage: null, + launchReadinessState: + Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured) + ? 'ready_api_key' + : 'missing_auth', + ...overrides?.codex, + }, }, }; } @@ -313,12 +355,21 @@ function findButtonByText(container: HTMLElement, text: string): HTMLButtonEleme describe('ProviderRuntimeSettingsDialog', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + codexAccountHookState.refresh.mockReset().mockResolvedValue(undefined); + codexAccountHookState.startChatgptLogin.mockReset().mockResolvedValue(true); + codexAccountHookState.cancelChatgptLogin.mockReset().mockResolvedValue(true); + codexAccountHookState.logout.mockReset().mockResolvedValue(true); storeState.appConfig = { providerConnections: { anthropic: { authMode: 'auto', }, - codex: {}, + codex: { + preferredAuthMode: 'auto', + }, }, }; storeState.apiKeys = []; @@ -340,7 +391,10 @@ describe('ProviderRuntimeSettingsDialog', () => { ...storeState.appConfig.providerConnections.anthropic, ...(nextProviderConnections.anthropic ?? {}), }, - codex: {}, + codex: { + ...storeState.appConfig.providerConnections.codex, + ...(nextProviderConnections.codex ?? {}), + }, }, }; } @@ -450,12 +504,747 @@ describe('ProviderRuntimeSettingsDialog', () => { }); expect(host.textContent).toContain( - 'Codex launches always use the native runtime now. Manage API-key credentials here before launching teams or one-shot Codex runs.' + 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.' + ); + expect(host.textContent).toContain('Connection method'); + expect(host.textContent).toContain('ChatGPT account'); + expect(host.textContent).toContain( + 'Use an OpenAI API key as a secondary Codex auth path.' ); expect(host.textContent).toContain('Set API key'); - expect(host.textContent).not.toContain('Connection method'); - expect(host.textContent).not.toContain('Connect Codex'); - expect(host.textContent).not.toContain('Reconnect Codex'); + expect(host.textContent).toContain('Connect ChatGPT'); + }); + + it('explains the missing Codex ChatGPT login without mixing it up with the detected API key', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + storeState.appConfig.providerConnections.codex.preferredAuthMode = 'chatgpt'; + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription. Switch to API key mode to use the detected API key.' + ); + expect(host.textContent).toContain( + 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one. The detected API key is only used after you switch Codex to API key mode.' + ); + expect(host.textContent).toContain('Detected from OPENAI_API_KEY'); + expect(host.textContent).not.toContain( + 'ChatGPT account mode is selected, but no managed Codex account is connected yet.' + ); + }); + + it('mentions local Codex account artifacts when ChatGPT mode is pinned but no active managed session is selected', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + storeState.appConfig.providerConnections.codex.preferredAuthMode = 'chatgpt'; + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Switch to API key mode to use the detected API key.' + ); + expect(host.textContent).toContain( + 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one. The detected API key is only used after you switch Codex to API key mode.' + ); + }); + + it('asks for reconnect when ChatGPT mode is pinned and a local selected account exists but the session is stale', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + storeState.appConfig.providerConnections.codex.preferredAuthMode = 'chatgpt'; + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + }, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex has a locally selected ChatGPT account, but the current session needs reconnect. Switch to API key mode to use the detected API key.' + ); + expect(host.textContent).toContain( + 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here. The detected API key is only used after you switch Codex to API key mode.' + ); + }); + + it('disables Codex account actions while a Codex account request is already in flight', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + codexAccountHookState.loading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(findButtonByText(host, 'Refresh').disabled).toBe(true); + expect(findButtonByText(host, 'Connect ChatGPT').disabled).toBe(true); + }); + + it('prefers live Codex snapshot readiness over stale provider status after the account hook refreshes', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'plus', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + apiKeyConfigured: false, + codex: { + launchAllowed: false, + launchIssueMessage: + 'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.', + launchReadinessState: 'missing_auth', + }, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('belief@example.com'); + expect(host.textContent).toContain('Plan: plus'); + expect(host.textContent).not.toContain( + 'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.' + ); + }); + + it('starts the ChatGPT login flow from the Codex account panel', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + findButtonByText(host, 'Connect ChatGPT').click(); + await Promise.resolve(); + }); + + expect(codexAccountHookState.startChatgptLogin).toHaveBeenCalledTimes(1); + }); + + it('shows cancel login while pending and refreshes provider state after cancellation', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRefreshProvider = vi.fn(() => Promise.resolve(undefined)); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + login: { + status: 'pending', + error: null, + startedAt: '2026-04-20T12:00:00.000Z', + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Cancel login'); + + await act(async () => { + findButtonByText(host, 'Cancel login').click(); + await Promise.resolve(); + }); + + expect(codexAccountHookState.cancelChatgptLogin).toHaveBeenCalledTimes(1); + expect(onRefreshProvider).toHaveBeenCalledWith('codex'); + }); + + it('surfaces a pending Codex ChatGPT login as a waiting alert instead of a missing-account warning', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + login: { + status: 'pending', + error: null, + startedAt: '2026-04-20T12:00:00.000Z', + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Waiting for ChatGPT account login to finish...'); + expect(host.textContent).not.toContain( + 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription, or switch to API key mode to use the detected API key.' + ); + }); + + it('shows disconnect account for connected Codex subscriptions and refreshes after logout', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRefreshProvider = vi.fn(() => Promise.resolve(undefined)); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'pro', + }, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [createCodexProvider()], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Disconnect account'); + + await act(async () => { + findButtonByText(host, 'Disconnect account').click(); + await Promise.resolve(); + }); + + expect(codexAccountHookState.logout).toHaveBeenCalledTimes(1); + expect(onRefreshProvider).toHaveBeenCalledWith('codex'); + }); + + it('renders Codex rate limits when available from the live account snapshot', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { + usedPercent: 77, + windowDurationMins: 300, + resetsAt: 1_776_678_034, + }, + secondary: { + usedPercent: 45, + windowDurationMins: 10_080, + resetsAt: 1_776_999_999, + }, + credits: { + hasCredits: true, + unlimited: false, + balance: '42', + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [createCodexProvider()], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Primary used (5h)'); + expect(host.textContent).toContain('77%'); + expect(host.textContent).toContain('23% left'); + expect(host.textContent).toContain('Primary reset (5h)'); + expect(host.textContent).toContain( + new Date(1_776_678_034_000).toLocaleString() + ); + expect(host.textContent).toContain('Weekly used (1w)'); + expect(host.textContent).toContain('45%'); + expect(host.textContent).toContain('55% left'); + expect(host.textContent).toContain('Weekly reset (1w)'); + expect(host.textContent).toContain( + new Date(1_776_999_999_000).toLocaleString() + ); + expect(host.textContent).toContain('Credits'); + expect(host.textContent).toContain('42'); + expect(host.textContent).toContain('These percentages show used quota, not remaining quota.'); + expect(host.textContent).toContain( + '77% used - about 23% left in the current 5-hour window.' + ); + }); + + it('shows truthful Codex rate-limit fallbacks instead of misleading zero values', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { + usedPercent: null as never, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: null, + credits: { + hasCredits: false, + unlimited: false, + balance: '0', + }, + planType: 'pro', + }, + updatedAt: new Date().toISOString(), + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [createCodexProvider()], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Primary used (5h)'); + expect(host.textContent).toContain('Unknown'); + expect(host.textContent).toContain('Remaining unknown'); + expect(host.textContent).toContain('Credits'); + expect(host.textContent).toContain('Not available'); + expect(host.textContent).not.toContain('0%'); + expect(host.textContent).toContain('Shows used quota in the current 5-hour window, not remaining quota.'); }); it('keeps the API key icon container square', async () => { diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index 6b2a37eb..c52984de 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -104,11 +104,32 @@ function createCodexProvider( connection: { supportsOAuth: false, supportsApiKey: true, - configurableAuthModes: [], - configuredAuthMode: overrides?.configuredAuthMode ?? null, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: overrides?.configuredAuthMode ?? 'auto', apiKeyConfigured: overrides?.apiKeyConfigured ?? false, apiKeySource: overrides?.apiKeySource ?? null, apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null, + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: overrides?.apiKeyConfigured ? 'api_key' : null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: null, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured), + launchIssueMessage: null, + launchReadinessState: + Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured) + ? 'ready_api_key' + : 'missing_auth', + ...overrides?.codex, + }, }, }; } @@ -139,15 +160,33 @@ describe('providerConnectionUi', () => { ); }); - it('treats Codex as lane-managed and hides the old connection-managed runtime summary', () => { + it('treats Codex as lane-managed and surfaces the current runtime summary', () => { const provider = createCodexProvider({ apiKeyConfigured: true, apiKeySource: 'stored', apiKeySourceLabel: 'Stored in app', }); - expect(isConnectionManagedRuntimeProvider(provider)).toBe(false); - expect(getProviderCurrentRuntimeSummary(provider)).toBeNull(); + expect(isConnectionManagedRuntimeProvider(provider)).toBe(true); + expect(getProviderCurrentRuntimeSummary(provider)).toBe('Current runtime: Codex native'); + }); + + it('keeps the Codex runtime summary native even if a stale legacy backend label leaks in', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + selectedBackendId: 'auto', + resolvedBackendId: 'api', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'legacy adapter', + projectId: null, + authMethodDetail: null, + }, + }); + + expect(getProviderCurrentRuntimeSummary(provider)).toBe('Selected runtime: Codex native'); }); it('shows stored Codex API keys as immediately usable for native runtime', () => { @@ -157,7 +196,9 @@ describe('providerConnectionUi', () => { apiKeySourceLabel: 'Stored in app', }); - expect(getProviderCredentialSummary(provider)).toBe('Saved API key available in Manage'); + expect(getProviderCredentialSummary(provider)).toBe( + 'Saved API key available in Manage - Auto will use this until ChatGPT is connected' + ); }); it('shows environment Codex credentials without claiming they are stored in Manage', () => { @@ -167,7 +208,73 @@ describe('providerConnectionUi', () => { apiKeySourceLabel: 'Detected from CODEX_API_KEY', }); - expect(getProviderCredentialSummary(provider)).toBe('Detected from CODEX_API_KEY'); + expect(getProviderCredentialSummary(provider)).toBe( + 'Detected from CODEX_API_KEY - Auto will use this until ChatGPT is connected' + ); + }); + + it('describes Codex API keys as a mode-switch fallback when ChatGPT mode is pinned', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }); + + expect(getProviderCredentialSummary(provider)).toBe( + 'Detected from OPENAI_API_KEY - available if you switch to API key mode' + ); + }); + + it('describes Codex API keys as the current Auto fallback when no ChatGPT account is connected', () => { + const provider = createCodexProvider({ + authenticated: true, + authMethod: 'api_key', + configuredAuthMode: 'auto', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: 'api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + }, + }); + + expect(getProviderCredentialSummary(provider)).toBe( + 'Detected from OPENAI_API_KEY - Auto will use this until ChatGPT is connected' + ); }); it('surfaces native backend status instead of flattening Codex to connected-via-api-key text', () => { @@ -190,6 +297,126 @@ describe('providerConnectionUi', () => { expect(formatProviderStatusText(provider)).toBe('Codex native ready'); }); + it('surfaces degraded ChatGPT verification warnings instead of flattening them to ready', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: 'chatgpt', + appServerState: 'degraded', + appServerStatusMessage: 'Transient app-server verification failure.', + managedAccount: { + type: 'chatgpt', + email: 'belief@example.com', + planType: 'plus', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: 'ChatGPT account detected, but account verification is currently degraded.', + launchReadinessState: 'warning_degraded_but_launchable', + }, + }); + + expect(formatProviderStatusText(provider)).toBe( + 'ChatGPT account detected, but account verification is currently degraded.' + ); + }); + + it('surfaces a clear ChatGPT-required state when the pinned subscription login is missing', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }); + + expect(formatProviderStatusText(provider)).toBe('Codex CLI reports no active ChatGPT login'); + }); + + it('mentions local Codex account artifacts when the CLI has no active managed ChatGPT session', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + }, + }); + + expect(formatProviderStatusText(provider)).toBe( + 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.' + ); + }); + + it('asks for reconnect when a locally selected ChatGPT account exists but the session is stale', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'chatgpt', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + }, + }); + + expect(formatProviderStatusText(provider)).toBe( + 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.' + ); + }); + it('surfaces native auth-required state from the selected backend option', () => { const provider = createCodexProvider({ authenticated: false, diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx new file mode 100644 index 00000000..5d0a1f61 --- /dev/null +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -0,0 +1,80 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => React.createElement('button', { type: 'button', onClick }, children), +})); + +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content), +})); + +vi.mock('@renderer/components/team/CliLogsRichView', () => ({ + CliLogsRichView: ({ cliLogsTail }: { cliLogsTail: string }) => + React.createElement('div', null, `logs:${cliLogsTail}`), +})); + +vi.mock('@renderer/components/team/StepProgressBar', () => ({ + StepProgressBar: () => React.createElement('div', null, 'step-progress'), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + CheckCircle2: Icon, + ChevronDown: Icon, + ChevronRight: Icon, + Info: Icon, + Loader2: Icon, + X: Icon, + }; +}); + +import { ProvisioningProgressBlock } from '@renderer/components/team/ProvisioningProgressBlock'; + +describe('ProvisioningProgressBlock', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('keeps live output and CLI logs collapsed by default while launch is still running', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Launching team', + currentStepIndex: 1, + loading: true, + startedAt: '2026-04-20T12:00:00.000Z', + pid: 1234, + assistantOutput: 'streamed output', + cliLogsTail: 'tail line', + defaultLiveOutputOpen: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Live output'); + expect(host.textContent).toContain('CLI logs'); + expect(host.textContent).not.toContain('streamed output'); + expect(host.textContent).not.toContain('logs:tail line'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 47efb9ee..b28e638a 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + vi.mock('@renderer/components/ui/tooltip', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -67,11 +69,28 @@ const storeState = { appConfig: { general: { multimodelEnabled: true } }, fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), }; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; vi.mock('@renderer/store', () => ({ useStore: (selector: (state: unknown) => unknown) => selector(storeState), })); +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; describe('TeamModelSelector disabled Codex models', () => { @@ -80,6 +99,13 @@ describe('TeamModelSelector disabled Codex models', () => { storeState.cliStatus = null; storeState.cliStatusLoading = false; storeState.fetchCliProviderStatus.mockClear(); + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + codexAccountHookState.refresh.mockClear(); + codexAccountHookState.startChatgptLogin.mockClear(); + codexAccountHookState.cancelChatgptLogin.mockClear(); + codexAccountHookState.logout.mockClear(); }); it('shows only Default while Codex runtime models are still loading', async () => { @@ -102,7 +128,6 @@ describe('TeamModelSelector disabled Codex models', () => { }); expect(host.textContent).toContain('Default'); - expect(host.textContent).toContain('Explicit models load from the current runtime'); expect(host.textContent).not.toContain('5.1 Codex Mini'); expect(host.textContent).not.toContain('5.3 Codex Spark'); @@ -205,6 +230,44 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('keeps the runtime-reported Codex model list visible during a background refresh', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.3-codex'], + }, + ], + }; + storeState.cliStatusLoading = true; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('5.4'); + expect(host.textContent).toContain('5.3 Codex'); + expect(host.textContent).not.toContain('Explicit models load from the current runtime'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows 5.2 Codex as a disabled tile when the runtime still reports it', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { @@ -316,6 +379,82 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('disables 5.1 Codex Max when the live Codex snapshot says ChatGPT account mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + authMethod: null, + backend: null, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('5.4'); + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.1 Codex Max') + ); + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps runtime model buttons selectable without starting automatic model probes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 1fd0adbd..cbb07ad7 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -165,6 +165,7 @@ describe('ProvisioningProviderStatusList', () => { it('keeps internal native rollout state visible in provisioning backend summaries', () => { expect( getProvisioningProviderBackendSummary({ + providerId: 'codex', selectedBackendId: 'codex-native', resolvedBackendId: 'codex-native', backend: { diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 39500110..7454069f 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -55,6 +55,36 @@ import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExt import type { CliInstallationStatus } from '@shared/types'; +function createMultimodelProvider( + overrides: Partial & { + providerId: 'anthropic' | 'codex' | 'gemini'; + displayName: string; + } +): CliInstallationStatus['providers'][number] { + return { + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'verified', + statusMessage: null, + models: [], + modelVerificationState: 'idle', + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + connection: null, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + ...overrides, + }; +} + describe('cliInstallerSlice', () => { beforeEach(() => { vi.clearAllMocks(); @@ -215,6 +245,153 @@ describe('cliInstallerSlice', () => { expect(useStore.getState().cliProviderStatusLoading).toEqual({}); expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); }); + + it('reuses hydrated provider statuses from bootstrap metadata without duplicate provider probes', async () => { + const mockStatus: CliInstallationStatus = { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: true, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/Users/belief/.agent-teams/runtime-cache/0.0.3/darwin-arm64/claude-multimodel', + latestVersion: null, + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth_token', + providers: [ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected', + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: true, + authMethod: 'chatgpt', + statusMessage: 'ChatGPT account ready', + }), + createMultimodelProvider({ + providerId: 'gemini', + displayName: 'Gemini', + statusMessage: 'Ready', + }), + ], + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + + await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + + expect(useStore.getState().cliStatus).toMatchObject({ + ...mockStatus, + launchError: null, + }); + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + codex: false, + gemini: false, + }); + expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); + }); + + it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => { + let resolveCodexStatus!: ( + value: CliInstallationStatus['providers'][number] + ) => void; + const pendingCodexStatus = new Promise((resolve) => { + resolveCodexStatus = resolve; + }); + const mockStatus: CliInstallationStatus = { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: true, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/Users/belief/.agent-teams/runtime-cache/0.0.3/darwin-arm64/claude-multimodel', + latestVersion: null, + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: true, + authMethod: 'oauth_token', + providers: [ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected', + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + backend: null, + connection: null, + availableBackends: [], + }), + createMultimodelProvider({ + providerId: 'gemini', + displayName: 'Gemini', + statusMessage: 'Ready', + }), + ], + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => { + if (providerId === 'codex') { + return pendingCodexStatus; + } + throw new Error(`Unexpected provider status request for ${providerId}`); + }); + + const bootstrapPromise = useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + + await vi.waitFor(() => { + expect(useStore.getState().cliStatusLoading).toBe(false); + }); + + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + codex: true, + gemini: false, + }); + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1); + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledWith('codex'); + + resolveCodexStatus( + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: true, + authMethod: 'chatgpt', + statusMessage: 'ChatGPT account ready', + }) + ); + await bootstrapPromise; + + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + codex: false, + gemini: false, + }); + expect(useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'codex')) + .toMatchObject({ + authenticated: true, + statusMessage: 'ChatGPT account ready', + }); + }); }); describe('installCli', () => { diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 25fa2d96..f3c3f474 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -55,7 +55,7 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; -import type { CliInstallationStatus } from '../../../src/shared/types'; +import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types'; import { getMcpDiagnosticKey, getMcpProjectStateKey, @@ -206,6 +206,69 @@ const makeLimitedMultimodelCliStatus = ( ], }); +function makeAppConfig(multimodelEnabled: boolean): AppConfig { + return { + notifications: { + enabled: true, + soundEnabled: false, + ignoredRegex: [], + ignoredRepositories: [], + snoozedUntil: null, + snoozeMinutes: 60, + includeSubagentErrors: true, + notifyOnLeadInbox: true, + notifyOnUserInbox: true, + notifyOnClarifications: true, + notifyOnStatusChange: true, + notifyOnTaskComments: true, + notifyOnTaskCreated: true, + notifyOnAllTasksCompleted: true, + notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, + notifyOnToolApproval: true, + autoResumeOnRateLimit: false, + statusChangeOnlySolo: false, + statusChangeStatuses: [], + triggers: [], + }, + general: { + launchAtLogin: false, + showDockIcon: true, + theme: 'system', + defaultTab: 'dashboard', + multimodelEnabled, + claudeRootPath: null, + agentLanguage: 'system', + autoExpandAIGroups: true, + useNativeTitleBar: false, + telemetryEnabled: false, + }, + providerConnections: { + anthropic: { + authMode: 'auto', + }, + codex: { + preferredAuthMode: 'auto', + }, + }, + runtime: { + providerBackends: { + gemini: 'auto', + codex: 'codex-native', + }, + }, + display: { + showTimestamps: true, + compactMode: false, + syntaxHighlighting: true, + }, + sessions: { + pinnedSessions: {}, + hiddenSessions: {}, + }, + }; +} + const pluginOperationKey = ( pluginId: string, scope: 'user' | 'project' | 'local' = 'user', @@ -602,6 +665,18 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); + it('does not block plugin install when a usable runtime status already exists during background refresh', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + + expect(api.plugins!.install).toHaveBeenCalledWith({ pluginId: 'test@m', scope: 'user' }); + expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); + }); + it('sets progress to error on failure', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); (api.plugins!.install as ReturnType).mockResolvedValue({ @@ -878,6 +953,33 @@ describe('extensionsSlice', () => { ); }); + it('does not block MCP install when a usable runtime status already exists during background refresh', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true }); + (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'user', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.install).toHaveBeenCalledWith({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'user', + envValues: {}, + headers: [], + }); + expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'success', + ); + }); + it('does not restore idle state after project switch clears a pending project-scope success timer', async () => { vi.useFakeTimers(); store.setState({ @@ -1032,6 +1134,9 @@ describe('extensionsSlice', () => { }); it('keeps saved API keys updated when provider status refresh fails', async () => { + store.setState({ + appConfig: makeAppConfig(false), + }); (api.apiKeys!.save as ReturnType).mockResolvedValue({ id: 'k1', name: 'Codex key', @@ -1118,6 +1223,9 @@ describe('extensionsSlice', () => { }); it('keeps local API key state updated when provider status refresh fails after delete', async () => { + store.setState({ + appConfig: makeAppConfig(false), + }); store.setState({ apiKeys: [ { diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index baa7acd9..dac0984a 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -41,7 +41,7 @@ describe('resolveMemberRuntimeSummary', () => { const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-7', runtimeAlive: true }); expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe( - 'Anthropic · Opus 4.7 · Medium · Codex native' + 'Anthropic · Opus 4.7 · Medium · Codex' ); }); @@ -62,7 +62,7 @@ describe('resolveMemberRuntimeSummary', () => { }); expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe( - '5.4 Mini · Medium · Codex native' + '5.4 Mini · Medium · Codex' ); }); @@ -79,7 +79,7 @@ describe('resolveMemberRuntimeSummary', () => { }; expect(resolveMemberRuntimeSummary(member, undefined, undefined, runtimeEntry)).toBe( - '5.4 Mini · Medium · Codex native · 256.0 MB' + '5.4 Mini · Medium · Codex · 256.0 MB' ); }); @@ -98,7 +98,7 @@ describe('resolveMemberRuntimeSummary', () => { }, undefined ) - ).toBe('5.4 Mini · Medium · Codex native'); + ).toBe('5.4 Mini · Medium · Codex'); }); it('normalizes persisted legacy Codex lanes to the native runtime summary', () => { @@ -116,6 +116,6 @@ describe('resolveMemberRuntimeSummary', () => { }, undefined ) - ).toBe('5.4 Mini · Medium · Codex native'); + ).toBe('5.4 Mini · Medium · Codex'); }); }); diff --git a/test/renderer/utils/refreshCliStatus.test.ts b/test/renderer/utils/refreshCliStatus.test.ts new file mode 100644 index 00000000..52c195f2 --- /dev/null +++ b/test/renderer/utils/refreshCliStatus.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; + +describe('refreshCliStatusForCurrentMode', () => { + it('uses provider-first bootstrap when multimodel is enabled', async () => { + const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); + const fetchCliStatus = vi.fn().mockResolvedValue(undefined); + + await refreshCliStatusForCurrentMode({ + multimodelEnabled: true, + bootstrapCliStatus, + fetchCliStatus, + }); + + expect(bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true }); + expect(fetchCliStatus).not.toHaveBeenCalled(); + }); + + it('falls back to legacy status fetch when multimodel is disabled', async () => { + const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); + const fetchCliStatus = vi.fn().mockResolvedValue(undefined); + + await refreshCliStatusForCurrentMode({ + multimodelEnabled: false, + bootstrapCliStatus, + fetchCliStatus, + }); + + expect(fetchCliStatus).toHaveBeenCalledTimes(1); + expect(bootstrapCliStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index a5cbb329..2c9b03b1 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -70,6 +70,24 @@ describe('teamModelAvailability', () => { ]); }); + it('hides 5.1 Codex Max on the ChatGPT subscription-backed path', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.1-codex-max'], { + authMethod: 'chatgpt', + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: 'chatgpt', + }, + }); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-max', providerStatus)).toBe(''); + expect(getTeamModelSelectionError('codex', 'gpt-5.1-codex-max', providerStatus)).toContain( + 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.' + ); + }); + it('builds Codex model options from the runtime list instead of the hardcoded fallback', () => { const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 7423826d..7f978266 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -88,6 +88,7 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessageSeverity).toBe('warning'); expect(presentation?.compactDetail).toBe('jack failed to start'); expect(presentation?.compactTone).toBe('warning'); + expect(presentation?.defaultLiveOutputOpen).toBe(false); }); it('surfaces the failed teammate reason after launch completes with errors', () => { diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 6b54cb09..7e57b360 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -290,6 +290,16 @@ describe('getExtensionActionDisableReason', () => { ).toContain('configured runtime'); }); + it('does not block extension actions during a background refresh when runtime status is already known', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: createDirectCliStatus(), + cliStatusLoading: true, + }) + ).toBeNull(); + }); + it('surfaces startup health-check failures separately from missing CLI', () => { expect( getExtensionActionDisableReason({