Skip to content
Open
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
51 changes: 29 additions & 22 deletions gui/packages/process-utils/src/cli-locations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { accessSync, constants, existsSync, lstatSync, statSync } from "node:fs";
import {
accessSync,
constants,
existsSync,
lstatSync,
realpathSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path";
import { windowsExecutableExtensions } from "./windows.js";
Expand Down Expand Up @@ -55,17 +62,15 @@ function windowsFallbackCandidates(
];
}

function codexAppBundlePaths(home: string): string[] {
if (process.platform !== "darwin") return [];
return ["/Applications", join(home, "Applications")].flatMap((apps) =>
["ChatGPT.app", "Codex.app"].flatMap((app) => {
const resources = join(apps, app, "Contents", "Resources");
return [
join(resources, "codex-cli", "bin", "codex"),
join(resources, "codex"),
];
}),
);
// Codex.app and ChatGPT.app ship a codex. Running a binary inside a bundle macOS
// flags makes it move the whole app to the Bin (#96), so Cloudroom never runs one,
// even through a PATH symlink. Those users install the standalone CLI instead.
function insideAppBundle(candidate: string): boolean {
try {
return realpathSync(candidate).includes(".app/");
} catch {
return false;
}
}

function homeDir(env: NodeJS.ProcessEnv): string {
Expand Down Expand Up @@ -99,15 +104,13 @@ function fallbackCandidates(cli: string, env: NodeJS.ProcessEnv): string[] {
join(home, ".volta", "bin"),
];
const providerPaths =
cli === "codex"
? codexAppBundlePaths(home)
: cli === "opencode"
? [join(home, ".opencode", "bin", "opencode")]
: cli === "claude"
? [join(home, ".claude", "local", "claude")]
: cli === "grok"
? [join(home, ".grok", "bin", "grok")]
: [];
cli === "opencode"
? [join(home, ".opencode", "bin", "opencode")]
: cli === "claude"
? [join(home, ".claude", "local", "claude")]
: cli === "grok"
? [join(home, ".grok", "bin", "grok")]
: [];
return [...binDirs.map((dir) => join(dir, cli)), ...providerPaths];
}

Expand All @@ -127,7 +130,11 @@ export function findCliExecutable(
...fallbackCandidates(cli, env),
],
env,
).find(isExecutableFile) ?? null
).find(
(candidate) =>
isExecutableFile(candidate) &&
!(cli === "codex" && insideAppBundle(candidate)),
) ?? null
);
}

Expand Down
53 changes: 53 additions & 0 deletions gui/packages/process-utils/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
Expand Down Expand Up @@ -374,4 +376,55 @@ describe("process utils", () => {
writeFileSync(official, "", { mode: 0o755 });
expect(findCliExecutable("codex", env)).toBe(official);
});

// #96: running a binary inside a flagged Codex.app gets the whole app moved to the Bin.
it.skipIf(process.platform === "win32")(
"never resolves the Codex inside Codex.app or ChatGPT.app, even through PATH",
() => {
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", {
...platform,
value: "darwin",
});
try {
const home = mkdtempSync(join(tmpdir(), "codex-app-"));
const bin = join(home, "bin");
mkdirSync(bin);
const env = { HOME: home, PATH: bin };
// A codex this machine keeps in /usr/local/bin or Homebrew; null on a clean Mac.
const elsewhere = findCliExecutable("codex", env);
const bundled = ["ChatGPT.app", "Codex.app"].flatMap((app) => {
const resources = join(
home,
"Applications",
app,
"Contents",
"Resources",
);
return [
join(resources, "codex-cli", "bin", "codex"),
join(resources, "codex"),
];
});
for (const file of bundled) {
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, "", { mode: 0o755 });
}
expect(findCliExecutable("codex", env)).toBe(elsewhere);
symlinkSync(bundled[0]!, join(bin, "codex"));
expect(findCliExecutable("codex", env)).toBe(elsewhere);

// A real PATH install, then OpenAI's official one, still win as before.
rmSync(join(bin, "codex"));
writeFileSync(join(bin, "codex"), "", { mode: 0o755 });
expect(findCliExecutable("codex", env)).toBe(join(bin, "codex"));
const official = officialCodexPath(env);
mkdirSync(join(official, ".."), { recursive: true });
writeFileSync(official, "", { mode: 0o755 });
expect(findCliExecutable("codex", env)).toBe(official);
} finally {
Object.defineProperty(process, "platform", platform);
}
},
);
});
2 changes: 1 addition & 1 deletion gui/plugins/provider-codex/src/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ function missingCodexCliGuidance(): string {
brokenLink === null
? ""
: ` \`${brokenLink}\` points to a file that no longer exists.`;
return `Cloudroom could not find the Codex CLI on this machine.${cause} Install Codex (https://developers.openai.com/codex/cli) or put \`codex\` on PATH, then retry.`;
return `Cloudroom could not find the Codex CLI on this machine.${cause} Install Codex (https://developers.openai.com/codex/cli) or put \`codex\` on PATH, then retry. Cloudroom never runs the copy inside Codex.app or ChatGPT.app.`;
}

