diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bdac82a7d..ffdb13c81 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,7 +52,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults( }; } +function codexMicrosoftStorePackageRoot(): string | null { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return null; + } + return path.join(localAppData, "Packages"); +} + +async function findCodexMicrosoftStoreBinary(): Promise { + if (process.platform !== "win32") { + return null; + } + + const packageRoot = codexMicrosoftStorePackageRoot(); + if (!packageRoot) { + return null; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(packageRoot, { withFileTypes: true }); + } catch { + return null; + } + + const codexPackages = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_")) + .map((entry) => entry.name) + .sort(); + + for (const packageName of codexPackages) { + const candidate = path.join( + packageRoot, + packageName, + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ); + if (await probeExecutable(candidate)) { + return candidate; + } + } + + return null; +} + +async function findDefaultCodexBinary(): Promise { + return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); +} + async function resolveCodexBinary(): Promise { - const found = await findExecutable("codex"); + const found = await findDefaultCodexBinary(); if (found) { return found; } @@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient { if (command?.mode === "replace") { return await isCommandAvailable(command.argv[0]); } - return await isCommandAvailable("codex"); + return (await findDefaultCodexBinary()) !== null; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = await findExecutable("codex"); + const resolvedBinary = await findDefaultCodexBinary(); const entries: Array<{ label: string; value: string }> = [ { label: "Binary", @@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = { CodexAppServerClient, codexModelSupportsFastMode, CodexAppServerAgentSession, + findCodexMicrosoftStoreBinary, + findDefaultCodexBinary, formatCodexQuestionPrompts, mapCodexQuestionRequestToToolCall, mapCodexPatchNotificationToToolCall, diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index 3f371e542..d5f2c9564 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js"; import { AgentStorage } from "../agent-storage.js"; import { ClaudeAgentClient } from "./claude/agent.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; const originalEnv = { + LOCALAPPDATA: process.env.LOCALAPPDATA, PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, }; @@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void { } } +function isolateCodexDefaultDiscoveryTo(dir: string): void { + isolatePathTo(dir); + if (process.platform === "win32") { + process.env.LOCALAPPDATA = dir; + } +} + afterEach(() => { + if (originalEnv.LOCALAPPDATA === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA; + } process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; for (const dir of tempDirs.splice(0)) { @@ -42,12 +55,51 @@ afterEach(() => { describe("default provider availability", () => { test("Codex reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-codex-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const client = new CodexAppServerAgentClient(createTestLogger()); await expect(client.isAvailable()).resolves.toBe(false); }); + test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => { + const originalPlatform = process.platform; + const originalLocalAppData = process.env.LOCALAPPDATA; + const root = makeTempDir("provider-availability-codex-store-"); + const emptyPathDir = join(root, "empty-path"); + const codexBinDir = join( + root, + "Packages", + "OpenAI.Codex_abc123", + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + ); + const codexExe = join(codexBinDir, "codex.exe"); + mkdirSync(emptyPathDir, { recursive: true }); + mkdirSync(codexBinDir, { recursive: true }); + copyFileSync(process.execPath, codexExe); + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + process.env.LOCALAPPDATA = root; + isolatePathTo(emptyPathDir); + process.env.PATHEXT = ".EXE"; + + try { + const client = new CodexAppServerAgentClient(createTestLogger()); + + await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe); + await expect(client.isAvailable()).resolves.toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + } + }); + test("Claude reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-claude-"); isolatePathTo(binDir); @@ -66,7 +118,7 @@ describe("default provider availability", () => { test("AgentManager reports Codex unavailable without throwing", async () => { const binDir = makeTempDir("provider-availability-manager-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-manager-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ @@ -88,7 +140,7 @@ describe("default provider availability", () => { test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => { const binDir = makeTempDir("provider-availability-resume-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-resume-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({