diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index b4bf670375ef..0612cbf51bb4 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -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("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/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("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/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", () => diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 606f9a460ba3..9261e6f52510 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -261,6 +261,11 @@ function isMiseCommandPath(commandPath: string): boolean { return normalized.includes("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/mise/installs/") || normalized.includes("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/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 ( @@ -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({