Skip to content
67 changes: 60 additions & 7 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
import {
buildServerProvider,
COMPACT_SLASH_COMMAND,
DEFAULT_TIMEOUT_MS,
providerModelsFromSettings,
spawnAndCollect,
type ServerProviderDraft,
} from "@t3tools/provider-core/server/snapshotProbe";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
Expand Down Expand Up @@ -74,6 +76,9 @@ const CODEX_CLIENT_NAME = "T3 Code";
// Handshake plus account/models/skills requests can exceed the shared 10s probe budget.
const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000;
const CODEX_DISCOVERY_MARGIN_MS = 250;
// `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`.
// Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`.
const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u;

const CODEX_PRESENTATION = {
displayName: "Codex",
Expand Down Expand Up @@ -458,22 +463,29 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
readonly binaryPath: string;
const codexProcessEnvironment = Effect.fn("codexProcessEnvironment")(function* (input: {
readonly homePath?: string | undefined;
readonly launchArgs?: string | undefined;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv | undefined;
}) {
// Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects.
const resolvedHomePath = input.homePath
? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory)
: undefined;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
return {
...codexAppServerEnvironment(input.environment),
...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
};
});

export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string | undefined;
readonly launchArgs?: string | undefined;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv | undefined;
}) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = yield* codexProcessEnvironment(input);
const spawnCommand = yield* resolveSpawnCommand(
input.binaryPath,
codexAppServerArgs(input.launchArgs),
Expand Down Expand Up @@ -593,6 +605,39 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
} satisfies CodexAppServerProviderSnapshot;
});

// Old CLIs can exit before initialize reports the version needed for compatibility advice.
const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")(
function* (input: {
readonly binaryPath: string;
readonly homePath?: string | undefined;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv | undefined;
}) {
const environment = yield* codexProcessEnvironment(input);
const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["--version"], {
env: environment,
extendEnv: true,
});
const result = yield* spawnAndCollect(
input.binaryPath,
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
cwd: input.cwd,
env: environment,
extendEnv: true,
forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
shell: spawnCommand.shell,
}),
);
if (result.code !== 0) return null;
return CODEX_CLI_VERSION_PATTERN.exec(result.stdout)?.[1] ?? null;
},
Effect.timeoutOption(Duration.millis(DEFAULT_TIMEOUT_MS)),
Effect.map(Option.getOrNull),
Effect.catch((cause) =>
Effect.logDebug("Codex --version fallback failed.", { cause }).pipe(Effect.as(null)),
),
);

