authorgravatar for jason.laster.11@gmail.comJason Laster <jason.laster.11@gmail.com> 2026-03-07 14:12:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-07 13:12:11-08:00
logc97c6b7836ce888b24a157de8eb4aea761028979
treefb946807d2261283075ed8eb163189c69ad63026
parent59b0527c38905784a04bd530f2a4a7d38073c72c
signature Signed by PGP key B5690EEEBB952194

Enforce minimum Codex CLI version (≥0.37.0) before starting sessions (#404)

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.

5 files changed, 299 insertions(+), 0 deletions(-)

apps/server/src/codexAppServerManager.test.ts+53
...@@ -316,6 +316,59 @@ describe("startSession", () => {...@@ -316,6 +316,59 @@ describe("startSession", () => {
316 manager.stopAll();316 manager.stopAll();
317 }317 }
318 });318 });
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 });
319});372});
320373
321describe("sendTurn", () => {374describe("sendTurn", () => {
apps/server/src/codexAppServerManager.ts+66
...@@ -22,6 +22,12 @@ import {...@@ -22,6 +22,12 @@ import {
22import { normalizeModelSlug } from "@t3tools/shared/model";22import { normalizeModelSlug } from "@t3tools/shared/model";
23import { Effect, ServiceMap } from "effect";23import { Effect, ServiceMap } from "effect";
2424
25import {
26 formatCodexCliUpgradeMessage,
27 isCodexCliVersionSupported,
28 parseCodexCliVersion,
29} from "./provider/codexCliVersion";
30
25type PendingRequestKey = string;31type PendingRequestKey = string;
2632
27interface PendingRequest {33interface PendingRequest {
...@@ -138,6 +144,8 @@ export interface CodexThreadSnapshot {...@@ -138,6 +144,8 @@ export interface CodexThreadSnapshot {
138 turns: CodexThreadTurnSnapshot[];144 turns: CodexThreadTurnSnapshot[];
139}145}
140146
147const CODEX_VERSION_CHECK_TIMEOUT_MS = 4_000;
148
141const ANSI_ESCAPE_CHAR = String.fromCharCode(27);149const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
142const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");150const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
143const CODEX_STDERR_LOG_REGEX =151const CODEX_STDERR_LOG_REGEX =
...@@ -535,6 +543,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -535,6 +543,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
535 const codexOptions = readCodexProviderOptions(input);543 const codexOptions = readCodexProviderOptions(input);
536 const codexBinaryPath = codexOptions.binaryPath ?? "codex";544 const codexBinaryPath = codexOptions.binaryPath ?? "codex";
537 const codexHomePath = codexOptions.homePath;545 const codexHomePath = codexOptions.homePath;
546 this.assertSupportedCodexCliVersion({
547 binaryPath: codexBinaryPath,
548 cwd: resolvedCwd,
549 ...(codexHomePath ? { homePath: codexHomePath } : {}),
550 });
538 const child = spawn(codexBinaryPath, ["app-server"], {551 const child = spawn(codexBinaryPath, ["app-server"], {
539 cwd: resolvedCwd,552 cwd: resolvedCwd,
540 env: {553 env: {
...@@ -1320,6 +1333,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -1320,6 +1333,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
1320 this.emit("event", event);1333 this.emit("event", event);
1321 }1334 }
13221335
1336 private assertSupportedCodexCliVersion(input: {
1337 readonly binaryPath: string;
1338 readonly cwd: string;
1339 readonly homePath?: string;
1340 }): void {
1341 assertSupportedCodexCliVersion(input);
1342 }
1343
1323 private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void {1344 private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void {
1324 context.session = {1345 context.session = {
1325 ...context.session,1346 ...context.session,
...@@ -1500,6 +1521,51 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {...@@ -1500,6 +1521,51 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
1500 };1521 };
1501}1522}
15021523
1524function 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
1503function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {1569function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
1504 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {1570 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
1505 return undefined;1571 return undefined;
apps/server/src/provider/Layers/ProviderHealth.test.ts+22
...@@ -85,6 +85,28 @@ it.effect("returns unavailable when codex is missing", () =>...@@ -85,6 +85,28 @@ it.effect("returns unavailable when codex is missing", () =>
85 }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),85 }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),
86);86);
8787
88it.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
88it.effect("returns unauthenticated when auth probe reports login required", () =>110it.effect("returns unauthenticated when auth probe reports login required", () =>
89 Effect.gen(function* () {111 Effect.gen(function* () {
90 const status = yield* checkCodexProviderStatus;112 const status = yield* checkCodexProviderStatus;
apps/server/src/provider/Layers/ProviderHealth.ts+17
...@@ -16,6 +16,11 @@ import type {...@@ -16,6 +16,11 @@ import type {
16import { Effect, Layer, Option, Result, Stream } from "effect";16import { Effect, Layer, Option, Result, Stream } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1818
19import {
20 formatCodexCliUpgradeMessage,
21 isCodexCliVersionSupported,
22 parseCodexCliVersion,
23} from "../codexCliVersion";
19import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";24import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
2025
21const DEFAULT_TIMEOUT_MS = 4_000;26const DEFAULT_TIMEOUT_MS = 4_000;
...@@ -247,6 +252,18 @@ export const checkCodexProviderStatus: Effect.Effect<...@@ -247,6 +252,18 @@ export const checkCodexProviderStatus: Effect.Effect<
247 };252 };
248 }253 }
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
250 // Probe 2: `codex login status` — is the user authenticated?267 // Probe 2: `codex login status` — is the user authenticated?
251 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(268 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
252 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),269 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
apps/server/src/provider/codexCliVersion.ts created+141
...@@ -0,0 +1,141 @@
1const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/;
2
3export const MINIMUM_CODEX_CLI_VERSION = "0.37.0";
4
5interface ParsedSemver {
6 readonly major: number;
7 readonly minor: number;
8 readonly patch: number;
9 readonly prerelease: ReadonlyArray<string>;
10}
11
12function 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
26function 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
58function 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
74export 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
120export 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
134export function isCodexCliVersionSupported(version: string): boolean {
135 return compareCodexCliVersions(version, MINIMUM_CODEX_CLI_VERSION) >= 0;
136}
137
138export 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}