From 1e50b66407726604d7fd014927b402057436e32a Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:21:05 +0800 Subject: [PATCH 01/12] fix: finish Codex provider follow-up for #677 --- application/i18n/locales/en.ts | 4 ++-- application/i18n/locales/zh-CN.ts | 4 ++-- components/ai/hooks/useAIChatStreaming.ts | 11 ++++----- components/settings/tabs/SettingsAITab.tsx | 7 +++--- .../settings/tabs/ai/CodexConnectionCard.tsx | 6 ++--- electron/bridges/aiBridge.cjs | 23 +++++++++++++++---- infrastructure/ai/managedAgents.ts | 23 ++++++++++++++++++- 7 files changed, 54 insertions(+), 24 deletions(-) diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 816a646d9..1fc957b82 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -1744,7 +1744,7 @@ const en: Messages = { // AI Codex 'ai.codex': 'Codex', 'ai.codex.title': 'Codex CLI', - 'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).', + 'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.', 'ai.codex.detecting': 'Detecting...', 'ai.codex.notFound': 'Not found', 'ai.codex.awaitingLogin': 'Awaiting login', @@ -1760,7 +1760,7 @@ const en: Messages = { 'ai.codex.logout': 'Logout', 'ai.codex.connectChatGPT': 'Connect ChatGPT', 'ai.codex.refreshStatus': 'Refresh Status', - 'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.', + 'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.', // AI Claude Code 'ai.claude.title': 'Claude Code', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index f98bc5fb8..ab1307fe0 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1752,7 +1752,7 @@ const zhCN: Messages = { // AI Codex 'ai.codex': 'Codex', 'ai.codex.title': 'Codex CLI', - 'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。', + 'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。', 'ai.codex.detecting': '检测中...', 'ai.codex.notFound': '未找到', 'ai.codex.awaitingLogin': '等待登录', @@ -1768,7 +1768,7 @@ const zhCN: Messages = { 'ai.codex.logout': '退出登录', 'ai.codex.connectChatGPT': '连接 ChatGPT', 'ai.codex.refreshStatus': '刷新状态', - 'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。', + 'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。', // AI Claude Code 'ai.claude.title': 'Claude Code', diff --git a/components/ai/hooks/useAIChatStreaming.ts b/components/ai/hooks/useAIChatStreaming.ts index 2ac329e1e..01558d3ad 100644 --- a/components/ai/hooks/useAIChatStreaming.ts +++ b/components/ai/hooks/useAIChatStreaming.ts @@ -30,7 +30,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools'; import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor'; import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter'; import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter'; -import { matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents'; +import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents'; import { classifyError } from '../../../infrastructure/ai/errorClassifier'; // ------------------------------------------------------------------- @@ -556,16 +556,13 @@ export function useAIChatStreaming({ // avoiding plaintext key transit across the IPC boundary. // Resolve the correct provider based on agent type: // - Claude agent → anthropic provider (prefer over generic custom) - // - Codex agent → openai provider + // - Codex agent → openai provider (fallback to openai-compatible custom) const agentProviderId = (() => { if (matchesManagedAgentConfig(agentConfig, 'claude')) { - return ( - context.providers.find(p => p.providerId === 'anthropic' && p.enabled && p.apiKey)?.id - ?? context.providers.find(p => p.providerId === 'custom' && p.enabled && p.apiKey && p.baseURL)?.id - ); + return findManagedAgentProvider(context.providers, 'claude')?.id; } if (matchesManagedAgentConfig(agentConfig, 'codex')) { - return context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey)?.id; + return findManagedAgentProvider(context.providers, 'codex')?.id; } return undefined; })(); diff --git a/components/settings/tabs/SettingsAITab.tsx b/components/settings/tabs/SettingsAITab.tsx index 3b58c79d5..28bef73a8 100644 --- a/components/settings/tabs/SettingsAITab.tsx +++ b/components/settings/tabs/SettingsAITab.tsx @@ -18,6 +18,7 @@ import type { WebSearchConfig, } from "../../../infrastructure/ai/types"; import { + findManagedAgentProvider, getManagedAgentStoredPath, matchesManagedAgentConfig, type ManagedAgentKey, @@ -304,9 +305,7 @@ const SettingsAITab: React.FC = ({ .map((a) => ({ value: a.id, label: a.name, icon: })), ], [externalAgents, t]); - const hasOpenAiProviderKey = providers.some( - (provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey, - ); + const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex")); const refreshCodexIntegration = useCallback(async () => { const bridge = getBridge(); @@ -524,7 +523,7 @@ const SettingsAITab: React.FC = ({ integration={codexIntegration} loginSession={codexLoginSession} isLoading={isCodexLoading} - hasOpenAiProviderKey={hasOpenAiProviderKey} + hasCompatibleProvider={hasCodexCompatibleProvider} error={codexError} onRefresh={() => void refreshCodexIntegration()} onConnect={() => void handleStartCodexLogin()} diff --git a/components/settings/tabs/ai/CodexConnectionCard.tsx b/components/settings/tabs/ai/CodexConnectionCard.tsx index 22fa6b994..633830e30 100644 --- a/components/settings/tabs/ai/CodexConnectionCard.tsx +++ b/components/settings/tabs/ai/CodexConnectionCard.tsx @@ -15,7 +15,7 @@ export const CodexConnectionCard: React.FC<{ integration: CodexIntegrationStatus | null; loginSession: CodexLoginSession | null; isLoading: boolean; - hasOpenAiProviderKey: boolean; + hasCompatibleProvider: boolean; error: string | null; onRefresh: () => void; onConnect: () => void; @@ -31,7 +31,7 @@ export const CodexConnectionCard: React.FC<{ integration, loginSession, isLoading, - hasOpenAiProviderKey, + hasCompatibleProvider, error, onRefresh, onConnect, @@ -157,7 +157,7 @@ export const CodexConnectionCard: React.FC<{ - {hasOpenAiProviderKey && ( + {hasCompatibleProvider && (

{t('ai.codex.apiKeyHint')}

diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index 3d2e7e1e0..6deba9452 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -234,6 +234,21 @@ function resolveProviderApiKey(providerId) { }; } +function getAcpProviderAuthFingerprint(apiKey, provider) { + const parts = [ + typeof apiKey === "string" ? apiKey.trim() : "", + typeof provider?.id === "string" ? provider.id.trim() : "", + typeof provider?.providerId === "string" ? provider.providerId.trim() : "", + typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "", + ].filter(Boolean); + + if (parts.length === 0) { + return null; + } + + return getCodexAuthFingerprint(parts.join("\n")); +} + /** Check if TLS verification should be skipped for a given provider. */ function shouldSkipTLSVerify(providerId) { if (!providerId) return false; @@ -2275,11 +2290,9 @@ function registerHandlers(ipcMain) { } } - const authFingerprint = isCodexAgent - ? getCodexAuthFingerprint(apiKey) - : isClaudeAgent - ? getCodexAuthFingerprint(apiKey + (resolvedProvider?.provider?.baseURL || "")) - : null; + const authFingerprint = isCodexAgent || isClaudeAgent + ? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider) + : null; const mcpSnapshot = isCodexAgent ? await resolveCodexMcpSnapshot(sessionCwd) : { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) }; diff --git a/infrastructure/ai/managedAgents.ts b/infrastructure/ai/managedAgents.ts index c7439fc55..e225258d4 100644 --- a/infrastructure/ai/managedAgents.ts +++ b/infrastructure/ai/managedAgents.ts @@ -1,4 +1,4 @@ -import type { DiscoveredAgent, ExternalAgentConfig } from './types'; +import type { DiscoveredAgent, ExternalAgentConfig, ProviderConfig } from './types'; export type ManagedAgentKey = 'codex' | 'claude' | 'copilot'; @@ -67,3 +67,24 @@ export function getManagedAgentStoredPath( ); return fallbackAgent?.command ?? null; } + +export function findManagedAgentProvider( + providers: ProviderConfig[], + agentKey: ManagedAgentKey, +): ProviderConfig | undefined { + if (agentKey === 'codex') { + return ( + providers.find((provider) => provider.providerId === 'openai' && provider.enabled && !!provider.apiKey) + ?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL) + ); + } + + if (agentKey === 'claude') { + return ( + providers.find((provider) => provider.providerId === 'anthropic' && provider.enabled && !!provider.apiKey) + ?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL) + ); + } + + return undefined; +} From c2c6b265d4a3e7bc389a3917a9be9699fa7a8809 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:49:23 +0800 Subject: [PATCH 02/12] feat: detect user's ~/.codex/config.toml custom provider as ready state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who hand-configure ~/.codex/config.toml with a custom model_provider and matching [model_providers.] entry are fully functional from the Codex CLI, but netcatty only looked at codex login status — which reports on ~/.codex/auth.json alone — and would therefore push them into the ChatGPT login flow even though the CLI works for them. Add a minimal TOML parser for the narrow subset we need (top-level keys plus [model_providers.] string tables), and readCodexCustomProvider Config() to detect a usable custom-provider setup: an active model_provider that isn't the built-in openai preset, pointing at a provider entry whose env_key is set in the shell env (or api_key is hardcoded). Surface this as a new integration state "connected_custom_config", add a customConfig summary on the IPC response, and tweak the Codex settings card so it shows the custom-provider name, hides the Connect ChatGPT button, and drops the stale "OpenAI-compatible provider" hint when this path is active. At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass authMethodId: "chatgpt" when we truly have no other option. When a netcatty-managed API key is present we still use "codex-api-key"; when the user has a custom config we omit authMethodId entirely so codex-acp resolves auth from the shell env / config.toml itself. Fold the detected custom config (provider name, base url, env key presence) into the provider reuse fingerprint so edits to config.toml invalidate cached ACP instances. Fixes the Codex half of #677 for users who skip Settings → AI providers. Co-Authored-By: Claude Opus 4.6 (1M context) --- application/i18n/locales/en.ts | 2 + application/i18n/locales/zh-CN.ts | 2 + .../settings/tabs/ai/CodexConnectionCard.tsx | 22 ++- components/settings/tabs/ai/types.ts | 11 ++ electron/bridges/ai/codexHelpers.cjs | 170 +++++++++++++++++- electron/bridges/aiBridge.cjs | 55 +++++- 6 files changed, 250 insertions(+), 12 deletions(-) diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 1fc957b82..7990af8c7 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -1750,6 +1750,8 @@ const en: Messages = { 'ai.codex.awaitingLogin': 'Awaiting login', 'ai.codex.connectedChatGPT': 'Connected via ChatGPT', 'ai.codex.connectedApiKey': 'Connected via API key', + 'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml', + 'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.', 'ai.codex.notConnected': 'Not connected', 'ai.codex.statusUnknown': 'Status unknown', 'ai.codex.path': 'Path:', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index ab1307fe0..84f53c150 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1758,6 +1758,8 @@ const zhCN: Messages = { 'ai.codex.awaitingLogin': '等待登录', 'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接', 'ai.codex.connectedApiKey': '已通过 API Key 连接', + 'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider', + 'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。', 'ai.codex.notConnected': '未连接', 'ai.codex.statusUnknown': '状态未知', 'ai.codex.path': '路径:', diff --git a/components/settings/tabs/ai/CodexConnectionCard.tsx b/components/settings/tabs/ai/CodexConnectionCard.tsx index 633830e30..dda1a3dd1 100644 --- a/components/settings/tabs/ai/CodexConnectionCard.tsx +++ b/components/settings/tabs/ai/CodexConnectionCard.tsx @@ -52,9 +52,11 @@ export const CodexConnectionCard: React.FC<{ ? t('ai.codex.connectedChatGPT') : integration?.state === "connected_api_key" ? t('ai.codex.connectedApiKey') - : integration?.state === "not_logged_in" - ? t('ai.codex.notConnected') - : t('ai.codex.statusUnknown'); + : integration?.state === "connected_custom_config" + ? t('ai.codex.connectedCustomConfig') + : integration?.state === "not_logged_in" + ? t('ai.codex.notConnected') + : t('ai.codex.statusUnknown'); const statusClassName = isResolvingPath ? "text-muted-foreground" @@ -139,6 +141,9 @@ export const CodexConnectionCard: React.FC<{ {t('common.cancel')} + ) : integration?.state === "connected_custom_config" ? ( + // Nothing to log out of; config.toml is user-owned state. + null ) : integration?.isConnected ? ( - {hasCompatibleProvider && ( + {integration?.state === "connected_custom_config" && integration.customConfig && ( +

+ {t('ai.codex.customConfigHint').replace( + '{provider}', + integration.customConfig.displayName || integration.customConfig.providerName, + )} +

