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
3 changes: 2 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
materializeCodexShadowHome,
resolveCodexHomeLayout,
} from "./CodexHomeLayout.ts";
import { resolveCodexBinaryPath } from "./CodexExecutable.ts";
const decodeCodexSettings = Schema.decodeSync(CodexSettings);

const DRIVER_KIND = ProviderDriverKind.make("codex");
Expand Down Expand Up @@ -155,7 +156,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
const effectiveConfig = {
...config,
enabled,
binaryPath: expandHomePath(config.binaryPath),
binaryPath: yield* resolveCodexBinaryPath(expandHomePath(config.binaryPath), processEnv),
homePath: homeLayout.effectiveHomePath ?? "",
} satisfies CodexSettings;
const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/provider/Drivers/CodexExecutable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";

import { resolveCodexBinaryPath } from "./CodexExecutable.ts";

const BUNDLED_CLI = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex";
const OLDER_BUNDLED_CLI = "/Applications/ChatGPT.app/Contents/Resources/codex";

function withMacExecutables(executables: ReadonlyArray<string>) {
const existing = new Set(executables);
return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provideService(SpawnExecutableResolution, (command) =>
existing.has(command) ? command : undefined,
),
);
}

describe("resolveCodexBinaryPath", () => {
it.effect("keeps the default command while it resolves on PATH", () =>
Effect.gen(function* () {
expect(
yield* resolveCodexBinaryPath("codex", {}).pipe(withMacExecutables(["codex", BUNDLED_CLI])),
).toBe("codex");
}),
);

it.effect("uses the CLI bundled with ChatGPT when codex is not on PATH", () =>
Effect.gen(function* () {
expect(
yield* resolveCodexBinaryPath("codex", {}).pipe(
withMacExecutables([BUNDLED_CLI, OLDER_BUNDLED_CLI]),
),
).toBe(BUNDLED_CLI);
}),
);

it.effect("finds the older ChatGPT bundle layout", () =>
Effect.gen(function* () {
expect(
yield* resolveCodexBinaryPath("codex", {}).pipe(withMacExecutables([OLDER_BUNDLED_CLI])),
).toBe(OLDER_BUNDLED_CLI);
}),
);

it.effect("keeps the default command when no bundled CLI exists either", () =>
Effect.gen(function* () {
expect(yield* resolveCodexBinaryPath("codex", {}).pipe(withMacExecutables([]))).toBe("codex");
}),
);

it.effect("returns a configured path unchanged", () =>
Effect.gen(function* () {
expect(
yield* resolveCodexBinaryPath("/opt/codex/bin/codex", {}).pipe(
withMacExecutables([BUNDLED_CLI]),
),
).toBe("/opt/codex/bin/codex");
}),
);

it.effect("leaves other platforms alone", () =>
Effect.gen(function* () {
expect(
yield* resolveCodexBinaryPath("codex", {}).pipe(
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provideService(SpawnExecutableResolution, () => {
throw new Error("must not resolve outside macOS");
}),
),
).toBe("codex");
}),
);
});
42 changes: 42 additions & 0 deletions apps/server/src/provider/Drivers/CodexExecutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";

/**
* Codex CLIs the ChatGPT desktop app ships inside its bundle on macOS, newest
* layout first. The app does not put either one on PATH.
*/
const MACOS_APP_BUNDLED_CODEX_PATHS = [
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex",
"/Applications/ChatGPT.app/Contents/Resources/codex",
] as const;

/**
* Resolves the configured Codex binary path for spawning.
*
* A path the user set is returned unchanged. The default `codex` stays as is
* while it resolves on PATH; on macOS, when it does not, the CLI bundled with
* the ChatGPT desktop app is used instead, so installing ChatGPT is enough to
* run Codex without setting a Binary path by hand.
*/
export const resolveCodexBinaryPath = Effect.fn("resolveCodexBinaryPath")(function* (
binaryPath: string,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<string> {
if (binaryPath !== "codex") {
return binaryPath;
}
const platform = yield* HostProcessPlatform;
if (platform !== "darwin") {
return binaryPath;
}
const resolveExecutable = yield* SpawnExecutableResolution;
if (resolveExecutable(binaryPath, platform, environment) !== undefined) {
return binaryPath;
}
return (
MACOS_APP_BUNDLED_CODEX_PATHS.find(
(candidate) => resolveExecutable(candidate, platform, environment) !== undefined,
) ?? binaryPath
);
});
Loading