From fe1627e6e7f4c5297f8bb13ebdfec5ebb4eaa9c4 Mon Sep 17 00:00:00 2001 From: HACBIT Date: Sat, 24 Oct 2026 17:26:21 +0800 Subject: [PATCH] feat: add opt-in discovery of Codex on PATH Keep bundled runtime discovery as the default. Add a persisted ChatGPT connection option to prefer a system Codex installation while retaining explicit path overrides and bundled fallback. Resolve Windows npm shims to their native executable without invoking a command shell. Recreate the runtime and invalidate verification when the option changes. Add localized settings copy and tests for discovery order and fallback. Validation: - pnpm typecheck - pnpm build - pnpm test: 336 passed, 5 skipped, 0 failed - User verified the enabled option in the Windows desktop test build --- src/main/lib/store.ts | 2 + src/main/lib/types.ts | 1 + .../llm/codexAppServerAgentRuntime.ts | 4 +- src/main/services/llm/codexAppServerClient.ts | 11 ++- src/main/services/llm/codexPath.ts | 54 +++++++++++ src/main/services/llm/providerRegistry.ts | 7 +- src/renderer/src/App.tsx | 17 ++++ src/renderer/src/i18n.ts | 14 +-- tests/codex-path.test.mjs | 94 +++++++++++++++++++ 9 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 src/main/services/llm/codexPath.ts create mode 100644 tests/codex-path.test.mjs diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index b4787d3..3bb08e7 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -450,6 +450,7 @@ function normalizeLlmConnections(settings: AppSettings): AppSettings { authMode: 'managed-login', model: chatgpt?.model || '', executablePath: chatgpt?.executablePath, + preferSystemCodex: chatgpt?.preferSystemCodex === true, enabled: chatgpt?.enabled !== false, setupStatus: chatgpt?.setupStatus ?? (chatGptWasActive ? settings.llmSetupStatus : 'unconfigured'), lastVerifiedAt: chatgpt?.lastVerifiedAt ?? (chatGptWasActive ? settings.llmLastVerifiedAt : '') @@ -560,6 +561,7 @@ export function setSettings(next: Partial): AppSettings { previous?.endpoint !== connection.endpoint || previous?.model !== connection.model || previous?.executablePath !== connection.executablePath || + (previous?.preferSystemCodex === true) !== (connection.preferSystemCodex === true) || (connection.id === targetConnectionId && apiKeyChanged) if (connection.id !== targetConnectionId) return connection if (Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus')) { diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index f990518..78ddec8 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -93,6 +93,7 @@ export interface LlmConnectionProfile { model: string endpoint?: string executablePath?: string + preferSystemCodex?: boolean enabled: boolean setupStatus?: LlmSetupStatus lastVerifiedAt?: string diff --git a/src/main/services/llm/codexAppServerAgentRuntime.ts b/src/main/services/llm/codexAppServerAgentRuntime.ts index 9a53988..a180a9a 100644 --- a/src/main/services/llm/codexAppServerAgentRuntime.ts +++ b/src/main/services/llm/codexAppServerAgentRuntime.ts @@ -60,8 +60,8 @@ export class CodexAppServerAgentRuntime implements AgentRuntimeAdapter { private readonly client: CodexAppServerClient - constructor(executablePath = '') { - this.client = new CodexAppServerClient(executablePath) + constructor(executablePath = '', preferSystemCodex = false) { + this.client = new CodexAppServerClient(executablePath, preferSystemCodex) } connectionState(connectionId: string): Promise { diff --git a/src/main/services/llm/codexAppServerClient.ts b/src/main/services/llm/codexAppServerClient.ts index 85e423e..64007c2 100644 --- a/src/main/services/llm/codexAppServerClient.ts +++ b/src/main/services/llm/codexAppServerClient.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { dirname, join, sep } from 'node:path' import { createInterface } from 'node:readline' import { appHome } from '@main/lib/store' +import { findCodexOnPath } from './codexPath' import type { LlmConnectionState, LlmLoginStartResult, @@ -124,12 +125,16 @@ function packagedCodexExecutable(): string | null { return existsSync(executable) ? executable : null } -export function resolveCodexExecutable(configuredPath = ''): string { +export function resolveCodexExecutable(configuredPath = '', preferSystemCodex = false): string { const explicit = configuredPath.trim() || process.env.GOAGENT_CODEX_BIN?.trim() if (explicit) { if (!existsSync(explicit)) throw new Error(`找不到指定的 ChatGPT 运行组件:${explicit}`) return explicit } + if (preferSystemCodex) { + const fromPath = findCodexOnPath() + if (fromPath) return fromPath + } const packaged = packagedCodexExecutable() if (packaged) return packaged const bundled = bundledCodexExecutable() @@ -243,7 +248,7 @@ export class CodexAppServerClient { private activeTurns = new Map() private stderrTail = '' - constructor(private executablePath = '') {} + constructor(private executablePath = '', private preferSystemCodex = false) {} private async ensureStarted(): Promise { if (this.started) return this.started @@ -255,7 +260,7 @@ export class CodexAppServerClient { } private async startProcess(): Promise { - const command = resolveCodexExecutable(this.executablePath) + const command = resolveCodexExecutable(this.executablePath, this.preferSystemCodex) this.stderrTail = '' mkdirSync(CODEX_HOME, { recursive: true }) const configOverrides = [ diff --git a/src/main/services/llm/codexPath.ts b/src/main/services/llm/codexPath.ts new file mode 100644 index 0000000..3f816f7 --- /dev/null +++ b/src/main/services/llm/codexPath.ts @@ -0,0 +1,54 @@ +import { accessSync, constants, existsSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { delimiter, dirname, isAbsolute, join } from 'node:path' + +function executableFile(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false + accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK) + return true + } catch { + return false + } +} + +// npm installs a .cmd shim on Windows. Resolve its native payload instead of +// launching a shell: Electron cannot spawn .cmd files directly, and a shell +// would complicate quoting and leave an intermediate process on cancellation. +function npmCodexExecutable(directory: string, arch: string): string | null { + const triple = arch === 'x64' ? 'x86_64-pc-windows-msvc' + : arch === 'arm64' ? 'aarch64-pc-windows-msvc' : null + if (!triple) return null + const packageJson = join(directory, 'node_modules', '@openai', 'codex', 'package.json') + if (!existsSync(packageJson)) return null + const roots = [dirname(packageJson)] + try { + roots.unshift(dirname(createRequire(packageJson).resolve(`@openai/codex-win32-${arch}/package.json`))) + } catch { + // Older npm packages include vendor/ directly in @openai/codex. + } + for (const root of roots) { + const candidate = join(root, 'vendor', triple, 'bin', 'codex.exe') + if (executableFile(candidate, 'win32')) return candidate + } + return null +} + +export function findCodexOnPath( + pathValue = process.env.PATH ?? '', + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string | null { + for (const entry of pathValue.split(platform === 'win32' ? ';' : delimiter)) { + const directory = entry.trim().replace(/^"(.*)"$/, '$1') + // Do not accidentally discover an executable in the current project. + if (!directory || !isAbsolute(directory)) continue + const candidate = join(directory, platform === 'win32' ? 'codex.exe' : 'codex') + if (executableFile(candidate, platform)) return candidate + if (platform === 'win32' && existsSync(join(directory, 'codex.cmd'))) { + const native = npmCodexExecutable(directory, arch) + if (native) return native + } + } + return null +} diff --git a/src/main/services/llm/providerRegistry.ts b/src/main/services/llm/providerRegistry.ts index 0cf726d..d3274bd 100644 --- a/src/main/services/llm/providerRegistry.ts +++ b/src/main/services/llm/providerRegistry.ts @@ -16,6 +16,7 @@ import { OpenAICompatibleAgentRuntime } from './openAICompatibleAgentRuntime' const openAICompatibleRuntime = new OpenAICompatibleAgentRuntime() let codexRuntime: CodexAppServerAgentRuntime | null = null let codexExecutablePath = '' +let codexPreferSystem = false const CODEX_DOMAIN_TOOL_NAMES = new Set([ 'library_findGames', @@ -47,10 +48,12 @@ export function toolsForLlmConnection(profile: LlmConnectionProfile, tools: Chat function codexRuntimeFor(profile: LlmConnectionProfile): CodexAppServerAgentRuntime { const executablePath = profile.executablePath?.trim() || '' - if (!codexRuntime || executablePath !== codexExecutablePath) { + const preferSystemCodex = profile.preferSystemCodex === true + if (!codexRuntime || executablePath !== codexExecutablePath || preferSystemCodex !== codexPreferSystem) { codexRuntime?.dispose() codexExecutablePath = executablePath - codexRuntime = new CodexAppServerAgentRuntime(executablePath) + codexPreferSystem = preferSystemCodex + codexRuntime = new CodexAppServerAgentRuntime(executablePath, preferSystemCodex) } return codexRuntime } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2ecef9f..144eef6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5262,6 +5262,23 @@ function SettingsDrawer({ )} +