export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
Expand Down Expand Up @@ -738,6 +783,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
if (Result.isFailure(probeResult)) {
const error = probeResult.failure;
const installed = !isCodexAppServerSpawnError(error);
const version = installed
? yield* probeCodexCliVersion({
binaryPath: codexSettings.binaryPath,
homePath: codexSettings.homePath,
cwd: process.cwd(),
environment: resolvedEnvironment,
})
: null;
return buildServerProvider({
presentation: CODEX_PRESENTATION,
enabled: codexSettings.enabled,
Expand All @@ -746,7 +799,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
skills: [],
probe: {
installed,
version: null,
version,
status: "error",
auth: { status: "unknown" },
message: installed
Expand Down
150 changes: 150 additions & 0 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as CodexErrors from "effect-codex-app-server/errors";
import * as Path from "effect/Path";
import * as FileSystem from "effect/FileSystem";
import { vi } from "vite-plus/test";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as CodexInstallation from "./CodexInstallation.ts";
Expand Down Expand Up @@ -86,6 +90,7 @@ const disabledDefaultSlots = (
);

const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
const decodeCodexSettings = Schema.decodeSync(CodexSettings);
const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});

process.env.T3CODE_CURSOR_ENABLED = "1";
Expand Down Expand Up @@ -213,6 +218,20 @@ function layerMockSpawner(
);
}

// A Codex CLI on disk whose `app-server` and `--version` behave as `script` says.
const writeFakeCodexCli = Effect.fn("writeFakeCodexCli")(function* (script: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-codex-" });
const binaryPath = path.join(directory, "codex");
yield* fs.writeFileString(binaryPath, `#!/bin/sh\n${script}\n`);
yield* fs.chmod(binaryPath, 0o755);
return { directory, binaryPath };
});

// The npm `@openai/codex` 0.1 CLI treats `app-server` as a prompt and exits 1.
const OBSOLETE_CODEX_APP_SERVER = `echo "Missing OpenAI API key." >&2; exit 1`;

const codexModelCapabilities = createModelCapabilities({
optionDescriptors: [
selectDescriptor("reasoningEffort", "Reasoning", [
Expand Down Expand Up @@ -424,6 +443,137 @@ it.layer(
assert.strictEqual(status.auth.status, "unknown");
}),
);

it.effect("reads the version of a Codex CLI too old to run app-server (#20351)", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
const codexHome = yield* FileSystem.FileSystem.pipe(
Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-codex-home-" })),
);
const { binaryPath } = yield* writeFakeCodexCli(
[
`if [ "$1" = "--version" ]; then`,
// The fallback runs with the provider's environment and CODEX_HOME.
` [ "$CODEX_HOME" = "${codexHome}" ] && [ "$FAKE_CODEX_ENV" = "kept" ] || exit 2`,
` echo 0.1.4161006; exit 0`,
`fi`,
OBSOLETE_CODEX_APP_SERVER,
].join("\n"),
);
const settings = decodeCodexSettings({ binaryPath, homePath: codexHome });

const status = yield* checkCodexProviderStatus(settings, undefined, {
...process.env,
FAKE_CODEX_ENV: "kept",
});

assert.strictEqual(status.status, "error");
assert.strictEqual(status.installed, true);
assert.strictEqual(status.version, "0.1.4161006");
assert.match(status.message ?? "", /^Codex app-server provider probe failed: /);
const classified = withBundledCompatibility({
...status,
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
});
assert.strictEqual(classified.compatibilityAdvisory?.status, "broken");
}),
);

it.effect("keeps a prerelease --version unclassified instead of reading it as stable", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
const { binaryPath } = yield* writeFakeCodexCli(
[
`[ "$1" = "--version" ] && { echo "codex-cli 0.159.0-alpha.1"; exit 0; }`,
OBSOLETE_CODEX_APP_SERVER,
].join("\n"),
);

const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath }));

assert.strictEqual(status.version, "0.159.0-alpha.1");
const classified = withBundledCompatibility({
...status,
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
});
assert.strictEqual(classified.compatibilityAdvisory?.status, "unknown");
}),
);

it.effect("keeps the probe failure without a version when --version also fails", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
const { binaryPath } = yield* writeFakeCodexCli(
[
`[ "$1" = "--version" ] && { echo "codex 0.1.0" >&2; exit 3; }`,
OBSOLETE_CODEX_APP_SERVER,
].join("\n"),
);

const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath }));

assert.strictEqual(status.status, "error");
assert.strictEqual(status.installed, true);
assert.strictEqual(status.version, null);
assert.match(status.message ?? "", /^Codex app-server provider probe failed: /);
}),
);

it.effect("bounds a hanging Codex --version fallback and releases its process scope", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const released = yield* Deferred.make<void>();
const spawner = ChildProcessSpawner.make(() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined));
return ChildProcessSpawner.makeHandle({
...mockHandle({ stdout: "", stderr: "", code: 0 }),
exitCode: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
});
}),
);
const fiber = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
Effect.fail(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })),
).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.forkChild,
);
yield* Deferred.await(started);
yield* TestClock.adjust("4 seconds");
const status = yield* Fiber.join(fiber);
assert.strictEqual(status.version, null);
assert.strictEqual(status.status, "error");
assert.strictEqual(status.installed, true);
assert.match(status.message ?? "", /^Codex app-server provider probe failed: /);
assert.isTrue(yield* Deferred.isDone(released));
}),
);

it.effect("does not run --version when the Codex executable cannot start", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { directory, binaryPath } = yield* writeFakeCodexCli(
`touch "$(dirname "$0")/ran"; echo 0.1.4161006`,
);

const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath }), () =>
Effect.fail(
new CodexErrors.CodexAppServerSpawnError({
command: `${binaryPath} app-server`,
cause: new Error("spawn ENOENT"),
}),
),
);

assert.strictEqual(status.installed, false);
assert.strictEqual(status.version, null);
assert.isFalse(yield* fs.exists(path.join(directory, "ran")));
}),
);
});

describe("ProviderRegistry.layer", () => {
Expand Down
Loading