From aeef0efcbf49e402ee513591c10782c0e1439f4b Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 6 Oct 2026 11:53:52 +0800 Subject: [PATCH 1/2] fix(server): keep app-bundled provider executables manual-only for updates The maintenance resolver falls back to the provider's own updater for any install no package manager proves. ChatGPT.app's bundled Codex CLI is one of those installs, but its `codex update` cannot detect the installation method and exits 1 in ~25ms, so the one-click Update button always failed. Treat executables inside a macOS app bundle (`.app/Contents/`) as manual-only, next to the existing mise branch. The version advisory still reports the outdated version; the toast now offers Settings instead of a button that cannot work. --- .../src/provider/providerMaintenance.test.ts | 32 +++++++++++++++++++ .../src/provider/providerMaintenance.ts | 12 +++++++ 2 files changed, 44 insertions(+) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index b4bf670375ef..25fcb090df88 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -245,6 +245,38 @@ 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, + }); + }), + ); + 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({ From 349bb39a9c459cb3a4ccf0904bc4fe1f1668731f Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 6 Oct 2026 12:06:56 +0800 Subject: [PATCH 2/2] test(server): cover the real-path guard for app-bundled executables The app-bundle regression test set both the resolved and the real path to the bundle, so it would still pass if the guard only checked the resolved path. Add a case where the visible path is a standalone layout and only the real path resolves inside the bundle. --- .../src/provider/providerMaintenance.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 25fcb090df88..0612cbf51bb4 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -277,6 +277,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); + // 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", () =>