+ )} + + {hasCompatibleProvider && integration?.state !== "connected_custom_config" && (

{t('ai.codex.apiKeyHint')}

diff --git a/components/settings/tabs/ai/types.ts b/components/settings/tabs/ai/types.ts index 715c78b32..fd0a35d6d 100644 --- a/components/settings/tabs/ai/types.ts +++ b/components/settings/tabs/ai/types.ts @@ -10,14 +10,25 @@ import type { export type CodexIntegrationState = | "connected_chatgpt" | "connected_api_key" + | "connected_custom_config" | "not_logged_in" | "unknown"; +export interface CodexCustomProviderConfig { + providerName: string; + displayName: string; + baseUrl: string | null; + envKey: string | null; + envKeyPresent: boolean; + hasHardcodedApiKey: boolean; +} + export interface CodexIntegrationStatus { state: CodexIntegrationState; isConnected: boolean; rawOutput: string; exitCode: number | null; + customConfig?: CodexCustomProviderConfig | null; } export type CodexLoginState = "running" | "success" | "error" | "cancelled"; diff --git a/electron/bridges/ai/codexHelpers.cjs b/electron/bridges/ai/codexHelpers.cjs index 381977945..190a7a135 100644 --- a/electron/bridges/ai/codexHelpers.cjs +++ b/electron/bridges/ai/codexHelpers.cjs @@ -8,7 +8,8 @@ const { execFileSync } = require("node:child_process"); const { createHash } = require("node:crypto"); -const { existsSync } = require("node:fs"); +const { existsSync, readFileSync } = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs"); @@ -124,6 +125,171 @@ function getActiveCodexLoginSession() { return null; } +// ── Codex config.toml probing ── +// +// Users who hand-configure `~/.codex/config.toml` with a custom +// `model_provider` + matching `[model_providers.]` entry are fully +// functional from the Codex CLI, but `codex login status` doesn't see them +// because it only reports on `~/.codex/auth.json` (populated by `codex login`). +// We read and minimally parse the config file so we can surface this as a +// valid "ready" state and skip the ChatGPT login prompt in the UI. + +/** Find `#` outside quoted regions. */ +function findUnquotedHash(value) { + let inStr = false; + let quote = ""; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (inStr) { + if (ch === quote && value[i - 1] !== "\\") { + inStr = false; + quote = ""; + } + continue; + } + if (ch === '"' || ch === "'") { + inStr = true; + quote = ch; + continue; + } + if (ch === "#") return i; + } + return -1; +} + +/** + * Parse the narrow subset of TOML we need from Codex's config.toml: + * - top-level string keys (e.g. `model_provider = "my_provider"`) + * - `[model_providers.]` tables with string-valued keys + * Unsupported TOML features (arrays, inline tables, multi-line strings, etc.) + * are ignored — Codex's config.toml doesn't use them for provider definitions. + */ +function parseCodexConfigToml(text) { + const result = { model_providers: {} }; + let currentProvider = null; + let atTopLevel = true; + + const lines = String(text || "").split(/\r?\n/); + for (const rawLine of lines) { + let line = rawLine; + const hashIdx = findUnquotedHash(line); + if (hashIdx >= 0) line = line.slice(0, hashIdx); + line = line.trim(); + if (!line) continue; + + const sectionMatch = line.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const section = sectionMatch[1].trim(); + if (section.startsWith("model_providers.")) { + currentProvider = section.slice("model_providers.".length); + if (!result.model_providers[currentProvider]) { + result.model_providers[currentProvider] = {}; + } + atTopLevel = false; + } else { + currentProvider = null; + atTopLevel = false; + } + continue; + } + + const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/); + if (!kvMatch) continue; + const key = kvMatch[1]; + let raw = kvMatch[2].trim(); + let value; + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) { + value = raw.slice(1, -1); + } else { + value = raw; + } + + if (atTopLevel) { + result[key] = value; + } else if (currentProvider) { + result.model_providers[currentProvider][key] = value; + } + } + + return result; +} + +/** + * Inspect `~/.codex/config.toml` to determine whether the user has a + * self-configured custom provider that Codex can authenticate against without + * needing `codex login`. + * + * Returns null when: + * - the config file doesn't exist or can't be read + * - no `model_provider` is set, or it points to the default OpenAI preset + * - the referenced provider entry is missing, or has no usable auth + * (neither a hardcoded api_key nor an env_key present in the shell env) + * + * Returns a summary object otherwise, for reporting to the UI and for + * deciding whether to skip forcing `authMethodId: "chatgpt"` at spawn time. + */ +function readCodexCustomProviderConfig(shellEnv) { + const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir(); + if (!home) return null; + const configPath = path.join(home, ".codex", "config.toml"); + if (!existsSync(configPath)) return null; + + let text; + try { + text = readFileSync(configPath, "utf8"); + } catch { + return null; + } + + let parsed; + try { + parsed = parseCodexConfigToml(text); + } catch { + return null; + } + + const activeName = typeof parsed.model_provider === "string" + ? parsed.model_provider.trim() + : ""; + if (!activeName) return null; + // The built-in "openai" provider still goes through ChatGPT/API-key auth + // managed by `codex login`, so treating it as "custom" would be wrong. + if (activeName === "openai") return null; + + const providerEntry = parsed.model_providers?.[activeName]; + if (!providerEntry) return null; + + const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : ""; + const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : ""; + const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : ""; + const hasAuth = Boolean(hardcodedApiKey) || Boolean(envKeyValue); + if (!hasAuth) return null; + + return { + providerName: activeName, + displayName: providerEntry.name || activeName, + baseUrl: providerEntry.base_url || null, + envKey: envKeyName || null, + envKeyPresent: Boolean(envKeyValue), + hasHardcodedApiKey: Boolean(hardcodedApiKey), + }; +} + +/** + * Compute the ACP auth override object for Codex spawn sites. + * - netcatty-managed API key present → "codex-api-key" + * - user's own ~/.codex/config.toml custom provider detected → no override + * (so codex-acp resolves auth from the shell env / config itself) + * - otherwise → "chatgpt" (triggers the browser OAuth login flow) + * + * Returned as an object designed to be spread into createACPProvider options. + */ +function getCodexAuthOverride(apiKey, shellEnv) { + if (apiKey) return { authMethodId: "codex-api-key" }; + if (readCodexCustomProviderConfig(shellEnv)) return {}; + return { authMethodId: "chatgpt" }; +} + // ── Integration state ── function normalizeCodexIntegrationState(rawOutput) { @@ -199,6 +365,8 @@ module.exports = { toCodexLoginSessionResponse, getActiveCodexLoginSession, normalizeCodexIntegrationState, + readCodexCustomProviderConfig, + getCodexAuthOverride, extractCodexError, isCodexAuthError, getCodexAuthFingerprint, diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index 6deba9452..a4ab76427 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -35,6 +35,8 @@ const { toCodexLoginSessionResponse, getActiveCodexLoginSession, normalizeCodexIntegrationState, + readCodexCustomProviderConfig, + getCodexAuthOverride, extractCodexError, isCodexAuthError, getCodexAuthFingerprint, @@ -234,12 +236,21 @@ function resolveProviderApiKey(providerId) { }; } -function getAcpProviderAuthFingerprint(apiKey, provider) { +function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) { const parts = [ typeof apiKey === "string" ? apiKey.trim() : "", typeof provider?.id === "string" ? provider.id.trim() : "", typeof provider?.providerId === "string" ? provider.providerId.trim() : "", typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "", + customConfig + ? [ + "custom", + customConfig.providerName || "", + customConfig.baseUrl || "", + customConfig.envKey || "", + customConfig.envKeyPresent ? "1" : "0", + ].join(":") + : "", ].filter(Boolean); if (parts.length === 0) { @@ -1739,11 +1750,33 @@ function registerHandlers(ipcMain) { } } + // `codex login status` only reflects ~/.codex/auth.json. A user who + // configured a custom provider directly in ~/.codex/config.toml is + // functional from the CLI but would look "not_logged_in" here. Probe + // config.toml so we can surface that as a valid ready state instead of + // pushing the user into the ChatGPT login flow. + let customConfig = null; + if (state !== "connected_chatgpt" && state !== "connected_api_key") { + try { + const shellEnv = await getShellEnv(); + customConfig = readCodexCustomProviderConfig(shellEnv); + if (customConfig) { + state = "connected_custom_config"; + } + } catch { + customConfig = null; + } + } + return { state, - isConnected: state === "connected_chatgpt" || state === "connected_api_key", + isConnected: + state === "connected_chatgpt" || + state === "connected_api_key" || + state === "connected_custom_config", rawOutput: effectiveRawOutput, exitCode: result.exitCode, + customConfig, }; } catch (err) { return { @@ -1751,6 +1784,7 @@ function registerHandlers(ipcMain) { isConnected: false, rawOutput: err?.message || String(err), exitCode: null, + customConfig: null, }; } }); @@ -2158,7 +2192,7 @@ function registerHandlers(ipcMain) { mcpServers: [], }, ...(isCodexAgent - ? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" } + ? getCodexAuthOverride(apiKey, shellEnv) : isCopilotAgent ? { authMethodId: "copilot-login" } : {}), @@ -2290,8 +2324,15 @@ function registerHandlers(ipcMain) { } } + // For Codex, also fold the user's ~/.codex/config.toml custom-provider + // state into the fingerprint so editing the config invalidates any + // cached ACP instance (otherwise a stale provider would keep hitting + // the old endpoint / env_key). + const codexCustomConfig = isCodexAgent && !apiKey + ? readCodexCustomProviderConfig(shellEnv) + : null; const authFingerprint = isCodexAgent || isClaudeAgent - ? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider) + ? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig) : null; const mcpSnapshot = isCodexAgent ? await resolveCodexMcpSnapshot(sessionCwd) @@ -2401,7 +2442,7 @@ function registerHandlers(ipcMain) { }, ...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}), ...(isCodexAgent - ? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" } + ? getCodexAuthOverride(apiKey, shellEnv) : isCopilotAgent ? { authMethodId: "copilot-login" } : {}), @@ -2413,7 +2454,7 @@ function registerHandlers(ipcMain) { resolvedCommand, resolvedArgs, mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name), - authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null, + authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null, }); if (isCopilotAgent) { @@ -2509,7 +2550,7 @@ function registerHandlers(ipcMain) { mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers, }, ...(isCodexAgent - ? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" } + ? getCodexAuthOverride(apiKey, shellEnv) : isCopilotAgent ? { authMethodId: "copilot-login" } : {}), From 18c001e9c5bd9ef31619042eb66e44b213adcd33 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:53:33 +0800 Subject: [PATCH 03/12] fix: show custom config even when env_key is not exported yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass required both a custom model_provider in ~/.codex/config.toml AND the referenced env_key to already be present in the shell environment. If a user had the config file set up but hadn't (yet) exported the key in their shell, detection returned null and the UI fell back to "Not connected" + "Connect ChatGPT" — which is the exact flow they were trying to avoid. The config.toml is a strong enough signal of intent on its own. Keep the integration in the connected_custom_config state regardless of env_key availability, but expose envKeyPresent on the response so the UI can explicitly warn "Warning: $MY_KEY is not set in your shell — export it". Status label and color also flip to amber ("Custom config detected — env var missing") so the state is easy to spot without dropping back to the login prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- application/i18n/locales/en.ts | 2 + application/i18n/locales/zh-CN.ts | 2 + .../settings/tabs/ai/CodexConnectionCard.tsx | 40 ++++++++++++++----- electron/bridges/ai/codexHelpers.cjs | 20 +++++----- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 7990af8c7..05dfed9c7 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -1751,7 +1751,9 @@ const en: Messages = { 'ai.codex.connectedChatGPT': 'Connected via ChatGPT', 'ai.codex.connectedApiKey': 'Connected via API key', 'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml', + 'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)', 'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.', + 'ai.codex.customConfigMissingEnvKey': 'Warning: ${envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.', 'ai.codex.notConnected': 'Not connected', 'ai.codex.statusUnknown': 'Status unknown', 'ai.codex.path': 'Path:', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 84f53c150..a1ca8201b 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1759,7 +1759,9 @@ const zhCN: Messages = { 'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接', 'ai.codex.connectedApiKey': '已通过 API Key 连接', 'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider', + 'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)', 'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。', + 'ai.codex.customConfigMissingEnvKey': '警告:环境变量 ${envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。', 'ai.codex.notConnected': '未连接', 'ai.codex.statusUnknown': '状态未知', 'ai.codex.path': '路径:', diff --git a/components/settings/tabs/ai/CodexConnectionCard.tsx b/components/settings/tabs/ai/CodexConnectionCard.tsx index dda1a3dd1..fe9dd5ca5 100644 --- a/components/settings/tabs/ai/CodexConnectionCard.tsx +++ b/components/settings/tabs/ai/CodexConnectionCard.tsx @@ -42,6 +42,14 @@ export const CodexConnectionCard: React.FC<{ const { t } = useI18n(); const found = pathInfo?.available; + const customConfigIncomplete = Boolean( + integration?.state === "connected_custom_config" + && integration.customConfig + && integration.customConfig.envKey + && !integration.customConfig.envKeyPresent + && !integration.customConfig.hasHardcodedApiKey, + ); + const status = isResolvingPath ? t('ai.codex.detecting') : !found @@ -53,7 +61,9 @@ export const CodexConnectionCard: React.FC<{ : integration?.state === "connected_api_key" ? t('ai.codex.connectedApiKey') : integration?.state === "connected_custom_config" - ? t('ai.codex.connectedCustomConfig') + ? customConfigIncomplete + ? t('ai.codex.customConfigIncomplete') + : t('ai.codex.connectedCustomConfig') : integration?.state === "not_logged_in" ? t('ai.codex.notConnected') : t('ai.codex.statusUnknown'); @@ -64,9 +74,11 @@ export const CodexConnectionCard: React.FC<{ ? "text-amber-500" : loginSession?.state === "running" ? "text-amber-500" - : integration?.isConnected - ? "text-emerald-500" - : "text-muted-foreground"; + : customConfigIncomplete + ? "text-amber-500" + : integration?.isConnected + ? "text-emerald-500" + : "text-muted-foreground"; const outputText = loginSession?.error ? loginSession.error @@ -163,12 +175,22 @@ export const CodexConnectionCard: React.FC<{ {integration?.state === "connected_custom_config" && integration.customConfig && ( -

- {t('ai.codex.customConfigHint').replace( - '{provider}', - integration.customConfig.displayName || integration.customConfig.providerName, + <> +

+ {t('ai.codex.customConfigHint').replace( + '{provider}', + integration.customConfig.displayName || integration.customConfig.providerName, + )} +

+ {integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && ( +

+ {t('ai.codex.customConfigMissingEnvKey').replace( + '{envKey}', + integration.customConfig.envKey, + )} +

)} -

+ )} {hasCompatibleProvider && integration?.state !== "connected_custom_config" && ( diff --git a/electron/bridges/ai/codexHelpers.cjs b/electron/bridges/ai/codexHelpers.cjs index 190a7a135..102b6f955 100644 --- a/electron/bridges/ai/codexHelpers.cjs +++ b/electron/bridges/ai/codexHelpers.cjs @@ -215,18 +215,20 @@ function parseCodexConfigToml(text) { } /** - * Inspect `~/.codex/config.toml` to determine whether the user has a - * self-configured custom provider that Codex can authenticate against without - * needing `codex login`. + * Inspect `~/.codex/config.toml` to determine whether the user has + * configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT + * path. * * Returns null when: * - the config file doesn't exist or can't be read - * - no `model_provider` is set, or it points to the default OpenAI preset - * - the referenced provider entry is missing, or has no usable auth - * (neither a hardcoded api_key nor an env_key present in the shell env) + * - no `model_provider` is set, or it points to the default `openai` preset + * - the referenced provider entry is missing (config is malformed) * - * Returns a summary object otherwise, for reporting to the UI and for - * deciding whether to skip forcing `authMethodId: "chatgpt"` at spawn time. + * Returns a summary object otherwise — even if the env_key isn't currently + * exported in the shell environment. That case is surfaced via + * `envKeyPresent: false` so the UI can warn the user; we don't want the + * absence of an env var to silently fall back to the ChatGPT login flow, + * because the config.toml is a strong signal the user doesn't want that. */ function readCodexCustomProviderConfig(shellEnv) { const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir(); @@ -262,8 +264,6 @@ function readCodexCustomProviderConfig(shellEnv) { const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : ""; const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : ""; const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : ""; - const hasAuth = Boolean(hardcodedApiKey) || Boolean(envKeyValue); - if (!hasAuth) return null; return { providerName: activeName, From b1291d3ee2eb67ac51cadf24912bcd95f58b535a Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:00:04 +0800 Subject: [PATCH 04/12] fix: probe codex-acp for available models instead of using hardcoded preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent, so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct for the stock ChatGPT/OpenAI path. When the user has a custom model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.), none of those IDs exist on their endpoint and the model picker is useless. Extend the condition to also trigger the aiAcpListModels probe for the Codex managed agent (detected via matchesManagedAgentConfig). The probe launches codex-acp the same way a real session does, so it now also goes through getCodexAuthOverride and respects the user's config.toml — and whatever availableModels codex-acp returns (typically at least the `model` field from config.toml) shows up in the picker. Claude keeps its curated presets to avoid regressing that path. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/AIChatSidePanel.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index f8bbf50f5..3fd634e0f 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -32,6 +32,7 @@ import type { WebSearchConfig, } from '../infrastructure/ai/types'; import { getAgentModelPresets } from '../infrastructure/ai/types'; +import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents'; import { useAgentDiscovery } from '../application/state/useAgentDiscovery'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; @@ -480,13 +481,22 @@ const AIChatSidePanelInner: React.FC = ({ () => isCopilotAgentConfig(currentAgentConfig), [currentAgentConfig], ); + // Codex's hardcoded CODEX_MODEL_PRESETS list is only valid for the stock + // OpenAI/ChatGPT path. When the user configures a custom model_provider in + // ~/.codex/config.toml (OpenRouter, a local model, etc.), those preset + // names are meaningless. Probe codex-acp for the real available-models + // list so the picker reflects what this session can actually use. + const isCodexManagedAgent = useMemo( + () => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false, + [currentAgentConfig], + ); const agentModelMapRef = useRef(agentModelMap); agentModelMapRef.current = agentModelMap; useEffect(() => { if (!currentAgentConfig?.acpCommand) return; - if (!isCopilotExternalAgent) return; + if (!isCopilotExternalAgent && !isCodexManagedAgent) return; const bridge = getNetcattyBridge(); if (!bridge?.aiAcpListModels) return; @@ -518,7 +528,7 @@ const AIChatSidePanelInner: React.FC = ({ return () => { cancelled = true; }; - }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]); + }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isCodexManagedAgent, setAgentModel]); const agentModelPresets = useMemo( () => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command), From 6309a49c37461d39285662031c685895f5a4f623 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:02:02 +0800 Subject: [PATCH 05/12] fix: cap model picker width and truncate long descriptions With dynamic models now pulled from codex-acp, preset descriptions can be arbitrarily long ("Latest frontier model with improvements across a..." from OpenAI's public model list). The popover had whitespace-nowrap on each option and no max-w on the container, so long descriptions pushed the dropdown off-screen. Cap the popover at max-w-[360px], add min-w-0 + truncate to the name span so flex children can actually shrink, and cap the description span at max-w-[160px] with truncate so it ellipses rather than expanding the row. ChevronRight gets shrink-0 so it can't be pushed out of view. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ai/ChatInput.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index 713563650..2483062f2 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -395,7 +395,7 @@ const ChatInput: React.FC = ({
setHoveredModelId(null)} > @@ -420,12 +420,12 @@ const ChatInput: React.FC = ({ closeAllMenus(); } }} - className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap" + className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap" > {isSelected ? : } - {preset.name} - {preset.description && {preset.description}} - {hasThinking && } + {preset.name} + {preset.description && {preset.description}} + {hasThinking && } {/* Thinking level sub-menu */} {hasThinking && hoveredModelId === preset.id && ( From ba2a0389fa47c161e7dbbaa96d413055f6d96cf6 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:03:24 +0800 Subject: [PATCH 06/12] fix: stack model picker description below name (vertical layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Horizontal layout + truncate clipped too much of codex-acp's longer descriptions ("Latest frontier model with improvements across a..." → "Latest frontier model w..."). Reorganize each option as checkmark | name-on-top, wrapped description below | chevron, so the full description is readable across two lines without pushing the popover width out. Fix popover to w-[300px] for a consistent column width. Checkmark and chevron anchor to the first text line (self-start with small top offset) so they stay visually aligned with the name when the description wraps. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ai/ChatInput.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index 2483062f2..ca820dab4 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -395,7 +395,7 @@ const ChatInput: React.FC = ({
setHoveredModelId(null)} > @@ -420,12 +420,18 @@ const ChatInput: React.FC = ({ closeAllMenus(); } }} - className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap" + className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer" > - {isSelected ? : } - {preset.name} - {preset.description && {preset.description}} - {hasThinking && } + {isSelected ? : } +
+ {preset.name} + {preset.description && ( + + {preset.description} + + )} +
+ {hasThinking && } {/* Thinking level sub-menu */} {hasThinking && hoveredModelId === preset.id && ( From f48db8ee4e66581e44061fe8b4918dd8448a2e9c Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:03:57 +0800 Subject: [PATCH 07/12] fix: drop description from model picker to keep it compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-acp's provider descriptions can be paragraphs ("Latest frontier model with improvements across a wide range of capabilities..."), which made each row of the picker feel bloated. The model id and (thinking sub-menu's) thinking level already convey the relevant distinction — drop the description render entirely. Keeps the dropdown tight regardless of how verbose the upstream model catalog is. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ai/ChatInput.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index ca820dab4..7516d1c57 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -422,16 +422,9 @@ const ChatInput: React.FC = ({ }} className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer" > - {isSelected ? : } -
- {preset.name} - {preset.description && ( - - {preset.description} - - )} -
- {hasThinking && } + {isSelected ? : } + {preset.name} + {hasThinking && } {/* Thinking level sub-menu */} {hasThinking && hoveredModelId === preset.id && ( From 79a7e460be345ea950e8179df4465b8d231d5167 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:07:05 +0800 Subject: [PATCH 08/12] fix: parse model ids that contain '/' correctly in ChatInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker label was being derived by splitting selectedModelId on the first '/'. That works for Codex's ChatGPT-preset format ("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"): selectedBaseModelId became "qwen", which doesn't match any preset, so selectedPreset fell back to undefined and the chip displayed the unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead of the actually selected Codex model. Replace the naive split with a two-step lookup: first try a direct id match; only if that fails, look for a preset whose declared thinkingLevels make "${preset.id}/${level}" equal to selectedModelId, and derive the thinking segment from that. Model ids that happen to contain '/' now round-trip correctly through the picker. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ai/ChatInput.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index 7516d1c57..8c7740211 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -166,10 +166,26 @@ const ChatInput: React.FC = ({ // Permission mode chip removed — agents run in autonomous mode - // selectedModelId may be "model/thinking" for codex - const selectedBaseModelId = selectedModelId?.split('/')[0]; - const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined; - const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId); + // selectedModelId may be "/" for codex ChatGPT models + // (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids + // themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just + // split on the first '/'. Match against the full id first; only treat the + // trailing segment as a thinking level when we find a preset whose + // declared thinkingLevels make the combined form equal to selectedModelId. + const { selectedPreset, selectedThinking } = (() => { + if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined }; + const direct = modelPresets.find(m => m.id === selectedModelId); + if (direct) return { selectedPreset: direct, selectedThinking: undefined }; + const viaThinking = modelPresets.find( + m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId), + ); + if (viaThinking) { + const thinking = selectedModelId.slice(viaThinking.id.length + 1); + return { selectedPreset: viaThinking, selectedThinking: thinking }; + } + return { selectedPreset: undefined, selectedThinking: undefined }; + })(); + const selectedBaseModelId = selectedPreset?.id; const modelLabel = selectedPreset ? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '') : modelName || providerName || t('ai.chat.noModel'); From fea8e8b305febd6d6ce9c4ed2af7c5fdf3524568 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:10:35 +0800 Subject: [PATCH 09/12] fix: stop probing codex-acp for models; show config.toml model when custom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues the user flagged with the previous round: 1. Probing codex-acp for available models returned the stock ChatGPT catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active provider. For a user with a custom model_provider in ~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless on their endpoint. Roll back the managed-Codex probe hook and go back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path. 2. The fixed w-[300px] popover left empty space on the right whenever the longest row was narrower than 300px. Instead of the probe, teach readCodexCustomProviderConfig to also return the top-level `model` from config.toml and expose it on the integration response. In AIChatSidePanel, call aiCodexGetIntegration when Codex is the active agent and, if customConfig.model is present, override agentModelPresets with a single-entry list pinned to that model. Otherwise fall back to the static presets as before — so ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while custom-config users see just the model their provider is actually pinned to. Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]` so it hugs content (great for short single-model lists) while still capping very long rows. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/AIChatSidePanel.tsx | 45 +++++++++++++++++++++------- components/ai/ChatInput.tsx | 2 +- components/settings/tabs/ai/types.ts | 1 + electron/bridges/ai/codexHelpers.cjs | 2 ++ global.d.ts | 11 ++++++- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 3fd634e0f..21d6d6489 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -481,22 +481,39 @@ const AIChatSidePanelInner: React.FC = ({ () => isCopilotAgentConfig(currentAgentConfig), [currentAgentConfig], ); - // Codex's hardcoded CODEX_MODEL_PRESETS list is only valid for the stock - // OpenAI/ChatGPT path. When the user configures a custom model_provider in - // ~/.codex/config.toml (OpenRouter, a local model, etc.), those preset - // names are meaningless. Probe codex-acp for the real available-models - // list so the picker reflects what this session can actually use. const isCodexManagedAgent = useMemo( () => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false, [currentAgentConfig], ); + // For Codex, pick up the model declared in ~/.codex/config.toml (if any) + // so the picker can show just that model instead of the hardcoded ChatGPT + // preset list. Probing codex-acp for its full catalog returns the stock + // OpenAI models regardless of the active provider, which is misleading. + const [codexConfigModel, setCodexConfigModel] = useState(null); + useEffect(() => { + if (!isCodexManagedAgent) { + setCodexConfigModel(null); + return; + } + const bridge = getNetcattyBridge(); + if (!bridge?.aiCodexGetIntegration) return; + let cancelled = false; + void bridge.aiCodexGetIntegration().then((info) => { + if (cancelled) return; + setCodexConfigModel(info?.customConfig?.model ?? null); + }).catch(() => { + if (!cancelled) setCodexConfigModel(null); + }); + return () => { cancelled = true; }; + }, [isCodexManagedAgent, currentAgentId]); + const agentModelMapRef = useRef(agentModelMap); agentModelMapRef.current = agentModelMap; useEffect(() => { if (!currentAgentConfig?.acpCommand) return; - if (!isCopilotExternalAgent && !isCodexManagedAgent) return; + if (!isCopilotExternalAgent) return; const bridge = getNetcattyBridge(); if (!bridge?.aiAcpListModels) return; @@ -528,12 +545,18 @@ const AIChatSidePanelInner: React.FC = ({ return () => { cancelled = true; }; - }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isCodexManagedAgent, setAgentModel]); + }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]); - const agentModelPresets = useMemo( - () => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command), - [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets], - ); + const agentModelPresets = useMemo(() => { + // When a custom provider is configured in ~/.codex/config.toml, that + // config pins a specific model. The stock CODEX_MODEL_PRESETS catalog + // would be invalid for that endpoint, so surface just the config.toml + // model as the only option. + if (isCodexManagedAgent && codexConfigModel) { + return [{ id: codexConfigModel, name: codexConfigModel }]; + } + return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command); + }, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, isCodexManagedAgent, codexConfigModel]); // Per-agent model: recall last selection or use first preset as default const selectedAgentModel = useMemo(() => { diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index 8c7740211..d48367629 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -411,7 +411,7 @@ const ChatInput: React.FC = ({
setHoveredModelId(null)} > diff --git a/components/settings/tabs/ai/types.ts b/components/settings/tabs/ai/types.ts index fd0a35d6d..bb142be24 100644 --- a/components/settings/tabs/ai/types.ts +++ b/components/settings/tabs/ai/types.ts @@ -21,6 +21,7 @@ export interface CodexCustomProviderConfig { envKey: string | null; envKeyPresent: boolean; hasHardcodedApiKey: boolean; + model: string | null; } export interface CodexIntegrationStatus { diff --git a/electron/bridges/ai/codexHelpers.cjs b/electron/bridges/ai/codexHelpers.cjs index 102b6f955..01dfc7521 100644 --- a/electron/bridges/ai/codexHelpers.cjs +++ b/electron/bridges/ai/codexHelpers.cjs @@ -264,6 +264,7 @@ function readCodexCustomProviderConfig(shellEnv) { const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : ""; const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : ""; const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : ""; + const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : ""; return { providerName: activeName, @@ -272,6 +273,7 @@ function readCodexCustomProviderConfig(shellEnv) { envKey: envKeyName || null, envKeyPresent: Boolean(envKeyValue), hasHardcodedApiKey: Boolean(hardcodedApiKey), + model: activeModel || null, }; } diff --git a/global.d.ts b/global.d.ts index dafa94bb7..c9d2973a4 100644 --- a/global.d.ts +++ b/global.d.ts @@ -733,10 +733,19 @@ declare global { acpArgs?: string[]; }>>; aiCodexGetIntegration?(): Promise<{ - state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown'; + state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown'; isConnected: boolean; rawOutput: string; exitCode: number | null; + customConfig?: { + providerName: string; + displayName: string; + baseUrl: string | null; + envKey: string | null; + envKeyPresent: boolean; + hasHardcodedApiKey: boolean; + model: string | null; + } | null; }>; aiCodexStartLogin?(): Promise<{ ok: boolean; From 2374f67ffc799fb5a4dee9800c213a60e0586b8b Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:13:21 +0800 Subject: [PATCH 10/12] fix: skip ChatGPT auth validation when config.toml provides custom provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex request without a netcatty-managed API key. That helper spawns a fresh codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json to be valid — which it never is for users who only have a custom model_provider set up in ~/.codex/config.toml. The validation failed, the main window got "Codex ChatGPT login is stale or invalid. Reconnect Codex in Settings" over the error channel, and the UI flipped to the login prompt — exactly the flow the config.toml path is meant to skip. Move readCodexCustomProviderConfig up so we compute it before the validation gate, and only run the ChatGPT validation when there's neither a netcatty-managed API key nor a detected config.toml custom provider. The rest of the spawn path already omits authMethodId for the custom-config case, so codex-acp connects directly with the shell env and config.toml. Co-Authored-By: Claude Opus 4.6 (1M context) --- electron/bridges/aiBridge.cjs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index a4ab76427..a1d79204e 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -2303,7 +2303,15 @@ function registerHandlers(ipcMain) { const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null; const apiKey = resolvedProvider?.apiKey || undefined; - if (isCodexAgent && !apiKey) { + // Probe ~/.codex/config.toml first so we can tell a ChatGPT user + // (needs login validation) from a custom-provider user (must NOT be + // forced through ChatGPT validation, since their auth lives in + // config.toml / shell env, not auth.json). + const codexCustomConfig = isCodexAgent && !apiKey + ? readCodexCustomProviderConfig(shellEnv) + : null; + + if (isCodexAgent && !apiKey && !codexCustomConfig) { const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 }); if (shouldAbortStartup()) return { ok: true }; if (!validation.ok) { @@ -2324,13 +2332,6 @@ function registerHandlers(ipcMain) { } } - // For Codex, also fold the user's ~/.codex/config.toml custom-provider - // state into the fingerprint so editing the config invalidates any - // cached ACP instance (otherwise a stale provider would keep hitting - // the old endpoint / env_key). - const codexCustomConfig = isCodexAgent && !apiKey - ? readCodexCustomProviderConfig(shellEnv) - : null; const authFingerprint = isCodexAgent || isClaudeAgent ? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig) : null; From 8f68e24057b0b74055a51a6e76ced37742cabadf Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:21:36 +0800 Subject: [PATCH 11/12] fix: address review feedback on config.toml detection flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round of fixes driven by two parallel reviewers: - i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal dollar-sign) but the replace call passed '{envKey}', so the warning displayed a raw "${envKey}" instead of the real env var name. Align on the codebase-standard {envKey} form. - Fingerprint now folds the hash of the actual auth material (P1). readCodexCustomProviderConfig computes a sha256 over the hardcoded api_key or the resolved env_key value and returns authHash. The ACP provider-reuse fingerprint includes it, so rotating the key in ~/.zshrc + restarting netcatty (which refreshes shellEnv) now invalidates the cached provider instance instead of keeping the stale key alive. Raw value never crosses the IPC boundary — we only send the hex digest. - Fail loud when config.toml's env_key isn't exported (P1). Previously we'd sail into spawn and let codex-acp fail mid-request with a cryptic "Missing environment variable". Now the stream handler rejects up front with a targeted error naming the missing variable and pointing at ~/.zshrc. - TOML parser: basic-string escape tracking (P1). findUnquotedHash now tracks an explicit `escaped` flag (and only honors escapes inside double-quoted strings, since literal single-quoted strings don't), so values like "C:\\path\\" close correctly instead of consuming the trailing `#` as part of the string. - TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend one and the first-key regex would silently fail to match, dropping everything before the first section header. - Picker correctness when config.toml lacks a `model` field (P1). Instead of silently falling back to CODEX_MODEL_PRESETS (stock OpenAI IDs the user's custom endpoint can't serve), show an empty list so the picker disables. Track codexCustomConfigResolved so we distinguish "still loading" from "not a custom-config session" and only clear the preset list once the integration probe confirmed connected_custom_config. - Logout handler isConnected also considers connected_custom_config (P2 consistency), matching get-integration. - Model picker popover clamps its left position so max-w-[360px] can't push it past the right edge of a narrow AI side panel (P2). Co-Authored-By: Claude Opus 4.6 (1M context) --- application/i18n/locales/en.ts | 2 +- application/i18n/locales/zh-CN.ts | 2 +- components/AIChatSidePanel.tsx | 35 +++++++++++++++++++++------- components/ai/ChatInput.tsx | 9 ++++++- electron/bridges/ai/codexHelpers.cjs | 31 +++++++++++++++++++++--- electron/bridges/aiBridge.cjs | 26 ++++++++++++++++++++- 6 files changed, 90 insertions(+), 15 deletions(-) diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 05dfed9c7..0c8ccbef5 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -1753,7 +1753,7 @@ const en: Messages = { 'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml', 'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)', 'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.', - 'ai.codex.customConfigMissingEnvKey': 'Warning: ${envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.', + 'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.', 'ai.codex.notConnected': 'Not connected', 'ai.codex.statusUnknown': 'Status unknown', 'ai.codex.path': 'Path:', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index a1ca8201b..40e09f3cf 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1761,7 +1761,7 @@ const zhCN: Messages = { 'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider', 'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)', 'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。', - 'ai.codex.customConfigMissingEnvKey': '警告:环境变量 ${envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。', + 'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。', 'ai.codex.notConnected': '未连接', 'ai.codex.statusUnknown': '状态未知', 'ai.codex.path': '路径:', diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 21d6d6489..4ddab8ade 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -492,6 +492,7 @@ const AIChatSidePanelInner: React.FC = ({ // OpenAI models regardless of the active provider, which is misleading. const [codexConfigModel, setCodexConfigModel] = useState(null); useEffect(() => { + setCodexCustomConfigResolved(false); if (!isCodexManagedAgent) { setCodexConfigModel(null); return; @@ -501,9 +502,17 @@ const AIChatSidePanelInner: React.FC = ({ let cancelled = false; void bridge.aiCodexGetIntegration().then((info) => { if (cancelled) return; + const hasCustom = info?.state === 'connected_custom_config'; setCodexConfigModel(info?.customConfig?.model ?? null); + // Only flip "resolved" to true when the probe confirms this is a + // custom-config session; otherwise keep it false so we fall back to + // the static CODEX_MODEL_PRESETS. + setCodexCustomConfigResolved(hasCustom); }).catch(() => { - if (!cancelled) setCodexConfigModel(null); + if (!cancelled) { + setCodexConfigModel(null); + setCodexCustomConfigResolved(false); + } }); return () => { cancelled = true; }; }, [isCodexManagedAgent, currentAgentId]); @@ -547,16 +556,26 @@ const AIChatSidePanelInner: React.FC = ({ }; }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]); + // When Codex is backed by a ~/.codex/config.toml custom provider, the + // stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint. Track + // whether we got a definitive answer from the integration probe so we can + // distinguish "still loading" from "no model set in config.toml". + const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false); + const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent; + const agentModelPresets = useMemo(() => { - // When a custom provider is configured in ~/.codex/config.toml, that - // config pins a specific model. The stock CODEX_MODEL_PRESETS catalog - // would be invalid for that endpoint, so surface just the config.toml - // model as the only option. - if (isCodexManagedAgent && codexConfigModel) { - return [{ id: codexConfigModel, name: codexConfigModel }]; + if (hasCodexCustomConfig) { + // Config.toml with a pinned model → show just that model. + if (codexConfigModel) { + return [{ id: codexConfigModel, name: codexConfigModel }]; + } + // Config.toml custom provider without a pinned model → codex-acp + // uses its provider default. Don't surface the OpenAI presets; they + // wouldn't work. Empty list disables the picker. + return []; } return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command); - }, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, isCodexManagedAgent, codexConfigModel]); + }, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]); // Per-agent model: recall last selection or use first preset as default const selectedAgentModel = useMemo(() => { diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index d48367629..dee5b28d0 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -391,7 +391,14 @@ const ChatInput: React.FC = ({ if (!hasModelPicker) return; if (!showModelPicker) { const rect = modelBtnRef.current?.getBoundingClientRect(); - if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 }); + if (rect) { + // Clamp so the popover (max-w-[360px]) stays inside the + // viewport when the chip is near the right edge of a + // narrow AI side panel. + const popoverMax = 360; + const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMax - 8)); + setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 }); + } setActiveMenu('model'); } else { closeAllMenus(); diff --git a/electron/bridges/ai/codexHelpers.cjs b/electron/bridges/ai/codexHelpers.cjs index 01dfc7521..65fdf7ceb 100644 --- a/electron/bridges/ai/codexHelpers.cjs +++ b/electron/bridges/ai/codexHelpers.cjs @@ -134,14 +134,27 @@ function getActiveCodexLoginSession() { // We read and minimally parse the config file so we can surface this as a // valid "ready" state and skip the ChatGPT login prompt in the UI. -/** Find `#` outside quoted regions. */ +/** Find `#` outside quoted regions. Tracks escape state via a flag rather + * than peeking at the previous character, so even runs of backslashes like + * `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML + * strings don't recognize `\` as an escape, so only honor escapes inside + * basic (double-quoted) strings. */ function findUnquotedHash(value) { let inStr = false; let quote = ""; + let escaped = false; for (let i = 0; i < value.length; i++) { const ch = value[i]; if (inStr) { - if (ch === quote && value[i - 1] !== "\\") { + if (escaped) { + escaped = false; + continue; + } + if (quote === '"' && ch === "\\") { + escaped = true; + continue; + } + if (ch === quote) { inStr = false; quote = ""; } @@ -169,7 +182,9 @@ function parseCodexConfigToml(text) { let currentProvider = null; let atTopLevel = true; - const lines = String(text || "").split(/\r?\n/); + // Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files. + const normalized = String(text || "").replace(/^\uFEFF/, ""); + const lines = normalized.split(/\r?\n/); for (const rawLine of lines) { let line = rawLine; const hashIdx = findUnquotedHash(line); @@ -266,6 +281,15 @@ function readCodexCustomProviderConfig(shellEnv) { const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : ""; const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : ""; + // Hash the actual auth material (either the hardcoded api_key or the + // resolved env_key value) so the ACP provider fingerprint changes when + // the user rotates their key — without ever returning the raw value + // across the IPC boundary. + const authMaterial = hardcodedApiKey || envKeyValue; + const authHash = authMaterial + ? createHash("sha256").update(authMaterial).digest("hex") + : null; + return { providerName: activeName, displayName: providerEntry.name || activeName, @@ -274,6 +298,7 @@ function readCodexCustomProviderConfig(shellEnv) { envKeyPresent: Boolean(envKeyValue), hasHardcodedApiKey: Boolean(hardcodedApiKey), model: activeModel || null, + authHash, }; } diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index a1d79204e..93454b93b 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -249,6 +249,10 @@ function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) { customConfig.baseUrl || "", customConfig.envKey || "", customConfig.envKeyPresent ? "1" : "0", + // authHash changes when the user rotates their hardcoded api_key + // or the env_key's resolved value; without it a cached ACP + // provider would keep serving the stale key. + customConfig.authHash || "", ].join(":") : "", ].filter(Boolean); @@ -1896,7 +1900,10 @@ function registerHandlers(ipcMain) { return { ok: true, state, - isConnected: state === "connected_chatgpt" || state === "connected_api_key", + isConnected: + state === "connected_chatgpt" || + state === "connected_api_key" || + state === "connected_custom_config", rawOutput, logoutOutput: [logoutResult.stdout, logoutResult.stderr] .filter((chunk) => chunk.trim().length > 0) @@ -2311,6 +2318,23 @@ function registerHandlers(ipcMain) { ? readCodexCustomProviderConfig(shellEnv) : null; + // Fail loud: custom-provider config is set but has no usable auth + // material yet (env_key is named but not exported in the shell env, + // and no api_key is hardcoded). Don't spawn — codex-acp would fail + // mid-request with an opaque "Missing environment variable" error. + if ( + codexCustomConfig + && codexCustomConfig.envKey + && !codexCustomConfig.envKeyPresent + && !codexCustomConfig.hasHardcodedApiKey + ) { + safeSend(event.sender, "netcatty:ai:acp:error", { + requestId, + error: `Codex is configured to use the "${codexCustomConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${codexCustomConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and restart netcatty.`, + }); + return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` }; + } + if (isCodexAgent && !apiKey && !codexCustomConfig) { const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 }); if (shouldAbortStartup()) return { ok: true }; From 1c9c4fcec3d8aec35501b9ef881823ce6db370b0 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:30:29 +0800 Subject: [PATCH 12/12] fix: address second-round review feedback - Extract fail-loud check to shared getCodexCustomConfigPreflightError so the list-models handler (aiBridge.cjs:2149) enforces the same up-front error as the stream handler. Previously a user whose config.toml env_key was unexported would get the targeted message on chat send but a generic "Missing env var" from model-list probes (once the probe was rewired for Codex in a future change). - Wire Settings "Refresh Status" to also invalidate the shell-env cache. New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration now accepts an optional { refreshShellEnv } flag; the button passes it so a user who just exported OPENROUTER_API_KEY in their rc file can click Refresh instead of having to restart netcatty. - Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts) so renderer TS actually sees the field instead of needing a cast. - DRY the 360 magic number in ChatInput: extract MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the left-clamp math so the two can't drift. - Move codexCustomConfigResolved useState declaration next to its companion codexConfigModel, above the effect that invokes its setter, and drop the duplicate declaration further down. Pure code-organization cleanup but removes a use-before-declaration nit. No functional changes beyond the fail-loud parity and the refresh-shell-env path. ACP behavior when authMethodId is omitted still requires a real-world OpenRouter config.toml validation, which the user is running. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/AIChatSidePanel.tsx | 9 +++--- components/ai/ChatInput.tsx | 16 ++++++----- components/settings/tabs/SettingsAITab.tsx | 6 ++-- components/settings/tabs/ai/types.ts | 3 +- electron/bridges/ai/codexHelpers.cjs | 15 ++++++++++ electron/bridges/ai/shellUtils.cjs | 10 +++++++ electron/bridges/aiBridge.cjs | 33 ++++++++++++++++------ electron/preload.cjs | 4 +-- global.d.ts | 3 +- 9 files changed, 73 insertions(+), 26 deletions(-) diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 4ddab8ade..efc7a8e1e 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -491,6 +491,7 @@ const AIChatSidePanelInner: React.FC = ({ // preset list. Probing codex-acp for its full catalog returns the stock // OpenAI models regardless of the active provider, which is misleading. const [codexConfigModel, setCodexConfigModel] = useState(null); + const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false); useEffect(() => { setCodexCustomConfigResolved(false); if (!isCodexManagedAgent) { @@ -557,10 +558,10 @@ const AIChatSidePanelInner: React.FC = ({ }, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]); // When Codex is backed by a ~/.codex/config.toml custom provider, the - // stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint. Track - // whether we got a definitive answer from the integration probe so we can - // distinguish "still loading" from "no model set in config.toml". - const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false); + // stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint. + // codexCustomConfigResolved (declared above alongside codexConfigModel) + // stays false until the integration probe confirms this session is + // custom-config, so we don't flash an empty picker while loading. const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent; const agentModelPresets = useMemo(() => { diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index dee5b28d0..05799035a 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -23,6 +23,9 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input'; import { formatThinkingLabel } from '../../infrastructure/ai/types'; import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types'; +// Keep in sync with the popover's Tailwind max-width below. +const MODEL_PICKER_MAX_WIDTH = 360; + interface ChatInputProps { value: string; onChange: (value: string) => void; @@ -392,11 +395,10 @@ const ChatInput: React.FC = ({ if (!showModelPicker) { const rect = modelBtnRef.current?.getBoundingClientRect(); if (rect) { - // Clamp so the popover (max-w-[360px]) stays inside the - // viewport when the chip is near the right edge of a - // narrow AI side panel. - const popoverMax = 360; - const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMax - 8)); + // Clamp so the popover stays inside the viewport when + // the chip is near the right edge of a narrow AI side + // panel. + const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8)); setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 }); } setActiveMenu('model'); @@ -418,8 +420,8 @@ const ChatInput: React.FC = ({
setHoveredModelId(null)} > {modelPresets.map(preset => { diff --git a/components/settings/tabs/SettingsAITab.tsx b/components/settings/tabs/SettingsAITab.tsx index 28bef73a8..cedf3ac2d 100644 --- a/components/settings/tabs/SettingsAITab.tsx +++ b/components/settings/tabs/SettingsAITab.tsx @@ -307,14 +307,14 @@ const SettingsAITab: React.FC = ({ const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex")); - const refreshCodexIntegration = useCallback(async () => { + const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => { const bridge = getBridge(); if (!bridge?.aiCodexGetIntegration) return; setIsCodexLoading(true); setCodexError(null); try { - const integration = await bridge.aiCodexGetIntegration(); + const integration = await bridge.aiCodexGetIntegration(opts); setCodexIntegration(integration); } catch (err) { setCodexError(normalizeCodexBridgeError(err)); @@ -525,7 +525,7 @@ const SettingsAITab: React.FC = ({ isLoading={isCodexLoading} hasCompatibleProvider={hasCodexCompatibleProvider} error={codexError} - onRefresh={() => void refreshCodexIntegration()} + onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })} onConnect={() => void handleStartCodexLogin()} onCancel={() => void handleCancelCodexLogin()} onOpenUrl={handleOpenCodexLoginUrl} diff --git a/components/settings/tabs/ai/types.ts b/components/settings/tabs/ai/types.ts index bb142be24..d4c02275e 100644 --- a/components/settings/tabs/ai/types.ts +++ b/components/settings/tabs/ai/types.ts @@ -22,6 +22,7 @@ export interface CodexCustomProviderConfig { envKeyPresent: boolean; hasHardcodedApiKey: boolean; model: string | null; + authHash: string | null; } export interface CodexIntegrationStatus { @@ -69,7 +70,7 @@ export interface FetchBridge { } export interface NetcattyAiBridge { - aiCodexGetIntegration?: () => Promise; + aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise; aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>; aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>; aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>; diff --git a/electron/bridges/ai/codexHelpers.cjs b/electron/bridges/ai/codexHelpers.cjs index 65fdf7ceb..523613feb 100644 --- a/electron/bridges/ai/codexHelpers.cjs +++ b/electron/bridges/ai/codexHelpers.cjs @@ -302,6 +302,20 @@ function readCodexCustomProviderConfig(shellEnv) { }; } +/** + * Returns a user-facing error message when a Codex config.toml custom + * provider references an env_key that isn't exported in the shell env and + * doesn't have a hardcoded api_key either — otherwise returns null. Shared + * by every spawn path (stream handler, list-models handler) so users get + * the same actionable message regardless of which one hits first. + */ +function getCodexCustomConfigPreflightError(customConfig) { + if (!customConfig) return null; + if (!customConfig.envKey) return null; + if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null; + return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`; +} + /** * Compute the ACP auth override object for Codex spawn sites. * - netcatty-managed API key present → "codex-api-key" @@ -394,6 +408,7 @@ module.exports = { normalizeCodexIntegrationState, readCodexCustomProviderConfig, getCodexAuthOverride, + getCodexCustomConfigPreflightError, extractCodexError, isCodexAuthError, getCodexAuthFingerprint, diff --git a/electron/bridges/ai/shellUtils.cjs b/electron/bridges/ai/shellUtils.cjs index b87495ca3..4cddccc73 100644 --- a/electron/bridges/ai/shellUtils.cjs +++ b/electron/bridges/ai/shellUtils.cjs @@ -211,6 +211,15 @@ async function getShellEnv() { return _cachedShellEnv; } +/** + * Drop the shell-env cache so the next getShellEnv() call re-spawns the + * login shell. Useful when the user has just exported a new variable in + * their rc file and clicks "Refresh Status" without restarting the app. + */ +function invalidateShellEnvCache() { + _cachedShellEnv = null; +} + // ── Claude Code ACP binary resolution ── /** @@ -316,5 +325,6 @@ module.exports = { resolveClaudeAcpBinaryPath, toUnpackedAsarPath, getShellEnv, + invalidateShellEnvCache, serializeStreamChunk, }; diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index 93454b93b..7f6d49288 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -24,6 +24,7 @@ const { resolveCliFromPath, resolveClaudeAcpBinaryPath, getShellEnv, + invalidateShellEnvCache, serializeStreamChunk, toUnpackedAsarPath, } = require("./ai/shellUtils.cjs"); @@ -37,6 +38,7 @@ const { normalizeCodexIntegrationState, readCodexCustomProviderConfig, getCodexAuthOverride, + getCodexCustomConfigPreflightError, extractCodexError, isCodexAuthError, getCodexAuthFingerprint, @@ -1719,8 +1721,14 @@ function registerHandlers(ipcMain) { return { path: resolvedPath, version, available: true }; }); - ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => { + ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => { if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" }; + // When the user clicks "Refresh Status" in Settings we also want to + // rescan the shell env — otherwise a newly-exported variable in + // .zshrc stays invisible until they restart netcatty entirely. + if (options && options.refreshShellEnv) { + invalidateShellEnvCache(); + } try { const result = await runCodexCli(["login", "status"]); const rawOutput = [result.stdout, result.stderr] @@ -2158,6 +2166,19 @@ function registerHandlers(ipcMain) { const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null; const apiKey = resolvedProvider?.apiKey || undefined; + // Mirror the stream handler's pre-flight: if Codex is pointed at a + // config.toml custom provider whose env_key is not exported, surface + // a targeted error instead of spawning codex-acp and letting it fail + // mid-init with an opaque message. + if (isCodexAgent && !apiKey) { + const preflight = getCodexCustomConfigPreflightError( + readCodexCustomProviderConfig(shellEnv), + ); + if (preflight) { + return { ok: false, models: [], error: preflight }; + } + } + const agentEnv = withCliDiscoveryEnv({ ...shellEnv }); if (isCodexAgent && apiKey) { agentEnv.CODEX_API_KEY = apiKey; @@ -2322,15 +2343,11 @@ function registerHandlers(ipcMain) { // material yet (env_key is named but not exported in the shell env, // and no api_key is hardcoded). Don't spawn — codex-acp would fail // mid-request with an opaque "Missing environment variable" error. - if ( - codexCustomConfig - && codexCustomConfig.envKey - && !codexCustomConfig.envKeyPresent - && !codexCustomConfig.hasHardcodedApiKey - ) { + const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig); + if (preflightError) { safeSend(event.sender, "netcatty:ai:acp:error", { requestId, - error: `Codex is configured to use the "${codexCustomConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${codexCustomConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and restart netcatty.`, + error: preflightError, }); return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` }; } diff --git a/electron/preload.cjs b/electron/preload.cjs index 8e21f705e..ad7fab440 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -1184,8 +1184,8 @@ const api = { aiResolveCli: async (params) => { return ipcRenderer.invoke("netcatty:ai:resolve-cli", params); }, - aiCodexGetIntegration: async () => { - return ipcRenderer.invoke("netcatty:ai:codex:get-integration"); + aiCodexGetIntegration: async (options) => { + return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options); }, aiCodexStartLogin: async () => { return ipcRenderer.invoke("netcatty:ai:codex:start-login"); diff --git a/global.d.ts b/global.d.ts index c9d2973a4..7abe95015 100644 --- a/global.d.ts +++ b/global.d.ts @@ -732,7 +732,7 @@ declare global { acpCommand?: string; acpArgs?: string[]; }>>; - aiCodexGetIntegration?(): Promise<{ + aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{ state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown'; isConnected: boolean; rawOutput: string; @@ -745,6 +745,7 @@ declare global { envKeyPresent: boolean; hasHardcodedApiKey: boolean; model: string | null; + authHash: string | null; } | null; }>; aiCodexStartLogin?(): Promise<{