+ >
)}
{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 = ({
+ {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 ? : }
-
- {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<{