export function resolveAppServerLaunch(
Expand Down
56 changes: 56 additions & 0 deletions gui/plugins/provider-codex/src/bridge/provider-maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { experimental_findCliExecutable as findCliExecutable } from "@get-bb/plugin-sdk/provider-bridge";
import { resetChatGptCloudflareCookiesForTests } from "../ai/chatgpt-fetch.js";
import {
__testing,
codexExecutable,
getCodexProviderHealth,
getCodexProviderInstallationStatus,
getCodexProviderUsage,
} from "./provider-maintenance.js";

Expand Down Expand Up @@ -267,6 +270,59 @@ describe("Codex credential health and usage", () => {
});
});

// #96: running a binary inside a flagged Codex.app gets the whole app moved to the Bin.
it.skipIf(process.platform === "win32")(
"never runs the Codex inside Codex.app, even through a PATH symlink, and offers Install",
async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", {
...platform,
value: "darwin",
});
try {
const binDir = path.join(homeDir, "bin");
await fs.rm(path.join(binDir, "codex"));
// Only the fake bin, so no npm runs either.
vi.stubEnv("PATH", binDir);
// A codex this machine keeps in /usr/local/bin or Homebrew; null on a clean Mac.
const elsewhere = findCliExecutable("codex");
const bundled = path.join(
homeDir,
"Applications/Codex.app/Contents/Resources/codex",
);
const ran = path.join(homeDir, "bundled-codex-ran");
await fs.mkdir(path.dirname(bundled), { recursive: true });
const script = `#!/bin/sh\n: > '${ran}'\necho "codex-cli 0.150.0"\n`;
await fs.writeFile(bundled, script, { mode: 0o755 });
await fs.symlink(bundled, path.join(binDir, "codex"));

const status = await getCodexProviderInstallationStatus();
await getCodexProviderHealth();
await getCodexProviderUsage();
const bundledRan = await fs.access(ran).then(
() => true,
() => false,
);
expect(bundledRan).toBe(false);
expect(codexExecutable()).toBe(
elsewhere ??
path.join(homeDir, ".codex/packages/standalone/current/codex"),
);
expect(status).toMatchObject(
elsewhere === null
? {
installed: false,
executablePath: null,
installAction: { kind: "install" },
}
: { executablePath: elsewhere },
);
} finally {
Object.defineProperty(process, "platform", platform);
}
},
);

it("fetches usage with the ChatGPT token and retries once through a Cloudflare challenge", async () => {
const accessToken = createAccessToken({
accountId: "account-123",
Expand Down
25 changes: 15 additions & 10 deletions gui/plugins/provider-codex/src/bridge/provider-maintenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { realpath } from "node:fs/promises";
import { sep } from "node:path";
import { homedir } from "node:os";
import { join, sep } from "node:path";
import {
type ProviderHealthResult,
type ProviderInstallationRunResult,
Expand Down Expand Up @@ -81,8 +82,19 @@ function minimumSupportedVersionForRequirement(
: CODEX_MINIMUM_SUPPORTED_VERSION;
}

// With no Codex Cloudroom may run, name where OpenAI's installer would put it, so a run fails as
// "not installed". A bare `codex` would let PATH reach a symlink into Codex.app (#96).
export function codexExecutable(env: NodeJS.ProcessEnv = process.env): string {
return findCliExecutable("codex", env) ?? "codex";
return (
findCliExecutable("codex", env) ??
join(
env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".codex"),
"packages",
"standalone",
"current",
"codex",
)
);
}

function codexUpdateCommand(
Expand Down Expand Up @@ -127,14 +139,7 @@ export async function getCodexProviderInstallationStatus(
]);
const installed = resolvedExecutable !== null || versionOutput !== null;
const currentVersion = versionFrom(versionOutput);
// The ChatGPT app ships its own Codex, which only updates with the app.
const appBundled =
resolvedExecutable !== null &&
(
await realpath(resolvedExecutable).catch(() => resolvedExecutable)
).includes(".app/");
const needsUpdate =
!appBundled &&
installed &&
currentVersion !== null &&
latestVersion !== null &&
Expand All @@ -146,7 +151,7 @@ export async function getCodexProviderInstallationStatus(
: compareVersions(currentVersion, minimumSupportedVersion) < 0);
const actionKind = !installed
? "install"
: (needsUpdate || versionUnsupported) && !appBundled
: needsUpdate || versionUnsupported
? "update"
: null;

Expand Down