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
64 changes: 64 additions & 0 deletions apps/server/src/provider/providerMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,70 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
),
);

// ChatGPT.app ships its own Codex CLI and updates it with the app; the
// bundled `codex update` exits with "Could not detect the Codex installation
// method", so an app-owned executable must stay manual-only.
it.effect("stays manual-only for an executable inside a macOS app bundle", () =>
Effect.gen(function* () {
const bundledPath = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex";
const capabilities = yield* resolvePackageManagedProviderMaintenance(
{
provider: driver("codex"),
npmPackageName: "@openai/codex",
nativeUpdate: {
args: ["update"],
isCommandPath: isNativeTestCommandPath("/packages/standalone/"),
},
},
{
binaryPath: bundledPath,
resolvedCommandPath: bundledPath,
realCommandPath: bundledPath,
env: {},
platform: "darwin",
},
).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));

expect(capabilities).toEqual({
provider: driver("codex"),
packageName: "@openai/codex",
update: null,
});
}),
);

// A launcher symlink can resolve outside the bundle while its target lives
// inside one; the real path alone must already make the install manual-only.
it.effect("stays manual-only when only the real path is inside a macOS app bundle", () =>
Effect.gen(function* () {
const bundledPath = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex";
const visiblePath = "/Users/test/.codex/packages/standalone/current/bin/codex";
const capabilities = yield* resolvePackageManagedProviderMaintenance(
{
provider: driver("codex"),
npmPackageName: "@openai/codex",
nativeUpdate: {
args: ["update"],
isCommandPath: isNativeTestCommandPath("/packages/standalone/"),
},
},
{
binaryPath: visiblePath,
resolvedCommandPath: visiblePath,
realCommandPath: bundledPath,
env: {},
platform: "darwin",
},
).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));

expect(capabilities).toEqual({
provider: driver("codex"),
packageName: "@openai/codex",
update: null,
});
}),
);

it.effect.skipIf(!symlinksSupported)(
"pins npm updates to the global prefix that owns the package",
() =>
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/providerMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ function isMiseCommandPath(commandPath: string): boolean {
return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/");
}

/** A provider executable shipped inside a macOS app bundle (`Foo.app/Contents/…`). */
function isMacAppBundleCommandPath(commandPath: string): boolean {
return normalizeCommandPath(commandPath).includes(".app/contents/");
}

function isPnpmGlobalCommandPath(commandPath: string): boolean {
const normalized = normalizeCommandPath(commandPath);
return (
Expand Down Expand Up @@ -415,6 +420,13 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
const packageName = definition.npmPackageName;

// A macOS app bundle ships and updates its own executables, and a bundled
// provider updater cannot detect that installation (ChatGPT.app's `codex
// update` refuses to run), so an app-owned executable stays manual-only.
if (commandPaths.some(isMacAppBundleCommandPath)) {
return manual;
}

const nativeUpdate = definition.nativeUpdate;
const native = nativeUpdate
? makeProviderMaintenanceCapabilities({
Expand Down
Loading