feat(codex): add app-server account management and polish native UX
This commit is contained in:
parent
8093201b78
commit
5683973c04
114 changed files with 17003 additions and 609 deletions
5195
docs/research/codex-app-server-account-feature-plan.md
Normal file
5195
docs/research/codex-app-server-account-feature-plan.md
Normal file
File diff suppressed because it is too large
Load diff
303
docs/research/codex-app-server-account-signoff.md
Normal file
303
docs/research/codex-app-server-account-signoff.md
Normal file
|
|
@ -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
|
||||
15
src/features/codex-account/contracts/api.ts
Normal file
15
src/features/codex-account/contracts/api.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { CodexAccountSnapshotDto } from './dto';
|
||||
|
||||
export interface CodexAccountElectronApi {
|
||||
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
|
||||
refreshCodexAccountSnapshot: (options?: {
|
||||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}) => Promise<CodexAccountSnapshotDto>;
|
||||
startCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
||||
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
|
||||
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
|
||||
onCodexAccountSnapshotChanged: (
|
||||
callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void
|
||||
) => () => void;
|
||||
}
|
||||
6
src/features/codex-account/contracts/channels.ts
Normal file
6
src/features/codex-account/contracts/channels.ts
Normal file
|
|
@ -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';
|
||||
83
src/features/codex-account/contracts/dto.ts
Normal file
83
src/features/codex-account/contracts/dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
3
src/features/codex-account/contracts/index.ts
Normal file
3
src/features/codex-account/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './dto';
|
||||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
3
src/features/codex-account/index.ts
Normal file
3
src/features/codex-account/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './contracts';
|
||||
export type { CodexLaunchReadinessResult } from './core/domain/evaluateCodexLaunchReadiness';
|
||||
export { evaluateCodexLaunchReadiness } from './core/domain/evaluateCodexLaunchReadiness';
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Logger, 'info' | 'warn' | 'error'>;
|
||||
|
||||
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<T>(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<void>; resolve: () => void } {
|
||||
let resolve: (() => void) | null = null;
|
||||
const promise = new Promise<void>((fulfill) => {
|
||||
resolve = fulfill;
|
||||
});
|
||||
|
||||
if (!resolve) {
|
||||
throw new Error('Failed to create deferred promise.');
|
||||
}
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexAccountFeatureFacade {
|
||||
getSnapshot(): Promise<CodexAccountSnapshotDto>;
|
||||
refreshSnapshot(options?: {
|
||||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto>;
|
||||
startChatgptLogin(): Promise<CodexAccountSnapshotDto>;
|
||||
cancelLogin(): Promise<CodexAccountSnapshotDto>;
|
||||
logout(): Promise<CodexAccountSnapshotDto>;
|
||||
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
|
||||
setMainWindow(window: BrowserWindow | null): void;
|
||||
getLaunchReadiness(): Promise<CodexLaunchReadinessResult>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
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<CodexAccountSnapshotDto> | null = null;
|
||||
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
|
||||
private lastKnownAccount: CodexLastKnownAccount | null = null;
|
||||
private lastKnownRateLimits: CodexLastKnownRateLimits | null = null;
|
||||
private mutationQueue: Promise<void> = 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<CodexAccountSnapshotDto> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
this.pendingRefreshOptions = mergeRefreshOptions(
|
||||
this.pendingRefreshOptions,
|
||||
normalizeRefreshOptions(options)
|
||||
);
|
||||
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.drainRefreshQueue().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async startChatgptLogin(): Promise<CodexAccountSnapshotDto> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
await this.runSerializedMutation(async () => {
|
||||
await this.loginSessionManager.cancel();
|
||||
});
|
||||
|
||||
return this.emitCurrentSnapshot();
|
||||
}
|
||||
|
||||
async logout(): Promise<CodexAccountSnapshotDto> {
|
||||
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<CodexLaunchReadinessResult> {
|
||||
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<void> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
if (!this.snapshotCache) {
|
||||
return this.refreshSnapshot();
|
||||
}
|
||||
|
||||
return this.setSnapshot({
|
||||
...this.snapshotCache,
|
||||
login: this.loginSessionManager.getState(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async publishLoggedOutSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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<CodexApiKeyAvailabilityDto> {
|
||||
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);
|
||||
}
|
||||
6
src/features/codex-account/main/index.ts
Normal file
6
src/features/codex-account/main/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
registerCodexAccountIpc,
|
||||
removeCodexAccountIpc,
|
||||
} from './adapters/input/ipc/registerCodexAccountIpc';
|
||||
export type { CodexAccountFeatureFacade } from './composition/createCodexAccountFeature';
|
||||
export { createCodexAccountFeature } from './composition/createCodexAccountFeature';
|
||||
|
|
@ -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<CodexAppServerGetAccountResponse>(
|
||||
'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<CodexAppServerGetAccountRateLimitsResponse> {
|
||||
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<CodexAppServerGetAccountRateLimitsResponse>(
|
||||
'account/rateLimits/read',
|
||||
undefined,
|
||||
ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async logout(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<CodexAppServerLogoutAccountResponse> {
|
||||
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<CodexAppServerLogoutAccountResponse>(
|
||||
'account/logout',
|
||||
undefined,
|
||||
ACCOUNT_LOGOUT_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export class CodexLoginSessionManager {
|
||||
private readonly listeners = new Set<CodexLoginStateListener>();
|
||||
private readonly settledListeners = new Set<CodexLoginSettledListener>();
|
||||
private state: CodexLoginStateDto = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
};
|
||||
private pendingStartToken: symbol | null = null;
|
||||
private activeSession: {
|
||||
session: CodexAppServerSession;
|
||||
loginId: string;
|
||||
disposeNotificationListener: () => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | 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<void> {
|
||||
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<CodexAppServerLoginAccountResponse>(
|
||||
'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<void> {
|
||||
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<CodexAppServerCancelLoginAccountResponse>(
|
||||
'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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectCodexLocalAccountState(
|
||||
accountsDir = CODEX_ACCOUNTS_DIR
|
||||
): Promise<CodexLocalAccountState> {
|
||||
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<CodexAccountsRegistry>(
|
||||
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<CodexAuthFile>(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<boolean> {
|
||||
const state = await detectCodexLocalAccountState(accountsDir);
|
||||
return state.hasArtifacts;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
1
src/features/codex-account/preload/index.ts
Normal file
1
src/features/codex-account/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createCodexAccountBridge } from './createCodexAccountBridge';
|
||||
|
|
@ -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<void>;
|
||||
startChatgptLogin: () => Promise<boolean>;
|
||||
cancelChatgptLogin: () => Promise<boolean>;
|
||||
logout: () => Promise<boolean>;
|
||||
} {
|
||||
const electronMode = isElectronMode();
|
||||
const [snapshot, setSnapshot] = useState<CodexAccountSnapshotDto | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(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<CodexAccountSnapshotDto>): Promise<boolean> => {
|
||||
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]
|
||||
);
|
||||
}
|
||||
14
src/features/codex-account/renderer/index.ts
Normal file
14
src/features/codex-account/renderer/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<CliProviderStatus['availableBackends']>[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<CliProviderStatus['availableBackends']> {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
129
src/features/codex-account/renderer/rateLimitDisplay.ts
Normal file
129
src/features/codex-account/renderer/rateLimitDisplay.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<CliProviderId, string | null>();
|
||||
|
||||
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<string, unknown> {
|
||||
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<string | null> {
|
||||
|
|
|
|||
|
|
@ -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<string, never>;
|
||||
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<AppConfig>, 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<AppConfig>;
|
||||
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<AppConfig[K]>;
|
||||
}
|
||||
|
|
@ -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<AppConfig[K]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>(
|
||||
options: {
|
||||
binaryPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
initializeTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
label: string;
|
||||
experimentalApi?: boolean;
|
||||
optOutNotificationMethods?: string[];
|
||||
},
|
||||
handler: (session: CodexAppServerSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<CodexAppServerSession> {
|
||||
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<CodexAppServerSession> {
|
||||
const initializeResponse = await session.request<CodexAppServerInitializeResponse>(
|
||||
'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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface JsonRpcErrorPayload {
|
||||
code?: number;
|
||||
|
|
@ -19,9 +18,16 @@ interface JsonRpcResponse<T> {
|
|||
error?: JsonRpcErrorPayload;
|
||||
}
|
||||
|
||||
interface JsonRpcNotificationMessage {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcSession {
|
||||
request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>;
|
||||
notify(method: string, params?: unknown): Promise<void>;
|
||||
onNotification(listener: (method: string, params: unknown) => void): () => void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
|
|
@ -40,42 +46,48 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
|||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
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<T>(
|
||||
options: {
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
label: string;
|
||||
},
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
binaryPath: string,
|
||||
args: string[],
|
||||
requestTimeoutMs: number,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
async openSession(options: {
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
}): Promise<JsonRpcSession> {
|
||||
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<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
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<unknown>;
|
||||
let message: JsonRpcResponse<unknown> & JsonRpcNotificationMessage;
|
||||
try {
|
||||
message = JSON.parse(line) as JsonRpcResponse<unknown>;
|
||||
message = JSON.parse(line) as JsonRpcResponse<unknown> & 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<void> => {
|
||||
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<void>((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: <TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs = requestTimeoutMs
|
||||
): Promise<TResult> =>
|
||||
new Promise<TResult>((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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/main/services/infrastructure/codexAppServer/index.ts
Normal file
29
src/main/services/infrastructure/codexAppServer/index.ts
Normal file
|
|
@ -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';
|
||||
113
src/main/services/infrastructure/codexAppServer/protocol.ts
Normal file
113
src/main/services/infrastructure/codexAppServer/protocol.ts
Normal file
|
|
@ -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<string, never>;
|
||||
|
||||
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<string, CodexAppServerRateLimitSnapshot | undefined> | 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;
|
||||
}
|
||||
|
|
@ -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<Record<CliProviderId, string>> = {
|
|||
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<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService = new ApiKeyService(),
|
||||
|
|
@ -58,13 +88,17 @@ export class ProviderConnectionService {
|
|||
return ProviderConnectionService.instance;
|
||||
}
|
||||
|
||||
setCodexAccountFeature(feature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | 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<string | null> {
|
||||
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<string[]> {
|
||||
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<CliProviderStatus> {
|
||||
return {
|
||||
...provider,
|
||||
|
|
@ -278,27 +366,57 @@ export class ProviderConnectionService {
|
|||
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
|
||||
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<CodexAccountSnapshotDto> {
|
||||
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<string | null> {
|
||||
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];
|
||||
|
||||
|
|
|
|||
86
src/main/services/runtime/buildRuntimeBaseEnv.ts
Normal file
86
src/main/services/runtime/buildRuntimeBaseEnv.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<Record<CliProviderId, string>>;
|
||||
}
|
||||
|
||||
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<ProviderAwareCliEnvResult> {
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
|
|
@ -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: ${
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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<string> => this.get<string>('/api/version');
|
||||
|
||||
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
refreshCodexAccountSnapshot = (_options?: {
|
||||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
startCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
logoutCodexAccount = (): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
onCodexAccountSnapshotChanged =
|
||||
(_callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void): (() => void) =>
|
||||
() =>
|
||||
undefined;
|
||||
|
||||
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
|
||||
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{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.`}
|
||||
</span>
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatCodexRemainingPercent,
|
||||
formatCodexWindowDuration,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
normalizeCodexResetTimestamp,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
|
|
@ -33,6 +40,8 @@ import { useStore } from '@renderer/store';
|
|||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -69,6 +78,45 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
|
|||
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
|
||||
interface CodexDashboardRateLimitItem {
|
||||
label: string;
|
||||
remaining: string;
|
||||
resetsAt: string;
|
||||
}
|
||||
|
||||
function getCodexDashboardHint(provider: CliProviderStatus): string | null {
|
||||
if (provider.providerId !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codex = provider.connection?.codex;
|
||||
if (!codex || codex.managedAccount?.type === 'chatgpt') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usageHint = codex.localActiveChatgptAccountPresent
|
||||
? 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.'
|
||||
: codex.localAccountArtifactsPresent
|
||||
? 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.'
|
||||
: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
|
||||
if (
|
||||
provider.connection?.configuredAuthMode === 'chatgpt' &&
|
||||
provider.connection.apiKeyConfigured
|
||||
) {
|
||||
return `${usageHint} API key fallback is available if you switch auth mode.`;
|
||||
}
|
||||
|
||||
if (provider.connection?.configuredAuthMode === 'auto' && provider.connection.apiKeyConfigured) {
|
||||
return `${usageHint} Auto will keep using the API key until ChatGPT is connected.`;
|
||||
}
|
||||
|
||||
return provider.connection?.configuredAuthMode === 'chatgpt' ? usageHint : null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
|
@ -83,7 +131,13 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null
|
|||
);
|
||||
};
|
||||
|
||||
const InstallCompletedNotice = ({ version }: { version: string | null }): React.JSX.Element => (
|
||||
const InstallCompletedNotice = ({
|
||||
version,
|
||||
runtimeDisplayName,
|
||||
}: {
|
||||
version: string | null;
|
||||
runtimeDisplayName: string;
|
||||
}): React.JSX.Element => (
|
||||
<div
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{
|
||||
|
|
@ -93,7 +147,7 @@ const InstallCompletedNotice = ({ version }: { version: string | null }): React.
|
|||
>
|
||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||
<span className="text-sm" style={{ color: '#4ade80' }}>
|
||||
Successfully installed Claude CLI v{version ?? 'latest'}
|
||||
Successfully installed {runtimeDisplayName} v{version ?? 'latest'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -199,8 +253,10 @@ const CliCheckingSpinner = ({
|
|||
|
||||
interface InstalledBannerProps {
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
|
||||
sourceProviderMap: Map<CliProviderId, CliProviderStatus>;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
codexSnapshotPending: boolean;
|
||||
cliStatusError: string | null;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
|
|
@ -316,6 +372,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 getApiKeyActionRequiredProviders(
|
||||
providers: readonly CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
|
|
@ -331,9 +415,75 @@ function formatRuntimeLabel(
|
|||
return null;
|
||||
}
|
||||
|
||||
const runtimeLabel = getHumanRuntimeDisplayName(cliStatus);
|
||||
return cliStatus.showVersionDetails && cliStatus.installedVersion
|
||||
? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}`
|
||||
: cliStatus.displayName;
|
||||
? `${runtimeLabel} v${cliStatus.installedVersion ?? 'unknown'}`
|
||||
: runtimeLabel;
|
||||
}
|
||||
|
||||
function isCodexSubscriptionActive(
|
||||
connection: CliProviderStatus['connection'] | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
connection?.codex?.effectiveAuthMode === 'chatgpt' &&
|
||||
(connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCodexRateLimitLabel(
|
||||
fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left',
|
||||
windowDurationMins: number | null | undefined
|
||||
): string {
|
||||
const duration = formatCodexWindowDuration(windowDurationMins);
|
||||
return duration ? `${duration} left` : fallbackTitle;
|
||||
}
|
||||
|
||||
function formatCodexDashboardResetTime(timestampSeconds: number | null | undefined): string {
|
||||
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
|
||||
if (!normalized) {
|
||||
return 'reset unknown';
|
||||
}
|
||||
|
||||
return new Date(normalized).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getCodexDashboardRateLimits(
|
||||
provider: CliProviderStatus
|
||||
): CodexDashboardRateLimitItem[] | null {
|
||||
if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rateLimits = provider.connection?.codex?.rateLimits;
|
||||
if (!rateLimits?.primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: CodexDashboardRateLimitItem[] = [];
|
||||
const primaryRemaining = formatCodexRemainingPercent(rateLimits.primary.usedPercent) ?? 'Unknown';
|
||||
items.push({
|
||||
label: buildCodexRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins),
|
||||
remaining: primaryRemaining,
|
||||
resetsAt: formatCodexDashboardResetTime(rateLimits.primary.resetsAt),
|
||||
});
|
||||
|
||||
if (rateLimits.secondary) {
|
||||
items.push({
|
||||
label: buildCodexRateLimitLabel(
|
||||
rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left',
|
||||
rateLimits.secondary.windowDurationMins
|
||||
),
|
||||
remaining: formatCodexRemainingPercent(rateLimits.secondary.usedPercent) ?? 'Unknown',
|
||||
resetsAt: formatCodexDashboardResetTime(rateLimits.secondary.resetsAt),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function formatRuntimeAuthSummary(
|
||||
|
|
@ -390,8 +540,10 @@ function hasVisibleAuthenticatedMultimodelProvider(
|
|||
|
||||
const InstalledBanner = ({
|
||||
cliStatus,
|
||||
sourceProviderMap,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
cliStatusError,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
|
|
@ -520,16 +672,31 @@ const InstalledBanner = ({
|
|||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
{visibleProviders.map((provider) => {
|
||||
const statusText = formatProviderStatusText(provider);
|
||||
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
||||
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
|
||||
? getProviderCurrentRuntimeSummary(provider)
|
||||
: getProviderRuntimeBackendSummary(provider);
|
||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
|
||||
const codexDashboardHint = getCodexDashboardHint(provider);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider,
|
||||
provider
|
||||
);
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||
maskNegativeBootstrapState;
|
||||
const showInlineCodexAccessoryRow =
|
||||
!showSkeleton &&
|
||||
provider.providerId === 'codex' &&
|
||||
provider.models.length > 0 &&
|
||||
Boolean(codexDashboardRateLimits?.length);
|
||||
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
||||
const hasDetailContent = Boolean(
|
||||
(provider.backend?.label && !runtimeSummary) ||
|
||||
runtimeSummary ||
|
||||
|
|
@ -562,7 +729,7 @@ const InstalledBanner = ({
|
|||
<span
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
||||
color: getProviderStatusColor(statusText, provider.authenticated),
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
|
|
@ -592,6 +759,90 @@ const InstalledBanner = ({
|
|||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{showInlineCodexAccessoryRow ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
/>
|
||||
{codexDashboardRateLimits!.map((item) => (
|
||||
<div
|
||||
key={`${provider.providerId}-${item.label}`}
|
||||
className="rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.2)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
|
||||
{item.remaining}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[10px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={item.resetsAt}
|
||||
>
|
||||
• resets {item.resetsAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showSkeleton &&
|
||||
codexDashboardRateLimits &&
|
||||
codexDashboardRateLimits.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{codexDashboardRateLimits.map((item) => (
|
||||
<div
|
||||
key={`${provider.providerId}-${item.label}`}
|
||||
className="rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.2)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
|
||||
{item.remaining}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[10px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={item.resetsAt}
|
||||
>
|
||||
• resets {item.resetsAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showSkeleton && codexDashboardHint ? (
|
||||
<div
|
||||
className="mt-2 rounded-md border px-2.5 py-2 text-[11px]"
|
||||
style={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.025)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{codexDashboardHint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<button
|
||||
|
|
@ -619,7 +870,7 @@ const InstalledBanner = ({
|
|||
<LogOut className="size-3" />
|
||||
{disconnectAction.label}
|
||||
</button>
|
||||
) : shouldShowProviderConnectAction(provider) ? (
|
||||
) : !showSkeleton && shouldShowProviderConnectAction(provider) ? (
|
||||
<button
|
||||
onClick={() => onProviderLogin(provider.providerId)}
|
||||
disabled={actionDisabled}
|
||||
|
|
@ -635,7 +886,7 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
<button
|
||||
onClick={() => onProviderRefresh(provider.providerId)}
|
||||
disabled={cliStatusLoading || providerLoading}
|
||||
disabled={providerLoading}
|
||||
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
|
|
@ -644,16 +895,12 @@ const InstalledBanner = ({
|
|||
title={`Re-check ${provider.displayName}`}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
cliStatusLoading || providerLoading
|
||||
? 'size-[11px] animate-spin'
|
||||
: 'size-[11px]'
|
||||
}
|
||||
className={providerLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
{!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
@ -712,10 +959,56 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
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 ? (
|
||||
<>
|
||||
<ProviderRuntimeSettingsDialog
|
||||
open={manageDialogOpen}
|
||||
|
|
@ -915,17 +1216,17 @@ export const CliStatusBanner = (): React.JSX.Element | 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 && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
|
|
@ -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 }}
|
||||
>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Claude CLI status will be checked in the background.
|
||||
{runtimeDisplayName} status will be checked in the background.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
|
|
@ -1006,9 +1307,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (multimodelEnabled) {
|
||||
return (
|
||||
<InstalledBanner
|
||||
cliStatus={createLoadingMultimodelCliStatus()}
|
||||
cliStatus={renderCliStatus ?? createLoadingMultimodelCliStatus()}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1045,7 +1348,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Downloading Claude CLI...
|
||||
Downloading {runtimeDisplayName}...
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -1104,7 +1407,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Installing Claude CLI...
|
||||
Installing {runtimeDisplayName}...
|
||||
</span>
|
||||
</div>
|
||||
<TerminalLogPanel chunks={installerRawChunks} />
|
||||
|
|
@ -1115,10 +1418,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// ── Completed ──────────────────────────────────────────────────────────
|
||||
if (
|
||||
installerState === 'completed' &&
|
||||
!cliStatus?.installed &&
|
||||
!(cliStatus?.binaryPath && cliStatus?.launchError)
|
||||
!renderCliStatus?.installed &&
|
||||
!(renderCliStatus?.binaryPath && renderCliStatus?.launchError)
|
||||
) {
|
||||
return <InstallCompletedNotice version={completedVersion} />;
|
||||
return (
|
||||
<InstallCompletedNotice version={completedVersion} runtimeDisplayName={runtimeDisplayName} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -1134,12 +1439,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
// ── Idle state with status ─────────────────────────────────────────────
|
||||
if (!cliStatus) return null;
|
||||
if (!renderCliStatus) return null;
|
||||
const cliLaunchIssue =
|
||||
!cliStatus.installed && Boolean(cliStatus.binaryPath && cliStatus.launchError);
|
||||
!renderCliStatus.installed &&
|
||||
Boolean(renderCliStatus.binaryPath && renderCliStatus.launchError);
|
||||
|
||||
// Not installed — red error banner
|
||||
if (!cliStatus.installed) {
|
||||
if (!renderCliStatus.installed) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 p-4"
|
||||
|
|
@ -1151,23 +1457,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
|
||||
{cliLaunchIssue
|
||||
? 'Claude CLI was found but failed to start'
|
||||
: 'Claude CLI is required'}
|
||||
? `${runtimeDisplayName} was found but failed to start`
|
||||
: `${runtimeDisplayName} is required`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{cliLaunchIssue
|
||||
? 'The app found a Claude CLI binary, but its startup health check failed. Repair or reinstall it, then retry.'
|
||||
: 'Claude CLI is required for team provisioning and session management. Install it to get started.'}
|
||||
? `The app found the configured ${runtimeDisplayName}, but its startup health check failed. Repair or reinstall it, then retry.`
|
||||
: `${runtimeDisplayName} is required for team provisioning and session management. Install it to get started.`}
|
||||
</p>
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
|
||||
<p
|
||||
className="mt-2 break-all font-mono text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{cliStatus.binaryPath}
|
||||
{renderCliStatus.binaryPath}
|
||||
</p>
|
||||
)}
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
{cliLaunchIssue && renderCliStatus.launchError && (
|
||||
<div
|
||||
className="mt-2 rounded border px-2 py-1.5 font-mono text-[11px]"
|
||||
style={{
|
||||
|
|
@ -1176,7 +1482,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{cliStatus.launchError}
|
||||
{renderCliStatus.launchError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1190,7 +1496,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<RefreshCw className="size-4" />
|
||||
Re-check
|
||||
</button>
|
||||
{cliStatus.supportsSelfUpdate ? (
|
||||
{renderCliStatus.supportsSelfUpdate ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
|
|
@ -1198,13 +1504,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{cliLaunchIssue ? 'Reinstall Claude CLI' : 'Install Claude CLI'}
|
||||
{cliLaunchIssue
|
||||
? `Reinstall ${runtimeDisplayName}`
|
||||
: `Install ${runtimeDisplayName}`}
|
||||
</button>
|
||||
) : (
|
||||
<p className="max-w-40 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1246,12 +1556,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1403,8 +1717,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<li>
|
||||
Open your terminal and run:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth status`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth status`
|
||||
: 'your configured CLI auth status command'}
|
||||
</code>{' '}
|
||||
— check if it shows "Logged in"
|
||||
|
|
@ -1412,25 +1726,25 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<li>
|
||||
If it says logged in but the app doesn't see it, try:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth logout`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth logout`
|
||||
: 'the runtime logout command'}
|
||||
</code>{' '}
|
||||
then{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth login`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth login`
|
||||
: 'the runtime login command'}
|
||||
</code>{' '}
|
||||
again
|
||||
</li>
|
||||
<li>
|
||||
Make sure the CLI in your terminal is the same runtime the app uses
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
|
||||
<span>
|
||||
:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.binaryPath}
|
||||
{renderCliStatus.binaryPath}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -1444,10 +1758,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
)}
|
||||
</div>
|
||||
{installedAuxiliaryUi}
|
||||
{showLoginTerminal && cliStatus.binaryPath && (
|
||||
{showLoginTerminal && renderCliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} Login`}
|
||||
command={cliStatus.binaryPath}
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={['auth', 'login']}
|
||||
onClose={() => {
|
||||
setShowLoginTerminal(false);
|
||||
|
|
@ -1493,9 +1807,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
return (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -20,12 +24,15 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import {
|
||||
formatCliExtensionCapabilityStatus,
|
||||
getVisibleMultimodelProviders,
|
||||
isMultimodelRuntimeStatus,
|
||||
} from '@renderer/utils/multimodelProviderVisibility';
|
||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
import {
|
||||
|
|
@ -97,10 +104,19 @@ function isProviderCapabilityCardLoading(
|
|||
);
|
||||
}
|
||||
|
||||
function isCodexSnapshotPending(
|
||||
provider: CliProviderStatus,
|
||||
codexSnapshotPending: boolean
|
||||
): boolean {
|
||||
return provider.providerId === 'codex' && codexSnapshotPending;
|
||||
}
|
||||
|
||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||
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 (
|
||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
|
|
@ -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 (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
|
|
@ -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.'}
|
||||
</p>
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
{cliLaunchIssue && effectiveCliStatus.launchError && (
|
||||
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
||||
{cliStatus.launchError}
|
||||
{effectiveCliStatus.launchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -296,16 +378,18 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isMultimodel && !cliStatus.authLoggedIn) {
|
||||
if (!isMultimodel && !effectiveCliStatus.authLoggedIn) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p>
|
||||
<p className="text-sm font-medium text-amber-300">{runtimeDisplayName} needs sign-in</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||
|
|
@ -332,7 +416,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
if (isProviderCapabilityCardLoading(provider, providerLoading)) {
|
||||
if (
|
||||
isProviderCapabilityCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending)
|
||||
) {
|
||||
return (
|
||||
<ProviderCapabilityCardSkeleton
|
||||
key={provider.providerId}
|
||||
|
|
@ -410,15 +497,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p>
|
||||
<p className="text-sm font-medium text-emerald-300">{runtimeDisplayName} is ready</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Plugins can be installed from this page
|
||||
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
|
||||
{effectiveCliStatus.installedVersion
|
||||
? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
effectiveCliStatus,
|
||||
effectiveCliStatusLoading,
|
||||
openDashboard,
|
||||
]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
@ -526,6 +622,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
clearFilters={tabState.clearFilters}
|
||||
hasActiveFilters={tabState.hasActiveFilters}
|
||||
setPluginSort={tabState.setPluginSort}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -539,6 +637,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
mcpSearchWarnings={tabState.mcpSearchWarnings}
|
||||
selectedMcpServerId={tabState.selectedMcpServerId}
|
||||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -565,6 +665,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
projectPath={projectPath}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ApiKeyEntry | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Scope>(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}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<div>
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? 'Configured runtime not available'
|
||||
: 'Claude CLI not installed'}
|
||||
? `${runtimeLabel} not available`
|
||||
: `${runtimeLabel} not installed`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -458,6 +467,8 @@ export const McpServersPanel = ({
|
|||
diagnostic={getDiagnostic(server)}
|
||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||
onClick={setSelectedMcpServerId}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -488,6 +499,8 @@ export const McpServersPanel = ({
|
|||
projectPath={projectPath}
|
||||
open={selectedMcpServerId !== null}
|
||||
onClose={() => setSelectedMcpServerId(null)}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
|
||||
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}` : ''}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -407,6 +422,8 @@ export const PluginsPanel = ({
|
|||
plugin={plugin}
|
||||
index={index}
|
||||
onClick={setSelectedPluginId}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -418,6 +435,8 @@ export const PluginsPanel = ({
|
|||
open={selectedPluginId !== null}
|
||||
onClose={() => setSelectedPluginId(null)}
|
||||
projectPath={projectPath}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(selectedSkillId);
|
||||
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
|
||||
{effectiveCliStatus?.flavor === 'agent_teams_orchestrator' && (
|
||||
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
|
||||
Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
|
||||
{skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3"
|
||||
style={{
|
||||
borderColor: accentStyles.borderColor,
|
||||
backgroundColor: accentStyles.backgroundColor,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
color: accentStyles.badgeColor,
|
||||
backgroundColor: accentStyles.badgeBackground,
|
||||
}}
|
||||
>
|
||||
{remainingValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{usedLabel}
|
||||
</div>
|
||||
<div
|
||||
className="text-3xl font-semibold leading-none"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{usedValue}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{remainingValue} left
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md border px-3 py-2"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{resetLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{resetValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
setConnectionError(null);
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (!success && codexAccount.error) {
|
||||
setConnectionError(codexAccount.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexCancelLogin = async (): Promise<void> => {
|
||||
setConnectionError(null);
|
||||
const success = await codexAccount.cancelChatgptLogin();
|
||||
if (success) {
|
||||
await onRefreshProvider?.('codex');
|
||||
} else if (codexAccount.error) {
|
||||
setConnectionError(codexAccount.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexLogout = async (): Promise<void> => {
|
||||
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 = ({
|
|||
<span
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: selectedProvider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
||||
color: getProviderStatusColor(
|
||||
selectedProvider.authenticated
|
||||
? `Using ${formatProviderAuthMethodLabelForProvider(
|
||||
selectedProvider.providerId,
|
||||
selectedProvider.authMethod
|
||||
)}`
|
||||
: selectedProvider.statusMessage || 'Not connected',
|
||||
selectedProvider.authenticated
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedProvider.authenticated
|
||||
|
|
@ -863,6 +1188,281 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedProvider.providerId === 'codex' ? (
|
||||
<div
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
ChatGPT account
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Manage the local Codex app-server account session that powers
|
||||
subscription-backed native launches.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexAccountRefresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{codexLoginPending ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexCancelLogin()}
|
||||
>
|
||||
Cancel login
|
||||
</Button>
|
||||
) : codexConnection?.managedAccount?.type === 'chatgpt' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexLogout()}
|
||||
>
|
||||
Disconnect account
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexStartLogin()}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
Connect ChatGPT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color:
|
||||
codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor:
|
||||
codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
? 'Connected'
|
||||
: codexLoginPending
|
||||
? 'Login in progress'
|
||||
: 'Not connected'}
|
||||
</span>
|
||||
{codexConnection ? (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color:
|
||||
codexConnection.appServerState === 'healthy'
|
||||
? '#86efac'
|
||||
: codexConnection.appServerState === 'degraded'
|
||||
? '#fbbf24'
|
||||
: '#fca5a5',
|
||||
backgroundColor:
|
||||
codexConnection.appServerState === 'healthy'
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: codexConnection.appServerState === 'degraded'
|
||||
? 'rgba(245, 158, 11, 0.12)'
|
||||
: 'rgba(248, 113, 113, 0.08)',
|
||||
}}
|
||||
>
|
||||
App-server: {codexConnection.appServerState}
|
||||
</span>
|
||||
) : null}
|
||||
{codexConnection?.managedAccount?.planType ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Plan: {codexConnection.managedAccount.planType}
|
||||
</span>
|
||||
) : null}
|
||||
{codexConnection?.managedAccount?.email ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{codexConnection.managedAccount.email}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{codexAccountPanelHint ? (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{codexAccountPanelHint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{codexConnection?.rateLimits ? (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
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.`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<CodexRateLimitWindowCard
|
||||
title="Primary window"
|
||||
usedLabel={formatCodexUsageWindowLabel(
|
||||
'Primary used',
|
||||
codexConnection.rateLimits.primary?.windowDurationMins
|
||||
)}
|
||||
usedValue={formatCodexUsagePercent(
|
||||
codexConnection.rateLimits.primary?.usedPercent
|
||||
)}
|
||||
remainingValue={
|
||||
formatCodexRemainingPercent(
|
||||
codexConnection.rateLimits.primary?.usedPercent
|
||||
) ?? 'Remaining unknown'
|
||||
}
|
||||
resetLabel={formatCodexResetWindowLabel(
|
||||
'Primary reset',
|
||||
codexConnection.rateLimits.primary?.windowDurationMins
|
||||
)}
|
||||
resetValue={formatCodexResetDateTime(
|
||||
codexConnection.rateLimits.primary?.resetsAt
|
||||
)}
|
||||
accent="primary"
|
||||
/>
|
||||
|
||||
{codexConnection.rateLimits.secondary ? (
|
||||
<CodexRateLimitWindowCard
|
||||
title={
|
||||
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
|
||||
? 'Weekly window'
|
||||
: 'Secondary window'
|
||||
}
|
||||
usedLabel={formatCodexUsageWindowLabel(
|
||||
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
|
||||
? 'Weekly used'
|
||||
: 'Secondary used',
|
||||
codexConnection.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
usedValue={formatCodexUsagePercent(
|
||||
codexConnection.rateLimits.secondary.usedPercent
|
||||
)}
|
||||
remainingValue={
|
||||
formatCodexRemainingPercent(
|
||||
codexConnection.rateLimits.secondary.usedPercent
|
||||
) ?? 'Remaining unknown'
|
||||
}
|
||||
resetLabel={formatCodexResetWindowLabel(
|
||||
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
|
||||
? 'Weekly reset'
|
||||
: 'Secondary reset',
|
||||
codexConnection.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
resetValue={formatCodexResetDateTime(
|
||||
codexConnection.rateLimits.secondary.resetsAt
|
||||
)}
|
||||
accent="secondary"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
Weekly window
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Weekly used (1w)
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-sm font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
Not reported
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Codex did not return a secondary window for this account snapshot.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Credits
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-sm font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{formatCodexCreditsValue(codexConnection.rateLimits.credits)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="max-w-md text-[11px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Credits are shown separately from window-based subscription usage and
|
||||
may be unavailable for plan-backed ChatGPT sessions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showApiKeySection && apiKeyConfig ? (
|
||||
<div
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
|
|||
const AUTH_MODE_LABELS: Record<CliProviderAuthMode, string> = {
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -332,7 +332,9 @@ export function useSettingsHandlers({
|
|||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: provider.authenticated
|
||||
? '#4ade80'
|
||||
: 'var(--color-text-muted)',
|
||||
color: getProviderStatusColor(
|
||||
statusText,
|
||||
provider.authenticated
|
||||
),
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
{showSkeleton ? (
|
||||
{effectiveShowSkeleton ? (
|
||||
<ProviderDetailSkeleton />
|
||||
) : hasDetailContent ? (
|
||||
<div
|
||||
|
|
@ -561,7 +649,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<LogOut className="size-3" />
|
||||
{disconnectAction.label}
|
||||
</button>
|
||||
) : shouldShowProviderConnectAction(provider) ? (
|
||||
) : !effectiveShowSkeleton &&
|
||||
shouldShowProviderConnectAction(provider) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
|
|
@ -583,7 +672,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
{!effectiveShowSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
@ -619,8 +708,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
|
||||
{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`}
|
||||
</div>
|
||||
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
||||
<div className="break-all font-mono text-xs text-text-muted">
|
||||
|
|
@ -664,16 +753,16 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
>
|
||||
<Download className="size-3.5" />
|
||||
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
|
||||
? 'Reinstall Claude CLI'
|
||||
: 'Install Claude CLI'}
|
||||
? `Reinstall ${runtimeDisplayName}`
|
||||
: `Install ${runtimeDisplayName}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -779,9 +868,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{providerTerminal && cliStatus?.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
title={`${getRuntimeDisplayName(cliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
|
|
|
|||
|
|
@ -157,9 +157,7 @@ export const ProvisioningProgressBlock = ({
|
|||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
|
|
@ -36,12 +40,14 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import {
|
||||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import {
|
||||
getTeamModelSelectionError,
|
||||
normalizeExplicitTeamModelForUi,
|
||||
|
|
@ -309,7 +315,25 @@ export const CreateTeamDialog = ({
|
|||
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 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<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
}, [effectiveCliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
}, [effectiveCliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<TeamModelSelectorProps> = ({
|
|||
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<TeamModelSelectorProps> = ({
|
|||
};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], CliInstalle
|
|||
return;
|
||||
}
|
||||
|
||||
const nextProviderLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
metadata.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
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<AppState, [], [], CliInstalle
|
|||
|
||||
return {
|
||||
cliStatus: {
|
||||
...state.cliStatus,
|
||||
flavor: metadata.flavor,
|
||||
displayName: metadata.displayName,
|
||||
supportsSelfUpdate: metadata.supportsSelfUpdate,
|
||||
showVersionDetails: metadata.showVersionDetails,
|
||||
showBinaryPath: metadata.showBinaryPath,
|
||||
installed: metadata.installed,
|
||||
installedVersion: metadata.installedVersion,
|
||||
binaryPath: metadata.binaryPath,
|
||||
...metadata,
|
||||
launchError: metadata.launchError ?? null,
|
||||
latestVersion: metadata.latestVersion,
|
||||
updateAvailable: metadata.updateAvailable,
|
||||
authStatusChecking:
|
||||
metadata.installed &&
|
||||
state.cliStatus.providers.some(
|
||||
(provider) => 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState, 'appConfig' | 'bootstrapCliStatus' | 'fetchCliStatus'>
|
||||
): Promise<void> {
|
||||
return refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true,
|
||||
bootstrapCliStatus: state.bootstrapCliStatus,
|
||||
fetchCliStatus: state.fetchCliStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function getExtensionActionCliStatusState(
|
||||
state: Pick<AppState, 'cliStatus' | 'cliStatusLoading'>
|
||||
): Pick<Parameters<typeof getExtensionActionDisableReason>[0], 'cliStatus' | 'cliStatusLoading'> {
|
||||
return {
|
||||
cliStatus: state.cliStatus,
|
||||
cliStatusLoading: state.cliStatus === null && state.cliStatusLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
get
|
||||
|
|
@ -886,22 +907,21 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
: { ...request, projectPath: effectiveProjectPath };
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const cliStatus = get().cliStatus;
|
||||
const extensionCliStatusState = getExtensionActionCliStatusState(get());
|
||||
const preflightError =
|
||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||
: getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...extensionCliStatusState,
|
||||
section: 'plugins',
|
||||
});
|
||||
|
||||
|
|
@ -977,9 +997,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||
}
|
||||
|
|
@ -987,8 +1007,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'plugins',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
|
|
@ -1055,9 +1074,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
|
|
@ -1065,8 +1084,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const installDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
|
|
@ -1130,9 +1148,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
|
|
@ -1140,8 +1158,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const installDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
|
|
@ -1199,9 +1216,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||
}
|
||||
|
|
@ -1209,8 +1226,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
|
|
@ -1309,7 +1325,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}));
|
||||
}
|
||||
|
||||
await get().fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(get());
|
||||
const refreshError = get().cliStatusError;
|
||||
if (refreshError) {
|
||||
warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`);
|
||||
|
|
@ -1333,7 +1349,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
set((prev) => ({
|
||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||
}));
|
||||
await get().fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(get());
|
||||
const refreshError = get().cliStatusError;
|
||||
set({
|
||||
apiKeysError: refreshError
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
src/renderer/utils/refreshCliStatus.ts
Normal file
17
src/renderer/utils/refreshCliStatus.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface RefreshCliStatusOptions {
|
||||
multimodelEnabled: boolean;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
}: RefreshCliStatusOptions): Promise<void> {
|
||||
if (multimodelEnabled) {
|
||||
return bootstrapCliStatus({ multimodelEnabled: true });
|
||||
}
|
||||
|
||||
return fetchCliStatus();
|
||||
}
|
||||
26
src/renderer/utils/runtimeDisplayName.ts
Normal file
26
src/renderer/utils/runtimeDisplayName.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { CliFlavor, CliInstallationStatus } from '@shared/types';
|
||||
|
||||
const MULTIMODEL_RUNTIME_LABEL = 'Multimodel runtime';
|
||||
|
||||
export function getRuntimeDisplayName(
|
||||
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'displayName'> | 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';
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
getSessions: (projectId: string) => Promise<Session[]>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -328,7 +328,9 @@ export interface AppConfig {
|
|||
anthropic: {
|
||||
authMode: 'auto' | 'oauth' | 'api_key';
|
||||
};
|
||||
codex: Record<string, never>;
|
||||
codex: {
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
};
|
||||
};
|
||||
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */
|
||||
runtime: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const openExternalMock = vi.fn<(url: string) => Promise<void>>();
|
||||
|
||||
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<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createSession(overrides?: {
|
||||
request?: ReturnType<typeof vi.fn>;
|
||||
close?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
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<CodexAppServerSession>();
|
||||
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<CodexAppServerSession>();
|
||||
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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof import('../../../../src/main/utils/shellEnv')>();
|
||||
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<void> {
|
||||
await loginStartMock();
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await loginCancelMock();
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
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<Partial<CodexAccountSnapshotDto>>({
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<CodexAccountSnapshotDto>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> };
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof buildEnrichedEnvMock>) => buildEnrichedEnvMock(...args),
|
||||
|
|
@ -42,6 +43,9 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () =>
|
|||
applyConfiguredConnectionEnvMock(...args),
|
||||
applyAllConfiguredConnectionEnv: (...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>) =>
|
||||
applyAllConfiguredConnectionEnvMock(...args),
|
||||
getConfiguredConnectionLaunchArgs: (
|
||||
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
|
||||
) => getConfiguredConnectionLaunchArgsMock(...args),
|
||||
getConfiguredConnectionIssues: (...args: Parameters<typeof getConfiguredConnectionIssuesMock>) =>
|
||||
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"}}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
40
test/renderer/api/httpClient.codexAccount.test.ts
Normal file
40
test/renderer/api/httpClient.codexAccount.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown> | 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<typeof import('@features/codex-account/renderer')>();
|
||||
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 () => {
|
||||
|
|
|
|||
104
test/renderer/components/common/CliInstallWarningBanner.test.tsx
Normal file
104
test/renderer/components/common/CliInstallWarningBanner.test.tsx
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof vi.fn>;
|
||||
bootstrapCliStatus: ReturnType<typeof vi.fn>;
|
||||
fetchCliStatus: ReturnType<typeof vi.fn>;
|
||||
fetchApiKeys: ReturnType<typeof vi.fn>;
|
||||
fetchSkillsCatalog: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -18,13 +20,30 @@ interface StoreState {
|
|||
cliStatus: CliInstallationStatus | null;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Record<string, boolean>;
|
||||
appConfig: {
|
||||
general: {
|
||||
multimodelEnabled: boolean;
|
||||
};
|
||||
};
|
||||
openDashboard: ReturnType<typeof vi.fn>;
|
||||
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<typeof import('@features/codex-account/renderer')>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
318
test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts
Normal file
318
test/renderer/components/extensions/apikeys/ApiKeysPanel.test.ts
Normal file
|
|
@ -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<typeof vi.fn>;
|
||||
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<typeof import('@features/codex-account/renderer')>();
|
||||
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<SVGSVGElement>) => 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
mcpDiagnostics: Record<string, never>;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue