Skip to content

Commit 4e9790f

Browse files
jasonLasterclaude
andcommitted
Enforce minimum Codex CLI version (≥0.37.0) before starting sessions
Add a pre-flight version check that runs `codex --version` and blocks session startup with a clear upgrade message when the installed CLI is below v0.37.0. The check runs both synchronously at session start (codexAppServerManager) and during provider health probes (ProviderHealth), so users get fast feedback in the UI. Introduces `codexCliVersion.ts` with semver parsing, comparison, and formatting helpers shared by both call sites. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 59b0527 commit 4e9790f

File tree

5 files changed

+299
-0
lines changed

5 files changed

+299
-0
lines changed

apps/server/src/codexAppServerManager.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,59 @@ describe("startSession", () => {
316316
manager.stopAll();
317317
}
318318
});
319+
320+
it("fails fast with an upgrade message when codex is below the minimum supported version", async () => {
321+
const manager = new CodexAppServerManager();
322+
const events: Array<{ method: string; kind: string; message?: string }> = [];
323+
manager.on("event", (event) => {
324+
events.push({
325+
method: event.method,
326+
kind: event.kind,
327+
...(event.message ? { message: event.message } : {}),
328+
});
329+
});
330+
331+
const versionCheck = vi
332+
.spyOn(
333+
manager as unknown as {
334+
assertSupportedCodexCliVersion: (input: {
335+
binaryPath: string;
336+
cwd: string;
337+
homePath?: string;
338+
}) => void;
339+
},
340+
"assertSupportedCodexCliVersion",
341+
)
342+
.mockImplementation(() => {
343+
throw new Error(
344+
"Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",
345+
);
346+
});
347+
348+
try {
349+
await expect(
350+
manager.startSession({
351+
threadId: asThreadId("thread-1"),
352+
provider: "codex",
353+
runtimeMode: "full-access",
354+
}),
355+
).rejects.toThrow(
356+
"Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",
357+
);
358+
expect(versionCheck).toHaveBeenCalledTimes(1);
359+
expect(events).toEqual([
360+
{
361+
method: "session/startFailed",
362+
kind: "error",
363+
message:
364+
"Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",
365+
},
366+
]);
367+
} finally {
368+
versionCheck.mockRestore();
369+
manager.stopAll();
370+
}
371+
});
319372
});
320373

321374
describe("sendTurn", () => {

apps/server/src/codexAppServerManager.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import {
2222
import { normalizeModelSlug } from "@t3tools/shared/model";
2323
import { Effect, ServiceMap } from "effect";
2424

25+
import {
26+
formatCodexCliUpgradeMessage,
27+
isCodexCliVersionSupported,
28+
parseCodexCliVersion,
29+
} from "./provider/codexCliVersion";
30+
2531
type PendingRequestKey = string;
2632

2733
interface PendingRequest {
@@ -138,6 +144,8 @@ export interface CodexThreadSnapshot {
138144
turns: CodexThreadTurnSnapshot[];
139145
}
140146

147+
const CODEX_VERSION_CHECK_TIMEOUT_MS = 4_000;
148+
141149
const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
142150
const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
143151
const CODEX_STDERR_LOG_REGEX =
@@ -535,6 +543,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
535543
const codexOptions = readCodexProviderOptions(input);
536544
const codexBinaryPath = codexOptions.binaryPath ?? "codex";
537545
const codexHomePath = codexOptions.homePath;
546+
this.assertSupportedCodexCliVersion({
547+
binaryPath: codexBinaryPath,
548+
cwd: resolvedCwd,
549+
...(codexHomePath ? { homePath: codexHomePath } : {}),
550+
});
538551
const child = spawn(codexBinaryPath, ["app-server"], {
539552
cwd: resolvedCwd,
540553
env: {
@@ -1320,6 +1333,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
13201333
this.emit("event", event);
13211334
}
13221335

1336+
private assertSupportedCodexCliVersion(input: {
1337+
readonly binaryPath: string;
1338+
readonly cwd: string;
1339+
readonly homePath?: string;
1340+
}): void {
1341+
assertSupportedCodexCliVersion(input);
1342+
}
1343+
13231344
private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void {
13241345
context.session = {
13251346
...context.session,
@@ -1500,6 +1521,51 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
15001521
};
15011522
}
15021523

1524+
function assertSupportedCodexCliVersion(input: {
1525+
readonly binaryPath: string;
1526+
readonly cwd: string;
1527+
readonly homePath?: string;
1528+
}): void {
1529+
const result = spawnSync(input.binaryPath, ["--version"], {
1530+
cwd: input.cwd,
1531+
env: {
1532+
...process.env,
1533+
...(input.homePath ? { CODEX_HOME: input.homePath } : {}),
1534+
},
1535+
encoding: "utf8",
1536+
shell: process.platform === "win32",
1537+
stdio: ["ignore", "pipe", "pipe"],
1538+
timeout: CODEX_VERSION_CHECK_TIMEOUT_MS,
1539+
maxBuffer: 1024 * 1024,
1540+
});
1541+
1542+
if (result.error) {
1543+
const lower = result.error.message.toLowerCase();
1544+
if (
1545+
lower.includes("enoent") ||
1546+
lower.includes("command not found") ||
1547+
lower.includes("not found")
1548+
) {
1549+
throw new Error(`Codex CLI (${input.binaryPath}) is not installed or not executable.`);
1550+
}
1551+
throw new Error(
1552+
`Failed to execute Codex CLI version check: ${result.error.message || String(result.error)}`,
1553+
);
1554+
}
1555+
1556+
const stdout = result.stdout ?? "";
1557+
const stderr = result.stderr ?? "";
1558+
if (result.status !== 0) {
1559+
const detail = stderr.trim() || stdout.trim() || `Command exited with code ${result.status}.`;
1560+
throw new Error(`Codex CLI version check failed. ${detail}`);
1561+
}
1562+
1563+
const parsedVersion = parseCodexCliVersion(`${stdout}\n${stderr}`);
1564+
if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
1565+
throw new Error(formatCodexCliUpgradeMessage(parsedVersion));
1566+
}
1567+
}
1568+
15031569
function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
15041570
if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
15051571
return undefined;

apps/server/src/provider/Layers/ProviderHealth.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ it.effect("returns unavailable when codex is missing", () =>
8585
}).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),
8686
);
8787

88+
it.effect("returns unavailable when codex is below the minimum supported version", () =>
89+
Effect.gen(function* () {
90+
const status = yield* checkCodexProviderStatus;
91+
assert.strictEqual(status.provider, "codex");
92+
assert.strictEqual(status.status, "error");
93+
assert.strictEqual(status.available, false);
94+
assert.strictEqual(status.authStatus, "unknown");
95+
assert.strictEqual(
96+
status.message,
97+
"Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",
98+
);
99+
}).pipe(
100+
Effect.provide(
101+
mockSpawnerLayer((args) => {
102+
const joined = args.join(" ");
103+
if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 };
104+
throw new Error(`Unexpected args: ${joined}`);
105+
}),
106+
),
107+
),
108+
);
109+
88110
it.effect("returns unauthenticated when auth probe reports login required", () =>
89111
Effect.gen(function* () {
90112
const status = yield* checkCodexProviderStatus;

apps/server/src/provider/Layers/ProviderHealth.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import type {
1616
import { Effect, Layer, Option, Result, Stream } from "effect";
1717
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1818

19+
import {
20+
formatCodexCliUpgradeMessage,
21+
isCodexCliVersionSupported,
22+
parseCodexCliVersion,
23+
} from "../codexCliVersion";
1924
import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
2025

2126
const DEFAULT_TIMEOUT_MS = 4_000;
@@ -247,6 +252,18 @@ export const checkCodexProviderStatus: Effect.Effect<
247252
};
248253
}
249254

255+
const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`);
256+
if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
257+
return {
258+
provider: CODEX_PROVIDER,
259+
status: "error" as const,
260+
available: false,
261+
authStatus: "unknown" as const,
262+
checkedAt,
263+
message: formatCodexCliUpgradeMessage(parsedVersion),
264+
};
265+
}
266+
250267
// Probe 2: `codex login status` — is the user authenticated?
251268
const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
252269
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/;
2+
3+
export const MINIMUM_CODEX_CLI_VERSION = "0.37.0";
4+
5+
interface ParsedSemver {
6+
readonly major: number;
7+
readonly minor: number;
8+
readonly patch: number;
9+
readonly prerelease: ReadonlyArray<string>;
10+
}
11+
12+
function normalizeCodexVersion(version: string): string {
13+
const [main, prerelease] = version.trim().split("-", 2);
14+
const segments = (main ?? "")
15+
.split(".")
16+
.map((segment) => segment.trim())
17+
.filter((segment) => segment.length > 0);
18+
19+
if (segments.length === 2) {
20+
segments.push("0");
21+
}
22+
23+
return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join(".");
24+
}
25+
26+
function parseSemver(version: string): ParsedSemver | null {
27+
const normalized = normalizeCodexVersion(version);
28+
const [main = "", prerelease] = normalized.split("-", 2);
29+
const segments = main.split(".");
30+
if (segments.length !== 3) {
31+
return null;
32+
}
33+
34+
const [majorSegment, minorSegment, patchSegment] = segments;
35+
if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) {
36+
return null;
37+
}
38+
39+
const major = Number.parseInt(majorSegment, 10);
40+
const minor = Number.parseInt(minorSegment, 10);
41+
const patch = Number.parseInt(patchSegment, 10);
42+
if (![major, minor, patch].every(Number.isInteger)) {
43+
return null;
44+
}
45+
46+
return {
47+
major,
48+
minor,
49+
patch,
50+
prerelease:
51+
prerelease
52+
?.split(".")
53+
.map((segment) => segment.trim())
54+
.filter((segment) => segment.length > 0) ?? [],
55+
};
56+
}
57+
58+
function comparePrereleaseIdentifier(left: string, right: string): number {
59+
const leftNumeric = /^\d+$/.test(left);
60+
const rightNumeric = /^\d+$/.test(right);
61+
62+
if (leftNumeric && rightNumeric) {
63+
return Number.parseInt(left, 10) - Number.parseInt(right, 10);
64+
}
65+
if (leftNumeric) {
66+
return -1;
67+
}
68+
if (rightNumeric) {
69+
return 1;
70+
}
71+
return left.localeCompare(right);
72+
}
73+
74+
export function compareCodexCliVersions(left: string, right: string): number {
75+
const parsedLeft = parseSemver(left);
76+
const parsedRight = parseSemver(right);
77+
if (!parsedLeft || !parsedRight) {
78+
return left.localeCompare(right);
79+
}
80+
81+
if (parsedLeft.major !== parsedRight.major) {
82+
return parsedLeft.major - parsedRight.major;
83+
}
84+
if (parsedLeft.minor !== parsedRight.minor) {
85+
return parsedLeft.minor - parsedRight.minor;
86+
}
87+
if (parsedLeft.patch !== parsedRight.patch) {
88+
return parsedLeft.patch - parsedRight.patch;
89+
}
90+
91+
if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) {
92+
return 0;
93+
}
94+
if (parsedLeft.prerelease.length === 0) {
95+
return 1;
96+
}
97+
if (parsedRight.prerelease.length === 0) {
98+
return -1;
99+
}
100+
101+
const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length);
102+
for (let index = 0; index < length; index += 1) {
103+
const leftIdentifier = parsedLeft.prerelease[index];
104+
const rightIdentifier = parsedRight.prerelease[index];
105+
if (leftIdentifier === undefined) {
106+
return -1;
107+
}
108+
if (rightIdentifier === undefined) {
109+
return 1;
110+
}
111+
const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier);
112+
if (comparison !== 0) {
113+
return comparison;
114+
}
115+
}
116+
117+
return 0;
118+
}
119+
120+
export function parseCodexCliVersion(output: string): string | null {
121+
const match = CODEX_VERSION_PATTERN.exec(output);
122+
if (!match?.[1]) {
123+
return null;
124+
}
125+
126+
const parsed = parseSemver(match[1]);
127+
if (!parsed) {
128+
return null;
129+
}
130+
131+
return normalizeCodexVersion(match[1]);
132+
}
133+
134+
export function isCodexCliVersionSupported(version: string): boolean {
135+
return compareCodexCliVersions(version, MINIMUM_CODEX_CLI_VERSION) >= 0;
136+
}
137+
138+
export function formatCodexCliUpgradeMessage(version: string | null): string {
139+
const versionLabel = version ? `v${version}` : "the installed version";
140+
return `Codex CLI ${versionLabel} is too old for T3 Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart T3 Code.`;
141+
}

0 commit comments

Comments
 (0)