Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup

[codex] finish Codex provider follow-up for #677
This commit is contained in:
陈大猫 authored and GitHub committed 2026-04-13 02:34:25 +08:00
commit ca6ca3f477
14 files changed
+539 -62

No files matched your search

+6 -2
View File
@@ -1744,12 +1744,16 @@ 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',
'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:',
@@ -1760,7 +1764,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',
+6 -2
View File
@@ -1752,12 +1752,16 @@ 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': '等待登录',
'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': '路径:',
@@ -1768,7 +1772,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',
+57 -4
View File
@@ -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,6 +481,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
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<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
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);
setCodexCustomConfigResolved(false);
}
});
return () => { cancelled = true; };
}, [isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
@@ -520,10 +557,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// 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(() => {
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, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
+35 -11
View File
@@ -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;
@@ -166,10 +169,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
// 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 "<modelId>/<thinkingLevel>" 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');
@@ -375,7 +394,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
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 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');
} else {
closeAllMenus();
@@ -395,8 +420,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
@@ -420,12 +445,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
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"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
+4 -7
View File
@@ -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;
})();
+6 -7
View File
@@ -18,6 +18,7 @@ import type {
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
findManagedAgentProvider,
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
@@ -304,18 +305,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [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 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));
@@ -524,9 +523,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
hasCompatibleProvider={hasCodexCompatibleProvider}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -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,
@@ -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
@@ -52,9 +60,13 @@ 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"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
@@ -62,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
@@ -139,6 +153,9 @@ export const CodexConnectionCard: React.FC<{
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout}>
<LogOut size={14} className="mr-1.5" />
@@ -157,7 +174,26 @@ export const CodexConnectionCard: React.FC<{
</Button>
</div>
{hasOpenAiProviderKey && (
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
+14 -1
View File
@@ -10,14 +10,27 @@ 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;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
@@ -57,7 +70,7 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
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 }>;
+211 -1
View File
@@ -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,212 @@ function getActiveCodexLoginSession() {
return null;
}
// ── Codex config.toml probing ──
//
// Users who hand-configure `~/.codex/config.toml` with a custom
// `model_provider` + matching `[model_providers.<name>]` 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. 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 (escaped) {
escaped = false;
continue;
}
if (quote === '"' && ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
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.<name>]` 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;
// 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);
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
* 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 (config is malformed)
*
* 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();
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 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,
baseUrl: providerEntry.base_url || null,
envKey: envKeyName || null,
envKeyPresent: Boolean(envKeyValue),
hasHardcodedApiKey: Boolean(hardcodedApiKey),
model: activeModel || null,
authHash,
};
}
/**
* 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"
* - 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 +406,9 @@ module.exports = {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
+10
View File
@@ -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,
};
+109 -13
View File
@@ -24,6 +24,7 @@ const {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
@@ -35,6 +36,9 @@ const {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
@@ -234,6 +238,34 @@ function resolveProviderApiKey(providerId) {
};
}
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",
// 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);
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;
@@ -1689,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]
@@ -1724,11 +1762,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 {
@@ -1736,6 +1796,7 @@ function registerHandlers(ipcMain) {
isConnected: false,
rawOutput: err?.message || String(err),
exitCode: null,
customConfig: null,
};
}
});
@@ -1847,7 +1908,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)
@@ -2102,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;
@@ -2143,7 +2220,7 @@ function registerHandlers(ipcMain) {
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2254,7 +2331,28 @@ 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;
// 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.
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
if (preflightError) {
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: preflightError,
});
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
}
if (isCodexAgent && !apiKey && !codexCustomConfig) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (shouldAbortStartup()) return { ok: true };
if (!validation.ok) {
@@ -2275,11 +2373,9 @@ function registerHandlers(ipcMain) {
}
}
const authFingerprint = isCodexAgent
? getCodexAuthFingerprint(apiKey)
: isClaudeAgent
? getCodexAuthFingerprint(apiKey + (resolvedProvider?.provider?.baseURL || ""))
: null;
const authFingerprint = isCodexAgent || isClaudeAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -2388,7 +2484,7 @@ function registerHandlers(ipcMain) {
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2400,7 +2496,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) {
@@ -2496,7 +2592,7 @@ function registerHandlers(ipcMain) {
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
+2 -2
View File
@@ -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");
Vendored
+12 -2
View File
@@ -732,11 +732,21 @@ declare global {
acpCommand?: string;
acpArgs?: string[];
}>>;
aiCodexGetIntegration?(): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
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;
authHash: string | null;
} | null;
}>;
aiCodexStartLogin?(): Promise<{
ok: boolean;
+22 -1
View File
@@ -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;
}