Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 5 additions & 56 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ import {
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Effect, ServiceMap } from "effect";

import {
formatCodexCliUpgradeMessage,
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "./provider/codexCliVersion";
import { assertSupportedCodexCliVersion } from "./codexCliProbe";

type PendingRequestKey = string;

Expand Down Expand Up @@ -144,8 +140,6 @@ export interface CodexThreadSnapshot {
turns: CodexThreadTurnSnapshot[];
}

const CODEX_VERSION_CHECK_TIMEOUT_MS = 4_000;

const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
const CODEX_STDERR_LOG_REGEX =
Expand Down Expand Up @@ -543,7 +537,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
const codexOptions = readCodexProviderOptions(input);
const codexBinaryPath = codexOptions.binaryPath ?? "codex";
const codexHomePath = codexOptions.homePath;
this.assertSupportedCodexCliVersion({
await this.assertSupportedCodexCliVersion({
binaryPath: codexBinaryPath,
cwd: resolvedCwd,
...(codexHomePath ? { homePath: codexHomePath } : {}),
Expand Down Expand Up @@ -1333,12 +1327,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
this.emit("event", event);
}

private assertSupportedCodexCliVersion(input: {
private async assertSupportedCodexCliVersion(input: {
readonly binaryPath: string;
readonly cwd: string;
readonly homePath?: string;
}): void {
assertSupportedCodexCliVersion(input);
}): Promise<void> {
await assertSupportedCodexCliVersion(input);
}

private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void {
Expand Down Expand Up @@ -1521,51 +1515,6 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
};
}

function assertSupportedCodexCliVersion(input: {
readonly binaryPath: string;
readonly cwd: string;
readonly homePath?: string;
}): void {
const result = spawnSync(input.binaryPath, ["--version"], {
cwd: input.cwd,
env: {
...process.env,
...(input.homePath ? { CODEX_HOME: input.homePath } : {}),
},
encoding: "utf8",
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
timeout: CODEX_VERSION_CHECK_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});

if (result.error) {
const lower = result.error.message.toLowerCase();
if (
lower.includes("enoent") ||
lower.includes("command not found") ||
lower.includes("not found")
) {
throw new Error(`Codex CLI (${input.binaryPath}) is not installed or not executable.`);
}
throw new Error(
`Failed to execute Codex CLI version check: ${result.error.message || String(result.error)}`,
);
}

const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (result.status !== 0) {
const detail = stderr.trim() || stdout.trim() || `Command exited with code ${result.status}.`;
throw new Error(`Codex CLI version check failed. ${detail}`);
}

const parsedVersion = parseCodexCliVersion(`${stdout}\n${stderr}`);
if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
throw new Error(formatCodexCliUpgradeMessage(parsedVersion));
}
}

function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
return undefined;
Expand Down
108 changes: 108 additions & 0 deletions apps/server/src/codexCliProbe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";

const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
}));

vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));

import {
assertSupportedCodexCliVersion,
type CodexCliCommandRunner,
runCodexCliCommand,
} from "./codexCliProbe";

class MockChildProcess extends EventEmitter {
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly kill = vi.fn();
}

describe("runCodexCliCommand", () => {
it("rejects when the child process closes without an exit code", async () => {
const child = new MockChildProcess();
spawnMock.mockReturnValueOnce(child);

const commandPromise = runCodexCliCommand({
binaryPath: "codex",
args: ["--version"],
timeoutMs: 1_000,
});

child.emit("close", null, null);

await expect(commandPromise).rejects.toThrow("Codex CLI process exited without an exit code.");
});
});

describe("assertSupportedCodexCliVersion", () => {
it("retries a timed out version check once before succeeding", async () => {
const runCommand = vi
.fn<CodexCliCommandRunner>()
.mockResolvedValueOnce({
stdout: "",
stderr: "",
code: 124,
timedOut: true,
})
.mockResolvedValueOnce({
stdout: "codex-cli 0.112.0\n",
stderr: "",
code: 0,
});

await expect(
assertSupportedCodexCliVersion(
{
binaryPath: "codex",
cwd: "/tmp",
},
runCommand,
),
).resolves.toBeUndefined();

expect(runCommand).toHaveBeenCalledTimes(2);
});

it("surfaces a timeout after exhausting retries", async () => {
const runCommand = vi.fn<CodexCliCommandRunner>().mockResolvedValue({
stdout: "",
stderr: "",
code: 124,
timedOut: true,
});

await expect(
assertSupportedCodexCliVersion(
{
binaryPath: "codex",
cwd: "/tmp",
},
runCommand,
),
).rejects.toThrow("Failed to execute Codex CLI version check: Timed out while running command.");

expect(runCommand).toHaveBeenCalledTimes(2);
});

it("rejects when the version output cannot be parsed", async () => {
const runCommand = vi.fn<CodexCliCommandRunner>().mockResolvedValue({
stdout: "codex-cli version unknown\n",
stderr: "",
code: 0,
});

await expect(
assertSupportedCodexCliVersion(
{
binaryPath: "codex",
cwd: "/tmp",
},
runCommand,
),
).rejects.toThrow("Codex CLI version check failed. Could not parse Codex CLI version from output.");
});
});
Loading