diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index af75b1c6d872..4e0073d75edb 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -316,6 +316,59 @@ describe("startSession", () => { manager.stopAll(); } }); + + it("fails fast with an upgrade message when codex is below the minimum supported version", async () => { + const manager = new CodexAppServerManager(); + const events: Array<{ method: string; kind: string; message?: string }> = []; + manager.on("event", (event) => { + events.push({ + method: event.method, + kind: event.kind, + ...(event.message ? { message: event.message } : {}), + }); + }); + + const versionCheck = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => { + throw new Error( + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow( + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + expect(versionCheck).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + { + method: "session/startFailed", + kind: "error", + message: + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + }, + ]); + } finally { + versionCheck.mockRestore(); + manager.stopAll(); + } + }); }); describe("sendTurn", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index c80b9f75f821..a8a8ce4607b3 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -22,6 +22,12 @@ import { import { normalizeModelSlug } from "@t3tools/shared/model"; import { Effect, ServiceMap } from "effect"; +import { + formatCodexCliUpgradeMessage, + isCodexCliVersionSupported, + parseCodexCliVersion, +} from "./provider/codexCliVersion"; + type PendingRequestKey = string; interface PendingRequest { @@ -138,6 +144,8 @@ 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 = @@ -535,6 +543,11 @@ export class CodexAppServerManager extends EventEmitter): void { context.session = { ...context.session, @@ -1500,6 +1521,51 @@ 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; diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 58eac64da0a1..90df9b691fe9 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -85,6 +85,28 @@ it.effect("returns unavailable when codex is missing", () => }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), ); +it.effect("returns unavailable when codex is below the minimum supported version", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), +); + it.effect("returns unauthenticated when auth probe reports login required", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus; diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index e9362758667d..59f41edf81e8 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -16,6 +16,11 @@ import type { import { Effect, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + formatCodexCliUpgradeMessage, + isCodexCliVersionSupported, + parseCodexCliVersion, +} from "../codexCliVersion"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; const DEFAULT_TIMEOUT_MS = 4_000; @@ -247,6 +252,18 @@ export const checkCodexProviderStatus: Effect.Effect< }; } + const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: formatCodexCliUpgradeMessage(parsedVersion), + }; + } + // Probe 2: `codex login status` — is the user authenticated? const authProbe = yield* runCodexCommand(["login", "status"]).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts new file mode 100644 index 000000000000..544020016c62 --- /dev/null +++ b/apps/server/src/provider/codexCliVersion.ts @@ -0,0 +1,141 @@ +const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; + +export const MINIMUM_CODEX_CLI_VERSION = "0.37.0"; + +interface ParsedSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +function normalizeCodexVersion(version: string): string { + const [main, prerelease] = version.trim().split("-", 2); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +function parseSemver(version: string): ParsedSemver | null { + const normalized = normalizeCodexVersion(version); + const [main = "", prerelease] = normalized.split("-", 2); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { + return null; + } + + const major = Number.parseInt(majorSegment, 10); + const minor = Number.parseInt(minorSegment, 10); + const patch = Number.parseInt(patchSegment, 10); + if (![major, minor, patch].every(Number.isInteger)) { + return null; + } + + return { + major, + minor, + patch, + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +export function compareCodexCliVersions(left: string, right: string): number { + const parsedLeft = parseSemver(left); + const parsedRight = parseSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} + +export function parseCodexCliVersion(output: string): string | null { + const match = CODEX_VERSION_PATTERN.exec(output); + if (!match?.[1]) { + return null; + } + + const parsed = parseSemver(match[1]); + if (!parsed) { + return null; + } + + return normalizeCodexVersion(match[1]); +} + +export function isCodexCliVersionSupported(version: string): boolean { + return compareCodexCliVersions(version, MINIMUM_CODEX_CLI_VERSION) >= 0; +} + +export function formatCodexCliUpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Codex CLI ${versionLabel} is too old for T3 Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart T3 Code.`; +}